23.7 Decorating Classes
Decorators provide a powerful mechanism to modify or enhance class behavior without resorting to inheritance. When applied to classes, decorators receive the class object itself as their argument, allowing them to inspect, modify, or even completely replace the original class definition. This approach is particularly valuable for implementing cross-cutting concerns like logging, validation, registration, and data transformation across multiple classes.
Basic Class Decoration Syntax
A class decorator is a function that takes a class and returns a modified class or a new class. The syntax mirrors function decoration, using the @ symbol immediately before the class definition.
def add_method(cls):
def greet(self):
return f"Hello from {self.__class__.__name__}!"
cls.greet = greet
return cls
@add_method
class MyClass:
def __init__(self, value):
self.value = value
obj = MyClass(42)
print(obj.greet()) # Output: Hello from MyClass!
In this example, add_method receives the MyClass object before any instances are created. The decorator dynamically adds a new method greet to the class and returns the modified class. This demonstrates how decorators can augment class functionality without altering the original source code.
Modifying Class Attributes
Class decorators can inspect and alter existing attributes, such as adding new methods, modifying existing ones, or even wrapping methods with additional functionality.
def log_method_calls(cls):
for attr_name in dir(cls):
attr_value = getattr(cls, attr_name)
if callable(attr_value) and not attr_name.startswith('__'):
def make_logged_method(original_method):
def logged_method(self, *args, **kwargs):
print(f"Calling {original_method.__name__} with args: {args}, kwargs: {kwargs}")
result = original_method(self, *args, **kwargs)
print(f"{original_method.__name__} returned: {result}")
return result
return logged_method
setattr(cls, attr_name, make_logged_method(attr_value))
return cls
@log_method_calls
class Calculator:
def add(self, a, b):
return a + b
def multiply(self, a, b):
return a * b
calc = Calculator()
calc.add(2, 3)
# Output:
# Calling add with args: (2, 3), kwargs: {}
# add returned: 5
This decorator iterates through all class attributes, identifies callable methods (excluding special methods), and replaces them with wrapped versions that log their execution. This pattern is extremely useful for adding debugging or monitoring capabilities across an entire class.
Replacing the Class Entirely
More advanced decorators can return a completely different class, effectively substituting the original implementation. This is particularly powerful when combined with closures or factories.
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, connection_string):
self.connection_string = connection_string
print(f"Connecting to {connection_string}")
# The decorator returns a function, not a class
conn1 = DatabaseConnection("postgresql://localhost:5432")
conn2 = DatabaseConnection("mysql://localhost:3306")
print(conn1 is conn2) # Output: True
print(conn1.connection_string) # Output: postgresql://localhost:5432
Here, the singleton decorator doesn’t return a class but rather a function (get_instance) that manages instance creation. When DatabaseConnection is called, it actually invokes get_instance, which ensures only one instance exists per class. This pattern fundamentally changes the class’s behavior from a constructor to a factory function.
Decorator Parameters
Parameterized decorators require an extra layer of nesting to accept arguments before receiving the class. This enables highly configurable class transformations.
def add_attributes(**kwargs):
def decorator(cls):
for key, value in kwargs.items():
setattr(cls, key, value)
return cls
return decorator
@add_attributes(version="1.0", author="Jane Doe")
class MyDocument:
def __init__(self, content):
self.content = content
doc = MyDocument("Hello World")
print(f"Document version: {MyDocument.version}, Author: {MyDocument.author}")
# Output: Document version: 1.0, Author: Jane Doe
The outer function add_attributes accepts keyword arguments and returns the actual decorator function, which then receives the class. This two-step process allows the decorator to be configured with specific parameters that influence how it modifies the class.
Common Pitfalls and Best Practices
A critical pitfall involves decorators that return a different class: method resolution order (MRO) can become problematic if the new class doesn’t properly inherit from the original. Always consider whether to use inheritance (class Wrapper(cls)) or composition (storing the original class as an attribute) when replacing a class.
Another subtle issue involves the timing of decoration. Class decorators execute immediately after the class definition is processed, which occurs at import time. Avoid performing expensive operations or I/O in decorators unless absolutely necessary, as this can slow down module loading.
When wrapping methods, preserve the original method’s metadata by using functools.wraps (though designed for functions, similar principles apply). For classes, consider copying the __dict__ or using a metaclass if precise metadata preservation is crucial.
Always document decorated classes thoroughly, as the decorator’s modifications aren’t visible in the class’s source code. The behavior of the class becomes dependent on external decorator functions, which can make debugging more challenging if not properly documented.