Skip to content

Class Decorators

Decorators can be applied to classes, and classes can be used as decorators.

Decorating Classes

A class decorator receives a class and returns a modified class.

Adding Methods

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

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

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': <class 'Handler'>, 'Processor': <class 'Processor'>}

Classes as Decorators

A class can act as a decorator by implementing __init__ and __call__.

Basic Pattern

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)

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

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:

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.wraps easily

Practical Examples

Timing Decorator Class

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

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

"""
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()