23.1 The Decorator Syntax: @ and What It Expands To
At its core, the @decorator syntax is a powerful form of syntactic sugar—a feature that makes code easier to read and write without adding new functionality to the language. It provides a clean, declarative way to modify or extend the behavior of a function or class immediately after its definition. To truly master decorators, one must understand what this syntax expands into, as this reveals the underlying mechanics and unlocks the ability to write more advanced decorators.
The Expansion of the @ Syntax
The @decorator syntax is not magic; it is translated by the Python interpreter into a straightforward series of assignments and function calls. When you write:
@decorator
def target_function():
pass
The interpreter performs the following operation behind the scenes:
def target_function():
pass
target_function = decorator(target_function)
This is the crucial insight: a decorator is a callable that accepts a function as its argument and returns a function (or more generally, a callable). The @ symbol is merely a shorthand for this reassignment. The same principle applies to classes:
@decorator
class TargetClass:
pass
# Expands to:
class TargetClass:
pass
TargetClass = decorator(TargetClass)
This expansion happens immediately after the function or class object is created, at the time of module import. This is why decorators are executed at import time, not at runtime when the decorated function is called.
A Simple Decorator Example and Its Expansion
Consider a decorator that prints a message every time a function is called.
def debug_decorator(func):
"""A decorator that prints a function's name when it is called."""
def wrapper():
print(f"Calling function: {func.__name__}")
return func() # Call the original function and return its result
return wrapper
# Applying the decorator using @ syntax
@debug_decorator
def say_hello():
print("Hello!")
say_hello()
# Output:
# Calling function: say_hello
# Hello!
The @debug_decorator syntax is equivalent to writing:
def say_hello():
print("Hello!")
say_hello = debug_decorator(say_hello)
In this expanded form, it’s clear that debug_decorator(say_hello) is called. This call returns the wrapper function object. The name say_hello is then rebound to this wrapper function. When we later call say_hello(), we are actually calling wrapper(), which contains the logic to print the message and then call the original function (which it remembers via the closure over the func variable).
The Importance of functools.wraps
A critical pitfall in the simple example above is the loss of the original function’s metadata. After decoration, say_hello is now the wrapper function. This means its __name__, __doc__, and other attributes are now those of wrapper, not the original say_hello.
print(say_hello.__name__) # Output: 'wrapper'
This can break tools that rely on introspection, such as debuggers, testing frameworks, and documentation generators like Sphinx. The standard library’s functools.wraps decorator is designed to solve this exact problem. It copies the metadata from the original function to the wrapper function.
import functools
def debug_decorator_preserved(func):
"""A decorator that uses functools.wraps to preserve metadata."""
@functools.wraps(func) # This decorator copies the metadata
def wrapper():
print(f"Calling function: {func.__name__}")
return func()
return wrapper
@debug_decorator_preserved
def say_goodbye():
"""A function that says goodbye."""
print("Goodbye!")
say_goodbye()
print(say_goodbye.__name__) # Output: 'say_goodbye'
print(say_goodbye.__doc__) # Output: 'A function that says goodbye.'
Best practice: Always use @functools.wraps(func) on the wrapper function inside your decorators to maintain consistent behavior and avoid confusing bugs.
Decorators That Accept Arguments
The @ syntax can also handle decorators that are called with arguments themselves, such as @decorator(arg1, arg2). This syntax expands in two steps.
@decorator_factory(arg1, arg2)
def target_function():
pass
# Expands to:
def target_function():
pass
target_function = decorator_factory(arg1, arg2)(target_function)
Here, decorator_factory(arg1, arg2) is called first. Its job is to return a decorator (i.e., a callable that accepts a function). This returned decorator is then called with the target_function as its argument. This pattern requires nesting an extra level deep.
def repeat(num_times):
"""A decorator factory that returns a decorator to repeat a function call."""
def decorator_repeat(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
for _ in range(num_times):
value = func(*args, **kwargs)
return value # Returns the value from the last call
return wrapper
return decorator_repeat
# The call to `repeat(3)` returns the `decorator_repeat` function.
@repeat(num_times=3)
def greet(name):
print(f"Hello {name}")
greet("World")
# Output:
# Hello World
# Hello World
# Hello World
Understanding this expansion is key to debugging more complex decorator setups. If you see @my_decorator(arg), you must remember that my_decorator(arg) is executed first and must return the actual function that will decorate the target.