Let's learn about python decorators. This is one of the patterns I love to use in python and It is widely used by senior programmers out there.
What are decorators ๐ค
A decorator in python is a design pattern that allows us to extend the functionality of a function without modifying the function itself. We can do this by wrapping the function we want to extend functionality to with another function. It is not complex at all, it's a very simple pattern. On the low level, we are just passing a function as an argument to another function which will return a function we can call to call the function passed as an argument in the first place.
Patterns behind a decorator
In python, we can nest functions. Inner functions can access the outer scope of the enclosing function. Meaning that if we define a variable in the enclosed function, the inner function can have access to it. This pattern is called Closure. This concept is very important, it is the core concept of python decorators. This concept is also used in other languages out there, including JavaScript, Golang, and more.
Coding Decorators
Let's dive into some python codes which will help us understand all those theories and jargon above.
Functional decorators
Assuming we have a function calculate_number
that returns a number 5
, but then some requirements came up that we needed to increase the output of that function by 1
, now we can create another function increase_number
that can help us do that. This is just an example we will cover complex use cases towards the end of the tutorial.
We have our function that returns the number
def calculate_number():
return 5
We have another function here, let's pay close attention to this function
# Decorator to increase function output by one
def increase_number(func):
def wrapper():
"""
Calls the function passed as an argument in the outer
scope and adds one to the output
"""
return func() + 1
return wrapper
So let's break this down;
Take a look at the
increase_number
function,def increase_number(func): def wrapper(): ... return wrapper
We need to pass a function as an argument to this function. A function is defined in the function
increase_number
calledwrapper
, we can call it any name, and the function is returned without calling it. Meaning that ifincrease_number
function is called, it's going to return a function as the response,wrapper
, which can also be called.# Pass `calculate_number` as an argument var_a = increase_number(calculate_number) # Call the function returned above inner_function_response = var_a()
var_a
from above will contain thewrapper
function waiting to be called. Then it was called and its return value is assigned to the variableinner_function_response
.Inside the nested function
wrapper
above, we called the function passed as an argument to the enclosing function and add1
to it and return the answer,def wrapper(): """ Calls the function passed as an argument in the outer scope and adds one to the output """ return func() + 1
We could call that function passed as an argument because it is in the scope of the enclosing function and the nested function as access to the outer scope. This concept is called Closure. We can also do something before we call the function and after we call the function. Also, do something before we define the function
wrapper
and after we define it. Try the example below;
def outer_function(func):
print('Before defining inner function')
def inner_function():
print('Do something before calling function')
func() # Call function passed as argument in outer function
print('Do something after calling function')
print('Do something before returning the inner function')
return inner_function
def sample():
print('I was decorated')
outer_function(sample)()
Output
Before defining inner function
Do something before returning the inner function
Do something before calling function
I was decorated
Do something after calling function
Full code: Increasing the number decorator
def increase_number(func):
def wrapper():
return func() + 1
return wrapper
def calculate_number():
return 5
var_a = increase_number(calculate_number)
inner_function_response = var_a()
print(inner_function_response)
Running the above code should output 6
.
To summarize what is happening again;
- the function
calculate_number
returns 5, - the function
increase_number
is a decorator, it takes in a function and wraps it with another function, then returns the wrapper, allowing us to manipulate the return value of the function passed as an argument which is going to be functioncalculate_number
. - the nested function,
wrapper
, inside the functionincrease_number
calls the function passed to functionincrease_number
and adds 1 to the return value, then returns the answer. - the nested function,
wrapper
, is returned by the functionincrease_number
, so that we can do something with it, most of the time we end up just calling it like a normal function as we did here withvar_a
. I said most of the time because we could also pass the returned function in variablevar_a
as an argument to another decorator, function.
Pythonic syntax of using decorators
Decorators are used a lot in python, there are even built-in decorators like staticmethod, classmethod, property, and more. So there is a pythonic syntax you can use to decorate a function. Imagine we wanted to apply multiple decorators to the function calculate_number
in the code above using the current way, it would be too complex for you or another person to understand the code when there is a bug in the code or just reading through the code.
Pythonic Code: Increasing the number
def increase_number(func):
def wrapper():
return func() + 1
return wrapper
@increase_number
def calculate_number():
return 5
print(calculate_number())
This is beautiful, I love this syntax. What happened here is simple, we decorated the function calculate_number
with function increase_number
by placing it just above the function calculate_number
with the @
symbol before it. This tells python that this is a decorator, so when this function calculate_number
is called, it automatically does the passing of the function to the decorator and calls the wrapper function. So now we can just call the function itself and it will get decorated. Now we can easily add multiple decorators to a function without making it too complex to understand.
Multiple decorators
Multiple decorators can be applied to a single function, by just stacking the decorators on top of each other using the decorator syntax @decorator_function
.
def split_string(func):
def inner():
return func().split()
return inner
def uppercase_string(func):
def inner():
return func().upper()
return inner
@split_string
@uppercase_string
def speak_python():
return "I love speaking Python"
print(speak_python())
Output
['I', 'LOVE', 'SPEAKING', 'PYTHON']
The first question that might come to your mind is, what is the order of how this decorator is applied to this function when it is called? The answer is it is from bottom to top, uppercase_string
then split_string
. So from the output above you can tell that the string was first converted to uppercase then it was split returning a list of uppercased strings.
You can also test it out, try switching the positions of the decorators, and place @uppercase_string
on top of @split_string
.
Code
@uppercase_string
@split_string
def speak_python():
return "I love speaking Python"
Output
...
AttributeError: 'list' object has no attribute 'upper'
From the output above, there was an error because the @uppercase_string
decorator was trying to call upper
method on a list. This happened because the @split_string
decorator split the string first returning a list of strings, then the @uppercase_string
decorator tried to apply the upper
method on that list which is impossible as python lists do not have an upper
method built-in.
Decorating functions that accept arguments
If our functions accept arguments, no need to worry, we just have to update our decorator to accept the arguments and pass them to the decorated function when calling it.
Let's modify our speak_python
function above to accept a language as an argument, format it with the string, and return it.
Bad Code:
@split_string
@uppercase_string
def speak_language(language):
return f"I love speaking {language}"
print(speak_language("Python"))
Output:
...
TypeError: inner() takes 0 positional arguments but 1 was given
We got the error because the argument Python
is being passed to the inner functions of our decorators, but those inner functions don't take any arguments, positional or keyword arguments. So we have to update the decorators to make sure the inner functions take in arguments.
Good code:
def split_string(func):
def inner(a):
return func(a).split()
return inner
def uppercase_string(func):
def inner(a):
return func(a).upper()
return inner
@split_string
@uppercase_string
def speak_language(language):
return f"I love speaking {language}"
print(speak_language("Python"))
Output:
['I', 'LOVE', 'SPEAKING', 'PYTHON']
Now it's working, what happened;
- the inner functions now accept an argument,
- then the argument is passed to the decorated function.
General Decorators
We can also create a decorator that accepts any amount of arguments, positional and keyword arguments, and passes them to the decorated function.
Say we updated our speak_language
function to accept more arguments, we would have to update all our decorators to accept those arguments. Imagine we have a complex codebase with a lot of decorators, it would be hard to update all of them. So we should make sure our decorators can handle multiple arguments in the future.
Best Code:
def split_string(func):
def inner(*args, **kwargs):
return func(*args, **kwargs).split()
return inner
def uppercase_string(func):
def inner(*args, **kwargs):
return func(*args, **kwargs).upper()
return inner
@split_string
@uppercase_string
def speak_language(word, language):
return f"I {word} speaking {language}"
print(speak_language("hate", "Python"))
Output:
['I', 'HATE', 'SPEAKING', 'PYTHON']
args and *kwargs allow you to pass multiple arguments or keyword arguments to a function. So we used it in the inner functions to accept any number of arguments and also pass them all to the decorated function. Now no matter how many arguments are passed to the speak_language
function in the future, we don't need to update the decorators. One less problem to think about. ๐
Learn more about args and *kwargs
Decorator Factory
Let's go back to our increase_number function Code:
def increase_number(func):
def wrapper():
return func() + 1
return wrapper
@increase_number
def calculate_number():
return 5
print(calculate_number())
This decorator only increases this number by 1
, what if we want to apply an increase by 10? We can edit the decorator to increase it by 10. What if we have another function that needs a decorator to increase it by 5, we can create another decorator like increase_number
that increases it by 5. This is not good because we are repeating code, we should try not to repeat codes anywhere possible because if there turns out to be a bug in the code we will have to change all places where the code has been replicated.
It is also possible with decorators. With the understanding of Closures, we know that a nested function has access to the scope of the outer function. Then we can create a function that returns a decorator. Doing this means if we pass an argument to the function creating the decorator, the decorator would have access to the arguments passed to its outer function and the inner function of the decorator should have access to these arguments too. Let's take a look at the code below.
Code:
def A(arg1):
def B():
def C():
def D():
def E():
print(arg1)
return E
return D
return C
return B
A("Good Python")()()()()
Output:
Good Python
I think with this piece of code, I have done justice to the fact that nested functions always have access to the outer function scope. Function E
was nested four levels down, but still has access to the scope of function A
.
This is the same idea behind a decorator factory, a function that returns a decorator. So to make the applied increase dynamic, we can create a function that accepts the number to increase by as an argument and then return a decorator which has access to this number. Code:
def apply_increase(increase):
def increase_number(func):
def wrapper():
return func() + increase
return wrapper
return increase_number
@apply_increase(10)
def calculate_number():
return 5
@apply_increase(1)
def myother_number():
return 1
print(calculate_number())
print(myother_number())
Output:
15
2
The apply_increase
function accepts the number as an argument, then returns the decorator. Since the decorator function returned for each of the functions above has access to the scope of their outer functions, we get unique decorators because the numbers in apply_increase(10)
and apply_increase(1)
are different. For both of the decorators returned, their increase
argument will be different.
Conclusion ๐
Decorator is a very amazing pattern in python, with it we can extend the functionality of the wrapped function. We have covered a lot in this article, but there are other things we haven't covered. Class decorators, method decorators, and python functools module will be covered in another article. Thanks for reading, Arigato. โ๏ธ
If you love this article, you can give this article likes and 10 reactions, comment below if you have any questions or views, and follow me for more updates on Software programming.