- Understand how class decorators work
- Create and apply decorators to classes
- Modify class behavior before instantiation
- Implement singleton and other class-level patterns
Class Decorators
Introduction
Imagine you're a factory manager. Instead of modifying each machine individually as it comes off the assembly line, you install an automated enhancement system that upgrades every machine before it leaves the factory. The machines themselves don't change - they just get better capabilities.
Class decorators work similarly to function decorators, but they modify entire classes rather than individual functions. They run when the class is defined, allowing you to enhance or modify class behavior before any instances are created.
How Class Decorators Work
A class decorator is a function that takes a class as input and returns a modified version of that class. Unlike function decorators that wrap function calls, class decorators modify the class itself.
Basic Class Decorator
def add_greeting(cls):
"""Add a greeting method to any class."""
cls.greet = lambda self: f"Hello from {cls.__name__}!"
return cls
@add_greeting
class Person:
def __init__(self, name):
self.name = name
person = Person("Alice")
print(person.greet()) # "Hello from Person!"
Manual Application
class Animal:
def __init__(self, species):
self.species = species
def add_sound(cls):
cls.make_sound = lambda self: f"{self.species} makes a sound!"
return cls
# Manual decoration
EnhancedAnimal = add_sound(Animal)
dog = EnhancedAnimal("Dog")
print(dog.make_sound()) # "Dog makes a sound!"
Singleton Pattern with Class Decorators
One of the most common uses of class decorators is implementing the singleton pattern - ensuring only one instance of a class exists.
def singleton(cls):
instances = {}
def get_instance(*args, **kwargs):
if cls not in instances:
instances[cls] = cls(*args, **kwargs)
return instances[cls]
return get_instance
@singleton
class DatabaseConnection:
def __init__(self, host, port):
self.host = host
self.port = port
print(f"Connecting to database at {host}:{port}")
def query(self, sql):
return f"Executing: {sql}"
# All instances are actually the same object
db1 = DatabaseConnection("localhost", 5432)
db2 = DatabaseConnection("remote", 5432)
print(db1 is db2) # True - same instance.
print(db1.query("SELECT * FROM users"))
print(db2.query("SELECT * FROM products"))
Adding Class Attributes and Methods
Class decorators can add new attributes and methods to classes dynamically.
def add_metadata(cls):
"""Add metadata attributes to a class."""
cls.created_at = "2024-01-01"
cls.version = "1.0"
cls.author = "Python Developer"
return cls
def add_logging(cls):
"""Add logging to all methods of a class."""
for attr_name in dir(cls):
attr = getattr(cls, attr_name)
if callable(attr) and not attr_name.startswith('_'):
setattr(cls, attr_name, log_method_calls(attr))
return cls
def log_method_calls(method):
def wrapper(*args, **kwargs):
print(f"Calling {method.__name__}")
result = method(*args, **kwargs)
print(f"{method.__name__} returned: {result}")
return result
return wrapper
@add_metadata
@add_logging
class Calculator:
def add(self, a, b):
return a + b
def multiply(self, a, b):
return a * b
calc = Calculator()
print(f"Version: {Calculator.version}")
result = calc.add(5, 3) # Logs the method call
Property Injection
Class decorators can add properties that behave like regular attributes but have custom logic.
def add_cached_property(cls):
"""Add a cached property to the class."""
def cached_value(self):
if not hasattr(self, '_cached_value'):
print("Computing expensive operation...")
self._cached_value = sum(range(1000000)) # Expensive operation
return self._cached_value
cls.expensive_property = property(cached_value)
return cls
@add_cached_property
class DataProcessor:
def __init__(self, data):
self.data = data
processor = DataProcessor([1, 2, 3, 4, 5])
print(processor.expensive_property) # Computes once
print(processor.expensive_property) # Uses cached value
Validation and Constraints
Class decorators can enforce constraints on class creation or usage.
def require_attributes(*required_attrs):
"""Ensure class has required attributes."""
def decorator(cls):
original_init = cls.__init__
def new_init(self, *args, **kwargs):
original_init(self, *args, **kwargs)
for attr in required_attrs:
if not hasattr(self, attr):
raise AttributeError(f"Class {cls.__name__} must have attribute '{attr}'")
cls.__init__ = new_init
return cls
return decorator
@require_attributes('name', 'age')
class Person:
def __init__(self, name, age, email=None):
self.name = name
self.age = age
self.email = email
# This works
person1 = Person("Alice", 30, "alice@example.com")
# This raises AttributeError
# person2 = Person("Bob", 25) # Missing required attributes
Method Enhancement
Class decorators can modify or enhance existing methods.
def add_method_validation(cls):
"""Add input validation to methods."""
for attr_name in dir(cls):
attr = getattr(cls, attr_name)
if callable(attr) and not attr_name.startswith('_'):
# Wrap method with validation
original_method = attr
def validated_method(self, *args, **kwargs):
# Add validation logic here
if hasattr(self, 'validate_input'):
self.validate_input(*args, **kwargs)
return original_method(self, *args, **kwargs)
setattr(cls, attr_name, validated_method)
return cls
@add_method_validation
class BankAccount:
def __init__(self, balance):
self.balance = balance
def validate_input(self, amount, **kwargs):
if amount < 0:
raise ValueError("Amount cannot be negative")
def deposit(self, amount):
self.balance += amount
return self.balance
def withdraw(self, amount):
if amount > self.balance:
raise ValueError("Insufficient funds")
self.balance -= amount
return self.balance
account = BankAccount(1000)
account.deposit(500) # Validated
# account.deposit(-100) # Raises ValueError
Real-World Applications
API Client with Automatic Retry
def add_retry_logic(max_retries=3):
def decorator(cls):
original_methods = {}
# Store original methods
for attr_name in dir(cls):
attr = getattr(cls, attr_name)
if callable(attr) and not attr_name.startswith('_'):
original_methods[attr_name] = attr
# Replace methods with retry logic
for method_name, original_method in original_methods.items():
def retry_method(self, *args, **kwargs):
method = original_methods[method_name]
for attempt in range(max_retries):
try:
return method(self, *args, **kwargs)
except Exception as e:
if attempt == max_retries - 1:
raise e
print(f"Attempt {attempt + 1} failed, retrying...")
time.sleep(1)
setattr(cls, method_name, retry_method.__get__(None, cls))
return cls
return decorator
@add_retry_logic(max_retries=3)
class APIClient:
def fetch_data(self, endpoint):
# Simulate occasional failures
if random.random() < 0.7:
raise ConnectionError("Network error")
return f"Data from {endpoint}"
client = APIClient()
data = client.fetch_data("/api/users") # Automatically retries on failure
Data Class with Automatic Methods
def dataclass(cls):
"""Simple dataclass decorator that adds __repr__, __eq__, and __hash__."""
def __repr__(self):
attrs = [f"{k}={getattr(self, k)!r}" for k in self.__dict__]
return f"{cls.__name__}({', '.join(attrs)})"
def __eq__(self, other):
if not isinstance(other, cls):
return False
return self.__dict__ == other.__dict__
def __hash__(self):
return hash(tuple(sorted(self.__dict__.items())))
cls.__repr__ = __repr__
cls.__eq__ = __eq__
cls.__hash__ = __hash__
return cls
@dataclass
class Point:
def __init__(self, x, y):
self.x = x
self.y = y
p1 = Point(1, 2)
p2 = Point(1, 2)
p3 = Point(3, 4)
print(p1) # Point(x=1, y=2)
print(p1 == p2) # True
print(p1 == p3) # False
print(hash(p1)) # Can be used in sets
Class Decorator vs Function Decorator
| Aspect | Function Decorator | Class Decorator |
|---|---|---|
| Input | Function object | Class object |
| Execution | When function is called | When class is defined |
| Modifies | Function behavior | Class structure |
| Use cases | Logging, caching, validation | Singletons, metadata, method injection |
Best Practices
1. Preserve Class Identity
def my_class_decorator(cls):
# Store original class for isinstance() checks
cls.__original_class__ = cls
# ... modifications ...
return cls
2. Document Your Decorators
def singleton(description="Ensures only one instance exists"):
"""
Singleton class decorator.
Args:
description: Description of the singleton behavior
Returns:
Decorated class that maintains single instance
"""
def decorator(cls):
# Implementation
return cls
return decorator
3. Consider Alternatives
Sometimes inheritance or metaclasses might be better than decorators:
# Instead of decorator, consider inheritance
class SingletonMeta(type):
def __call__(cls, *args, **kwargs):
# Singleton logic
pass
class MyClass(metaclass=SingletonMeta):
pass
Key Points to Remember
- Class decorators modify entire classes, not just individual functions
- They execute when the class is defined, before any instances exist
- Common uses include singletons, method injection, and validation
- Can add attributes, methods, or completely replace the class
- Often used for cross-cutting concerns that affect entire classes
- Consider alternatives like metaclasses for complex class modifications
Class decorators are powerful, but Python offers even more advanced decorator patterns. In the next lesson, we'll explore advanced decorators with functools and learn about decorator factories, nested decorators, and complex decorator patterns.
