What are decorators? If you come from other languages that have this feature you can skip to the next section. But in short, decorators wrap a function, adding some features on top of the original function.
How so?
Functions as First-Class Objects
In Python, functions are first-class objects. This means that functions can be passed around and used as arguments. It means you can define function inside functions, return functions from functions, and receive functions as arguments to functions.
That basically summaries what decorator can do. Let’s start with an easy example:
def a_decorator(f):
def wrapper():
print("Before the function is called.")
f()
print("After the function is called.")
return wrapperdef hello_world():
print("Hello world!")hello_world = my_decorator(hello_world)
=======================================
>>> hello_world()
Before the function is called.
Hello world!
After the function is called.
a_decorator
is a decorator. All it’s doing is to print a statement before and after the decorated function call.
But it’s a bit hassle to writehello_world = my_decorator(hello_world)
all the time, no?
Syntactic Sugar!
@
symbol, or the “pie” syntax — it’s just equivalent of hello_world = a_decorator(hello_world)
.
You can rewrite the example as below.
@a_decorator
def hello_world():
print("Hello world!")
This decorator just receive a function that takes no argument. But most of the time, the wrapped function would need to take arguments. What should we do?
Decorated with Arguments
The solution is to use *args
and **kwargs
in the wrapper function, so that it will accept an arbitrary number of both positional and keyword arguments.
def a_decorator(f):
def wrapper(*args, **kwargs):
print("Before the function is called.")
f(*args, **kwargs)
print("After the function is called.")
return wrapper@a_decorator
def hello_to(name):
print("Hello" + name + "!")
Now when the a_decorator
gets called, it will return the wrapper()
function that will accept arguments of f
and pass them to f
.
>>> hello_to("Elfi")
Before the function is called.
Hello Elfi!
After the function is called.
Returning Decorated Function Values
The next step is to make sure that the decorator function will return the return value of the decorated function. To do this, we simply let the wrapper function returns the return value of the decorated function.
def a_decorator(f):
def wrapper(*args, **kwargs):
print("Before the function is called.")
f(*args, **kwargs)
return f(*args, **kwargs)
print("After the function is called.")
return wrapper@a_decorator
def hello_to_with_return(name):
print("Hello" + name + "!")
return("This is the return, end.")======================================
>>> hello_to_with_return("Elfi")
Before the function is called.
Hello Elfi!
This is the return, end.
After the function is called.
Decorating with Arguments
But we not only want to preserve arguments of decorated functions but also want to be able to pass arguments to our decorators. For example, we want @a_decorator
receives an argument to change the default before and after statement. For a decorator to use arguments, we need another decorator layer on top of it:
def decor_with_arg(anything):
def a_decorator(func):
def wrapper(*args, **kwargs):
print(f"The decorator args: {anything}")
return f(*args, **kwargs)
return wrapper
return a_decorator@decor_with_arg("lalalala") #a_decorator
def hello_to_with_return(name):
print("Hello" + name + "!")
return("This is the return, end.")======================================
>>> hello_to_with_return("Elfi")
The decorator args: lalalala
Hello Elfi!
This is the return, end.
You can see the decor_with_arg
function takes arbitrary arguments and returns the our original decorator function, a_decorator
uncalled. And then inside a_decorator
inner scope, the anything
argument is preserved through closure so it can be used later by wrapper
.
Carrying over the details
Remember the original form for the syntax sugar @
? It’s essentially decorated_func= decorator_func(decorated_func)
. So you can see we are literally modifying the decorated_func
by assigning it with the return value of decorator_func
, which is the wrapper function
.
These are not the same, and we can lose some information on the original decorated_func
:
def logged(func):
def with_logging(*args, **kwargs):
print(func.__name__ + " is the decorated function name")
return func(*args, **kwargs)
return with_logging@logged # f = logged(f)
# function f is replaced with the function with_logging
def original_func(x):
"""does some math"""
return 2 * x
=================================
>>> print(original_func.__name__)
with_logging
>>> print(original_func.__doc__)
None
You can see the name of the function is not original_func
. In fact, if you look at the docstring for f
, it will be blank because with_logging
has no docstring, and so the docstring you wrote won't be there anymore.
But don’t worry. That's why we have functools.wraps
. It is another decorator to the wrapper function that copies over the decorated function name, docstring, arguments list, etc.
def logged(func):
@functools.wraps(func)
def with_logging(*args, **kwargs):
print(func.__name__ + " is the decorated function name")
return func(*args, **kwargs)
return with_logging
@logged
def original_func(x):
"""does some math"""
return 2 * x==============================
>>> print(original_func.__name__)
original_func
>>> print(original_func.__doc__)
does some math
Note that @functools.wraps
does three things:
- it copies the
__module__
,__name__
,__qualname__
,__doc__
, and__annotations__
attributes ofdecorated function
on thewrapper function
. You can see it in the functools source. - it updates the
__dict__
ofwrapper function
with all elements fromdecorated function.__dict__
. - it sets a new
__wrapped__=decorated function
attribute onwrapper function
.
In this way, wrapper function
appears as having the same name, docstring, module name, and signature as decorated function
.
Chaining decorators
For Single Responsibility purpose, we don’t want to overload any single decorator, meaning we might have multiple decorators added to one function. In this case, the order matters.
def a_decorator(f):
def wrapper(*args, **kwargs):
print("***************")
f(*args, **kwargs)
print("***************")
return wrapperdef b_decorator(f):
def wrapper(*args, **kwargs):
print("$$$$$$$$$$$$$$$$$")
f(*args, **kwargs)
print("$$$$$$$$$$$$$$$$$")
return wrapper@a_decorator
@b_decorator
def hello_to(name):
print("Hello" + name + "!")
======================================
>>> hello_to("Elfi")
*****************
$$$$$$$$$$$$$$$$$
Hello Elfi!
$$$$$$$$$$$$$$$$$
*****************
So in summary, we know that:
- Decorators can alter the functionality of a function. (We are talking about pure decorators here)
- Decorator can receive arguments, pass arguments to decorated function, return the value of decorated functions.
- It can chain with other decorators.
- With
functool.wraps
our decorator carries the details of the decorated function and become more “real”.
We didn’t go through the common usage of decorators as well as class decorators in this blog, but we will soon!
Happy Reading!