Class Decorators¶
Decorators can be applied to classes, and classes can be used as decorators.
Mental Model
A class decorator intercepts a class at creation time, modifies or wraps it, and hands it back -- just like a function decorator, but the input is a class object instead of a function. Think of it as a factory inspector who stamps extra features onto each class as it rolls off the assembly line.
Decorating Classes¶
A class decorator receives a class and returns a modified class.
Adding Methods¶
```python def add_repr(cls): """Add a repr method to a class.""" def repr(self): attrs = ', '.join(f'{k}={v!r}' for k, v in vars(self).items()) return f"{cls.name}({attrs})" cls.repr = repr return cls
@add_repr class Point: def init(self, x, y): self.x = x self.y = y
p = Point(3, 4) print(p) # Point(x=3, y=4) ```
Adding Class Attributes¶
```python def singleton(cls): """Make a class a singleton.""" instances = {}
def get_instance(*args, **kwargs):
if cls not in instances:
instances[cls] = cls(*args, **kwargs)
return instances[cls]
return get_instance
@singleton class Database: def init(self): print("Connecting to database...")
db1 = Database() # Prints message db2 = Database() # No message (same instance) print(db1 is db2) # True ```
Registering Classes¶
```python registry = {}
def register(cls): """Register a class in the global registry.""" registry[cls.name] = cls return cls
@register class Handler: pass
@register class Processor: pass
print(registry) # {'Handler':
Classes as Decorators¶
A class can act as a decorator by implementing __init__ and __call__.
Basic Pattern¶
```python class CountCalls: """Decorator class that counts function calls."""
def __init__(self, func):
self.func = func
self.count = 0
def __call__(self, *args, **kwargs):
self.count += 1
print(f"Call {self.count} of {self.func.__name__}")
return self.func(*args, **kwargs)
@CountCalls def greet(name): print(f"Hello, {name}!")
greet("Alice") # Call 1 of greet, Hello, Alice! greet("Bob") # Call 2 of greet, Hello, Bob! print(greet.count) # 2 ```
With Parameters (Factory Pattern)¶
```python class Repeat: """Decorator class with parameters."""
def __init__(self, times):
self.times = times
def __call__(self, func):
def wrapper(*args, **kwargs):
for _ in range(self.times):
result = func(*args, **kwargs)
return result
return wrapper
@Repeat(3) def say_hello(): print("Hello!")
say_hello() # Prints "Hello!" 3 times ```
Stateful Decorator Class¶
```python class Memoize: """Caching decorator implemented as a class."""
def __init__(self, func):
self.func = func
self.cache = {}
def __call__(self, *args):
if args not in self.cache:
self.cache[args] = self.func(*args)
return self.cache[args]
def clear_cache(self):
self.cache.clear()
@Memoize def fibonacci(n): if n < 2: return n return fibonacci(n - 1) + fibonacci(n - 2)
print(fibonacci(100)) # Fast due to caching print(fibonacci.cache) # View cached values fibonacci.clear_cache() # Clear the cache ```
Preserving Method Behavior¶
When decorating methods, use __get__ to handle the descriptor protocol:
```python from functools import wraps
class Logger: """Decorator that works with both functions and methods."""
def __init__(self, func):
self.func = func
wraps(func)(self)
def __call__(self, *args, **kwargs):
print(f"Calling {self.func.__name__}")
return self.func(*args, **kwargs)
def __get__(self, obj, objtype=None):
if obj is None:
return self
# Return a bound method
from functools import partial
return partial(self.__call__, obj)
class MyClass: @Logger def method(self, x): return x * 2
obj = MyClass() print(obj.method(5)) # Works correctly with self ```
Comparison: Function vs Class Decorators¶
| Aspect | Function Decorator | Class Decorator |
|---|---|---|
| State | Closure variables | Instance attributes |
| Methods | Not applicable | Can add helper methods |
| Readability | Simpler for basic cases | Better for complex state |
| Instance check | Not applicable | isinstance(decorated, DecoratorClass) |
When to Use Class Decorators¶
- Need complex state management
- Want to expose additional methods
- Need to work with the descriptor protocol
- Want cleaner organization for complex decorators
When to Use Function Decorators¶
- Simple transformations
- No complex state needed
- Prefer functional style
- Need to work with
functools.wrapseasily
Practical Examples¶
Timing Decorator Class¶
```python import time
class Timer: """Time function execution with statistics."""
def __init__(self, func):
self.func = func
self.times = []
def __call__(self, *args, **kwargs):
start = time.perf_counter()
result = self.func(*args, **kwargs)
elapsed = time.perf_counter() - start
self.times.append(elapsed)
return result
@property
def average_time(self):
return sum(self.times) / len(self.times) if self.times else 0
@property
def total_time(self):
return sum(self.times)
@Timer def slow_function(): time.sleep(0.1)
for _ in range(5): slow_function()
print(f"Average: {slow_function.average_time:.3f}s") print(f"Total: {slow_function.total_time:.3f}s") ```
Validation Decorator Class¶
```python class ValidateArgs: """Validate function arguments against type specifications."""
def __init__(self, **type_specs):
self.type_specs = type_specs
def __call__(self, func):
def wrapper(*args, **kwargs):
# Validate keyword arguments
for name, expected_type in self.type_specs.items():
if name in kwargs:
if not isinstance(kwargs[name], expected_type):
raise TypeError(
f"{name} must be {expected_type.__name__}"
)
return func(*args, **kwargs)
return wrapper
@ValidateArgs(name=str, age=int) def create_user(name, age): return {'name': name, 'age': age}
create_user(name="Alice", age=30) # Works create_user(name="Bob", age="30") # TypeError ```
Summary¶
| Pattern | Description |
|---|---|
| Class decorator | Decorator applied to a class |
| Decorator class | Class that acts as a decorator |
__init__ |
Receives the function/parameters |
__call__ |
Called when decorated function is invoked |
__get__ |
For method decoration (descriptor protocol) |
Key Points:
- Class decorators modify or enhance classes
- Decorator classes provide stateful decorators
- Use
__get__for method compatibility - Choose based on complexity and state requirements
Runnable Example: singleton_decorator_example.py¶
```python """ Singleton Pattern: Decorator vs Metaclass Approaches
The singleton pattern ensures a class has only one instance. This tutorial shows how to implement it using a class decorator with thread-safety via double-checked locking.
Topics covered: - Class decorators (decorators applied to classes) - Singleton pattern implementation - Thread safety with threading.Lock - functools.wraps on classes - Comparison: decorator vs metaclass singleton
Based on concepts from Python-100-Days examples 10 & 18, and ch05/decorators materials. """
import threading from functools import wraps
=============================================================================¶
Example 1: Thread-Safe Singleton Decorator¶
=============================================================================¶
def singleton(cls): """Decorator that makes a class a singleton (only one instance ever created).
Uses double-checked locking for thread safety:
1. First check without lock (fast path for existing instance)
2. Acquire lock and check again (prevent race condition)
>>> @singleton
... class Database:
... def __init__(self, url):
... self.url = url
>>> db1 = Database("localhost:5432")
>>> db2 = Database("localhost:3306") # Returns same instance!
>>> db1 is db2
True
"""
instances = {}
lock = threading.Lock()
@wraps(cls)
def get_instance(*args, **kwargs):
if cls not in instances: # Fast check (no lock)
with lock: # Acquire lock
if cls not in instances: # Double-check under lock
instances[cls] = cls(*args, **kwargs)
return instances[cls]
return get_instance
=============================================================================¶
Example 2: Singleton in Action¶
=============================================================================¶
@singleton class AppConfig: """Application configuration (should only exist once)."""
def __init__(self, debug=False, db_url="sqlite:///app.db"):
self.debug = debug
self.db_url = db_url
def __str__(self):
return f"AppConfig(debug={self.debug}, db_url='{self.db_url}')"
def demo_singleton(): """Demonstrate that singleton always returns the same instance.""" print("=== Singleton Decorator Demo ===")
config1 = AppConfig(debug=True, db_url="postgres://localhost/mydb")
config2 = AppConfig(debug=False, db_url="mysql://localhost/other")
print(f"config1: {config1}")
print(f"config2: {config2}")
print(f"Same object? {config1 is config2}") # True
print(f"Class name preserved: {AppConfig.__name__}")
print()
=============================================================================¶
Example 3: Thread-Safety Verification¶
=============================================================================¶
@singleton class Counter: """Thread-safe singleton counter."""
def __init__(self):
self.count = 0
self._lock = threading.Lock()
def increment(self):
with self._lock:
self.count += 1
def demo_thread_safety(): """Verify singleton works correctly under concurrent access.""" print("=== Thread Safety Verification ===")
def worker():
c = Counter() # Always gets the same instance
for _ in range(1000):
c.increment()
threads = [threading.Thread(target=worker) for _ in range(10)]
for t in threads:
t.start()
for t in threads:
t.join()
counter = Counter()
print(f"Expected count: 10000")
print(f"Actual count: {counter.count}")
print(f"Thread safe: {counter.count == 10000}")
print()
=============================================================================¶
Example 4: Metaclass Alternative (for comparison)¶
=============================================================================¶
class SingletonMeta(type): """Metaclass approach to singleton pattern.
Instead of decorating the class, we use a custom metaclass that
intercepts instance creation via __call__.
"""
def __init__(cls, *args, **kwargs):
cls._instance = None
cls._lock = threading.Lock()
super().__init__(*args, **kwargs)
def __call__(cls, *args, **kwargs):
if cls._instance is None:
with cls._lock:
if cls._instance is None:
cls._instance = super().__call__(*args, **kwargs)
return cls._instance
class Logger(metaclass=SingletonMeta): """Logger using metaclass singleton."""
def __init__(self, name="default"):
self.name = name
self.messages = []
def log(self, message):
self.messages.append(message)
def __str__(self):
return f"Logger('{self.name}', {len(self.messages)} messages)"
def demo_metaclass_singleton(): """Compare metaclass singleton with decorator singleton.""" print("=== Metaclass Singleton Comparison ===")
log1 = Logger("app")
log1.log("Started")
log2 = Logger("other") # Returns same instance
log2.log("Continued")
print(f"log1: {log1}")
print(f"log2: {log2}")
print(f"Same object? {log1 is log2}")
print()
print("Decorator singleton:")
print(" + Simple to apply (@singleton)")
print(" + Works with functools.wraps")
print(" - isinstance() won't work (returns function)")
print()
print("Metaclass singleton:")
print(" + isinstance() works correctly")
print(" + More 'proper' OOP approach")
print(" - More complex to understand")
print(" - Can't combine with other metaclasses easily")
=============================================================================¶
Main¶
=============================================================================¶
if name == 'main': demo_singleton() demo_thread_safety() demo_metaclass_singleton() ```
Exercises¶
Exercise 1.
Write a class decorator add_str that adds a __str__ method to any class. The generated __str__ should return the class name followed by all instance attributes in key=value format. Apply it to a Book class with title and author attributes and verify the output.
Solution to Exercise 1
def add_str(cls):
"""Add a __str__ method that shows all instance attributes."""
def __str__(self):
attrs = ', '.join(f'{k}={v!r}' for k, v in vars(self).items())
return f"{cls.__name__}({attrs})"
cls.__str__ = __str__
return cls
@add_str
class Book:
def __init__(self, title, author):
self.title = title
self.author = author
b = Book("1984", "Orwell")
print(b) # Book(title='1984', author='Orwell')
Exercise 2.
Create a decorator class RateLimiter that limits how many times a decorated function can be called within a given number of seconds. The constructor should accept a max_calls parameter (default 3). If the function is called more than max_calls times within one second, raise a RuntimeError. Expose a reset() method to clear the call history.
Solution to Exercise 2
import time
class RateLimiter:
"""Decorator class that limits function call frequency."""
def __init__(self, max_calls=3):
self.max_calls = max_calls
self.call_times = []
def __call__(self, func):
def wrapper(*args, **kwargs):
now = time.time()
# Keep only calls within the last second
self.call_times = [t for t in self.call_times if now - t < 1.0]
if len(self.call_times) >= self.max_calls:
raise RuntimeError(
f"Rate limit exceeded: {self.max_calls} calls per second"
)
self.call_times.append(now)
return func(*args, **kwargs)
wrapper.reset = self.reset
return wrapper
def reset(self):
self.call_times.clear()
@RateLimiter(max_calls=2)
def ping():
return "pong"
print(ping()) # pong
print(ping()) # pong
try:
ping() # RuntimeError
except RuntimeError as e:
print(e)
Exercise 3.
Write a class decorator track_instances that adds instance tracking to any class. The decorated class should gain a class-level instances list that holds weak references (or plain references) to every object created, and a class method get_instance_count() that returns the current number of tracked instances. Demonstrate it with a Player class.
Solution to Exercise 3
def track_instances(cls):
"""Class decorator that tracks all created instances."""
original_init = cls.__init__
cls.instances = []
def new_init(self, *args, **kwargs):
original_init(self, *args, **kwargs)
cls.instances.append(self)
cls.__init__ = new_init
@classmethod
def get_instance_count(klass):
return len(klass.instances)
cls.get_instance_count = get_instance_count
return cls
@track_instances
class Player:
def __init__(self, name):
self.name = name
p1 = Player("Alice")
p2 = Player("Bob")
p3 = Player("Charlie")
print(Player.get_instance_count()) # 3