Previous | Tutorial index | Next

📘 TUTORIAL 9: USING CLASS AS A DECORATOR

Learning Objective

Learn how to define decorators using classes instead of functions.

9.1 What Are Decorators? A Quick Refresher

Decorators are a powerful design pattern in Python that allows you to modify or extend the behavior of functions or classes without changing their source code. They are a form of metaprogramming – code that writes or modifies other code.

Function-Based Decorator (Review)

A function-based decorator is a function that takes another function as an argument, wraps it, and returns the wrapped function.

def logger(func): def wrapper(*args, **kwargs): print(f"Calling {func.__name__}") return func(*args, **kwargs) return wrapper @logger def greet(name): return f"Hello, {name}!"

This works, but function-based decorators that need to maintain state (e.g., a counter) can become messy with nonlocal variables.

9.2 Why Use a Class as a Decorator?

A class-based decorator offers several advantages over a function-based one:

Feature Function-Based Decorator Class-Based Decorator
State Management Requires nonlocal or mutable containers (e.g., lists) to maintain state. State is stored naturally as instance attributes (self.count, self.data).
Readability Nested functions (wrapper) can become hard to read for complex logic. Methods (__init__, __call__) are clearly separated and easier to understand.
Initialization Can take arguments, but requires nested function layers. __init__ handles arguments cleanly; __call__ handles the wrapper logic.
Extensibility Harder to add methods or sub-decorators. Easy to add helper methods (reset(), enable(), etc.).
Inheritance Not applicable. Can be subclassed to create variants of the decorator.

Key Insight: A class can be a decorator if it implements __call__. When you write @MyDecorator, Python calls MyDecorator(func) to create an instance, and then when the decorated function is called, Python calls the instance's __call__ method.

9.3 How a Class-Based Decorator Works

The Mechanism

  1. __init__(self, func) – The class receives the decorated function (or class) as an argument and stores it.
  2. __call__(self, *args, **kwargs) – This makes the instance callable. When the decorated function is invoked, Python executes __call__, which can add behavior before/after calling the stored function.

Basic Template

class MyDecorator: def __init__(self, func): self.func = func # Optional: initialize state def __call__(self, *args, **kwargs): # Before-call logic result = self.func(*args, **kwargs) # After-call logic return result

Example: A Counting Decorator

class CountCalls: 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 say_hello(): print("Hello!") say_hello() # Call 1 of say_hello say_hello() # Call 2 of say_hello

What happens:

9.4 Preserving Metadata with functools.wraps

Like function-based decorators, class-based decorators can lose the original function's metadata (name, docstring, annotations). You can fix this by using functools.wraps inside __call__, or by using functools.update_wrapper in __init__.

Method 1: Using wraps in __call__

from functools import wraps class CountCalls: def __init__(self, func): self.func = func self.count = 0 def __call__(self, *args, **kwargs): @wraps(self.func) def wrapper(*args, **kwargs): self.count += 1 print(f"Call {self.count} of {self.func.__name__}") return self.func(*args, **kwargs) return wrapper(*args, **kwargs)

Method 2: Using update_wrapper in __init__

from functools import update_wrapper class CountCalls: def __init__(self, func): self.func = func self.count = 0 update_wrapper(self, func) # Copies metadata from func to self def __call__(self, *args, **kwargs): self.count += 1 print(f"Call {self.count} of {self.func.__name__}") return self.func(*args, **kwargs)

Best Practice: Use update_wrapper in __init__ – it's cleaner and ensures that the decorator instance itself looks like the original function when inspected.

9.5 Decorating Functions with Arguments

Class-based decorators can also accept arguments to customize behavior.

Example: A Repeat Decorator

class Repeat: def __init__(self, times): self.times = times def __call__(self, func): self.func = func def wrapper(*args, **kwargs): for _ in range(self.times): result = self.func(*args, **kwargs) return result return wrapper @Repeat(times=3) def greet(name): print(f"Hello, {name}!") greet("Alice") # Prints "Hello, Alice!" three times

How it works:

9.6 Decorating Instance Methods

Class-based decorators can also decorate instance methods. The decorator's __call__ receives self as the first argument (since the method is bound).

Example: A Method Timer

import time class Timer: def __init__(self, func): self.func = func def __call__(self, *args, **kwargs): start = time.time() result = self.func(*args, **kwargs) end = time.time() print(f"{self.func.__name__} took {end - start:.4f}s") return result class Calculator: @Timer def slow_square(self, n): time.sleep(0.5) return n ** 2 calc = Calculator() print(calc.slow_square(10)) # Output: slow_square took 0.5001s, then 100

Important: The decorator's __call__ receives self as the first argument because calc.slow_square is a bound method, and Python automatically passes calc as the first argument. So *args includes self and any other arguments.

9.7 Decorating Classes Themselves

You can also use class-based decorators to modify classes, not just functions. In this case, __init__ receives a class, not a function.

Example: Adding a __repr__ to a Class Automatically

class AddRepr: def __init__(self, cls): self.cls = cls def __call__(self, *args, **kwargs): # Create an instance instance = self.cls(*args, **kwargs) # Add a __repr__ method instance.__repr__ = lambda self: f"{self.__class__.__name__}({self.__dict__})" return instance @AddRepr class Person: def __init__(self, name, age): self.name = name self.age = age p = Person("Alice", 30) print(p) # Person({'name': 'Alice', 'age': 30})

Note: This example modifies the instance, not the class itself. A more common pattern is to modify the class before instantiation:

class AddClassRepr: def __init__(self, cls): self.cls = cls def __call__(self, *args, **kwargs): # Create an instance of the decorated class return self.cls(*args, **kwargs) # But we want to modify the class itself, so we use __new__ or metaclasses. # A simpler approach: decorate the class before instantiation.

Better Example: Add a class attribute or method to the class.

class AddGreeting: def __init__(self, cls): self.cls = cls def __call__(self, *args, **kwargs): # Modify the class *before* instantiation self.cls.greet = lambda self: f"Hello from {self.__class__.__name__}" return self.cls(*args, **kwargs) @AddGreeting class Person: def __init__(self, name): self.name = name p = Person("Alice") print(p.greet()) # Hello from Person

Note: This is not a typical decorator usage; often class decorators are used to register classes in a registry, add methods, or apply class-level metadata.

9.8 The @property Decorator – It's a Class

Did you know that @property is a class, not a function? The property built-in is a class that implements __init__, __call__ (as a decorator), getter, setter, and deleter.

Simplified View

class property: def __init__(self, fget=None, fset=None, fdel=None, doc=None): self.fget = fget self.fset = fset self.fdel = fdel # ... def __call__(self, func): # Called when @property is used as a decorator # Returns a property instance with the getter set return property(func, self.fset, self.fdel, self.doc) def setter(self, func): # Returns a new property with the setter set return property(self.fget, func, self.fdel, self.doc) # ... etc.

This is a beautiful example of a class-based decorator with methods (setter, getter, deleter) that return new property instances.

9.9 Parameterized Class Decorators (Advanced)

Sometimes you want a decorator that takes parameters and can be used with or without parentheses (like @timer vs @timer(unit='ms')).

Example: A Timed Decorator with Parameter

import time class Timed: def __init__(self, func=None, unit='s'): self.func = func self.unit = unit def __call__(self, *args, **kwargs): if self.func is None: # Called as @Timed(unit='ms') – returns a new instance with func bound later return Timed(unit=self.unit) # Called as @Timed or as a normal call start = time.time() result = self.func(*args, **kwargs) elapsed = time.time() - start if self.unit == 'ms': elapsed *= 1000 unit_label = 'ms' else: unit_label = 's' print(f"{self.func.__name__} took {elapsed:.4f} {unit_label}") return result # Usage with and without parameters @Timed def slow_func(): time.sleep(0.5) @Timed(unit='ms') def fast_func(): time.sleep(0.01) slow_func() # slow_func took 0.5001 s fast_func() # fast_func took 10.0012 ms

How it works:

This pattern is complex but very powerful.

9.10 Common Pitfalls with Class-Based Decorators

Pitfall Explanation How to Avoid
Forgetting to return the wrapper from __call__ If __call__ doesn't return a callable, the decorated function won't work. Always return a callable (usually wrapper) or use the instance itself as the wrapper.
Not preserving metadata The decorated function loses its name, docstring, etc. Use functools.update_wrapper(self, func) in __init__.
Using self incorrectly in __call__ __call__ must handle self correctly when decorating methods. Use *args, **kwargs and pass them properly.
Decorating a method but forgetting that self is the first argument The decorator's __call__ receives self as the first arg. Ensure your wrapper handles *args correctly.
Class decorator modifying the class incorrectly If you intend to modify the class, do it before instantiation, not in __call__. Use __new__ or modify the class in __init__ and return the class.
Confusing @Decorator with @Decorator() If your decorator takes arguments, you need the parentheses. Be explicit: use @Decorator for no arguments, @Decorator() for parameterized.

9.11 Full Walkthrough Example: A Retry Decorator

Let's build a practical class-based decorator that retries a function on failure.

import time from functools import update_wrapper class Retry: """ Decorator that retries a function on exception. Args: attempts (int): Max number of attempts. delay (float): Delay in seconds between attempts. exceptions (tuple): Exceptions to catch and retry on. """ def __init__(self, func=None, attempts=3, delay=1.0, exceptions=(Exception,)): self.func = func self.attempts = attempts self.delay = delay self.exceptions = exceptions if func is not None: update_wrapper(self, func) # Preserve metadata def __call__(self, *args, **kwargs): if self.func is None: # Called as @Retry(attempts=5) – return a new instance return Retry(attempts=self.attempts, delay=self.delay, exceptions=self.exceptions) # Normal call: execute the function with retry logic last_exception = None for attempt in range(1, self.attempts + 1): try: return self.func(*args, **kwargs) except self.exceptions as e: last_exception = e if attempt < self.attempts: print(f"Attempt {attempt} failed. Retrying in {self.delay}s...") time.sleep(self.delay) else: print(f"All {self.attempts} attempts failed.") # If we get here, all attempts failed raise last_exception # For parameterized decorator (if used without parentheses) # We need to handle this correctly. # See the pattern above. # Usage @Retry(attempts=3, delay=0.5) def unstable_network_call(): import random if random.random() < 0.7: raise ConnectionError("Network failed") return "Success!" # Another example without parameters @Retry def simple_retry(): if random.random() < 0.5: raise ValueError("Something went wrong") return "OK" # Test the decorator print(unstable_network_call()) # May retry a couple times print(simple_retry()) # Uses default (3 attempts, 1s delay)

Observations:

📝 Quiz 9: Class-Based Decorators

Answer the following questions to check your understanding.

1. Which special method must a class implement to be used as a decorator?

Answer(B) `__call__`

2. When you write @MyDecorator, what does Python do?

Answer(B) It calls `MyDecorator(func)` and replaces `func` with the instance.

3. How do you preserve the original function's name and docstring in a class‑based decorator?

Answer(A) Use `functools.update_wrapper(self, func)` in `__init__` (or (D) is also acceptable but (A) is the recommended approach).

4. (True/False) A class‑based decorator can maintain state (e.g., a counter) across multiple calls of the decorated function.

Answer(A) True

5. Consider this code:

class Timer: def __init__(self, func): self.func = func def __call__(self, *args, **kwargs): # ...

If you decorate a method my_method with @Timer, what is passed to __call__ when my_method is called on an instance?

Answer(A) The instance (`self`) and any arguments.

6. How do you create a parameterized class‑based decorator (e.g., @Retry(attempts=3))?

Answer(A) Define `__init__` to accept the parameters and `__call__` to accept the function.

7. Which of the following is an advantage of class‑based decorators over function‑based decorators?

Answer(B) They handle state more naturally through instance attributes.

8. What does the property class use to implement @property.setter?

Answer(B) Instance methods that return new `property` objects.

9. Consider this code:

class MyDec: def __init__(self, func): self.func = func def __call__(self, x): return self.func(x) * 2 @MyDec def square(n): return n * n print(square(5))

What is the output?

Answer(B) `50` – `square(5)` returns 25, then `__call__` multiplies by 2.

10. What will happen if you forget to return a callable from __call__ in a class‑based decorator?

Answer(B) It will raise a `TypeError` when the decorated function is called because the result is not callable.
Below is the revised **Exercise 9** and **Homework 9** text with sample answers added. Each sample answer is provided inside a `
` block so that students can review them after attempting the task on their own.

🧪 Exercise 9: Building Custom Class Decorators

Part A: A Timing Decorator
Create a class-based decorator Timer that:

Part B: A Debugging Decorator
Create a class-based decorator Debug that:

Part C: Testing
Decorate three different functions (e.g., add, multiply, greet) with your Timer and Debug decorators, and call them to demonstrate the output.

Sample Answers (Exercise 9)

Part A: Timer Decorator

import time from functools import update_wrapper class Timer: def __init__(self, func=None, unit='s'): self.func = func self.unit = unit if func is not None: update_wrapper(self, func) def __call__(self, *args, **kwargs): if self.func is None: # Called as @Timer(unit='ms') return Timer(unit=self.unit) start = time.time() result = self.func(*args, **kwargs) elapsed = time.time() - start if self.unit == 'ms': elapsed *= 1000 unit_label = 'ms' else: unit_label = 's' print(f"{self.func.__name__} took {elapsed:.4f} {unit_label}") return result

Part B: Debug Decorator

from functools import update_wrapper class Debug: def __init__(self, func): self.func = func self.count = 0 update_wrapper(self, func) def __call__(self, *args, **kwargs): self.count += 1 # Build a readable arguments string args_repr = ', '.join(repr(a) for a in args) kwargs_repr = ', '.join(f"{k}={v!r}" for k, v in kwargs.items()) all_args = args_repr if kwargs_repr: all_args += (', ' if args_repr else '') + kwargs_repr result = self.func(*args, **kwargs) print(f"Calling {self.func.__name__}({all_args}) -> {result!r}") return result

Part C: Testing

@Timer def add(a, b): return a + b @Timer(unit='ms') def multiply(a, b): return a * b @Debug def greet(name): return f"Hello, {name}!" @Debug @Timer def slow_add(a, b): time.sleep(0.1) return a + b print(add(3, 5)) # timed in seconds print(multiply(4, 7)) # timed in milliseconds print(greet("Alice")) # debug output with count print(slow_add(10, 20)) # both debug and timer (order matters) print(f"Debug count: {greet.count}") # access count attribute

🏠 Homework 9: Building a Cache Decorator

Task: Implement a class-based decorator Cache that caches the results of a function based on its arguments.

Specifications

Class Cache:

Metadata Preservation: Use functools.update_wrapper to preserve the function's name, docstring, and annotations.

Cache Key Generation: For simplicity, use a tuple (args, tuple(sorted(kwargs.items()))) as the key. If kwargs are present, include them.

Example Usage

@Cache(max_size=2) def slow_square(n): import time time.sleep(0.5) return n * n print(slow_square(5)) # Computes, takes ~0.5s print(slow_square(5)) # Returns from cache, instant print(slow_square(6)) # Computes, takes ~0.5s print(slow_square(5)) # Returns from cache print(slow_square.stats()) # {'hits': 2, 'misses': 2, 'size': 2} slow_square.clear() print(slow_square.stats()) # {'hits': 0, 'misses': 0, 'size': 0}

Part 3: Reflection Questions (these appear in the original Tutorial 9)

  1. Why is a class-based decorator well-suited for caching compared to a function-based one?
  2. How does the cache key generation handle functions that take mutable arguments like lists? (Hint: If you pass a list, it's unhashable, so the tuple would fail. How could you handle this?)
  3. What would be the downside of using a class-based decorator that stores cache in instance attributes if you want to use it on multiple functions?
  4. How does the stats() method provide useful information for optimization?
  5. If you wanted to make the cache persistent (e.g., save to disk), how would you modify the class?
Sample Answers (Homework 9)

Part 1: Sample Implementation of Cache

import functools from collections import OrderedDict class Cache: def __init__(self, func=None, max_size=128): self.func = func self.max_size = max_size self.cache = OrderedDict() # preserves insertion order for LRU self.hits = 0 self.misses = 0 if func is not None: functools.update_wrapper(self, func) def __call__(self, *args, **kwargs): if self.func is None: # Called as @Cache(max_size=10) return Cache(max_size=self.max_size) # Generate cache key key = (args, tuple(sorted(kwargs.items()))) if key in self.cache: self.hits += 1 # Move to end to mark as recently used (LRU) self.cache.move_to_end(key) return self.cache[key] else: self.misses += 1 result = self.func(*args, **kwargs) # If cache is full, remove oldest (first item) if len(self.cache) >= self.max_size: self.cache.popitem(last=False) self.cache[key] = result return result def clear(self): self.cache.clear() self.hits = 0 self.misses = 0 def stats(self): return { 'hits': self.hits, 'misses': self.misses, 'size': len(self.cache) }

Reflection Answers

  1. Why class-based?
    A class-based decorator can naturally hold the cache dictionary, hit/miss counters, and configuration (max_size) as instance attributes. This keeps all state cleanly encapsulated and easily accessible via methods like clear() and stats(). A function-based decorator would need to use nonlocal variables or mutable containers (e.g., lists) to hold such state, which is less readable and harder to extend.

  2. Handling mutable arguments (lists)
    Lists are unhashable, so they cannot be used directly as dictionary keys. In the current implementation, args is a tuple, but if you pass a list as an argument (e.g., func([1,2])), args becomes ([1,2],), and ([1,2],) is not hashable because the list inside is unhashable. To handle this, we could convert all arguments to hashable representations, e.g., by using repr() or by recursively converting lists to tuples (but this may not work for custom objects). A simpler approach is to accept the limitation and document that only hashable arguments should be used, or to use a custom key generator that serialises the arguments (e.g., using pickle).

  3. Downside of sharing cache across multiple functions
    If the same decorator instance is used for multiple functions (which doesn't happen with @Cache because each usage creates a new instance), the cache would be shared and key collisions could occur (different functions with same arguments). That's why we create a separate instance per decorated function, ensuring each has its own independent cache.

  4. Value of stats()
    stats() provides hit/miss counts and cache size, allowing developers to evaluate the effectiveness of caching. A high hit rate indicates the cache is beneficial; a low hit rate suggests that the cache size might be too small or that the function is not called with repeated arguments, so caching may not be useful.

  5. Making the cache persistent
    To persist the cache to disk, we could add save(filename) and load(filename) methods. Inside save, we would write the cache dictionary (and possibly the hit/miss counts) to a file using json or pickle. In load, we would read the file and restore the cache. The __call__ method could check if a persistent cache exists and load it on first call, but careful handling is needed to avoid stale data when the function definition changes.

Homework Submission Requirements

Part 1: Code
Write the complete Cache class with all specified methods. Include:

Part 2: Testing Script
Write a test section that does the following:

  1. Define a function factorial(n) that recursively computes factorial (or use a simple expensive function like a CPU-intensive loop). Decorate it with Cache.
  2. Call the function with a few arguments (some repeated, some new) and print the results.
  3. Print the cache stats after the calls.
  4. Call clear() and show stats after clearing.
  5. Demonstrate the maximum size by calling more distinct arguments than max_size and show that old entries are evicted (if you implement LRU) or cache is cleared (if you implement simple clearing). Recommendation: Implement LRU for bonus points, or at least count the size and clear when full.

Part 3: Reflection Questions
Answer these in a comment block at the top of your script:

  1. Why is a class-based decorator well-suited for caching compared to a function-based one?
  2. How does the cache key generation handle functions that take mutable arguments like lists? (Hint: If you pass a list, it's unhashable, so the tuple would fail. How could you handle this?)
  3. What would be the downside of using a class-based decorator that stores cache in instance attributes if you want to use it on multiple functions?
  4. How does the stats() method provide useful information for optimization?
  5. If you wanted to make the cache persistent (e.g., save to disk), how would you modify the class?

Bonus Challenge (Optional):
Implement LRU (Least Recently Used) eviction policy for the cache. When the cache exceeds max_size, remove the least recently used entry. You can use collections.OrderedDict to help with this. Update stats() accordingly.

📚 Additional Resources for Self-Study

  1. Educative: Using Python Class Decorators Effectively – Clear examples.
  2. Runestone Academy: 20.15. Class Decorators – Academic perspective.
  3. Real Python: Primer on Python Decorators – Comprehensive guide (includes both function and class decorators).
  4. Python Official Docs: Decorators – Glossary entry.

✅ Summary Checklist for Tutorial 9

Before moving to Tutorial 10 (The property() Function), ensure you can confidently say YES to the following:

Ready for the next tutorial? In Tutorial 10, you will learn about the built-in property() function – how to create managed attributes with getters, setters, and deleters using the functional approach, before moving to the decorator syntax in Tutorial 11.

Previous | Tutorial index | Next