Decorating with Python Decorators

E.Y.
4 min readJan 7, 2021

--

Photo by Vellva UK on Unsplash

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 wrapper
def 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 of decorated function on the wrapper function. You can see it in the functools source.
  • it updates the __dict__ of wrapper function with all elements from decorated function.__dict__.
  • it sets a new __wrapped__=decorated function attribute on wrapper 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 wrapper
def 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!

--

--

Responses (1)