How to Understand Decorators in Python#
First of all, this garbage document engineer is back. Starting my daily writing of nonsense. The reason is that I saw this question How to understand Python decorators?, and I happened to explain this to someone not long ago, so this garbage started another round of writing trash articles.
Prerequisites#
To understand decorators, the first important concept to grasp in Python is: "Functions are First Class Members." To put it another way, functions are a special type of variable that can be passed as arguments to other functions and can also be returned as return values.
def abc():
print("abc")
def abc1(func):
func()
abc1(abc)
The output of this code is the string abc
that we output in the function abc
. The process is simple; we pass the function abc
as an argument to abc1
, and then call the passed function inside abc1
.
Now let's look at another piece of code.
def abc1():
def abc():
print("abc")
return abc
abc1()()
This code outputs the same as before. Here, we return the function abc
defined inside abc1
as a variable, and then after calling abc1
to get the return value, we continue to call the returned function.
Now, let's think about a problem: implement a function add
such that add(m)(n)
is equivalent to m+n
. If we clarify the previous concept of First-Class Members, we can write it out clearly.
def add(m):
def temp(n):
return m+n
return temp
print(add(1)(2))
Well, the output here is 3.
Main Text#
After reviewing the prerequisites, we can start today's topic.
Let's look at a requirement first.#
Now we have a function.
def range_loop(a,b):
for i in range(a,b):
temp_result=a+b
return temp_result
Now we want to add some code to this function to calculate its running time.
We might think of writing code like this.
import time
def range_loop(a,b):
time_flag=time.time()
for i in range(a,b):
temp_result=a+b
print(time.time()-time_flag)
return temp_result
Leaving aside whether this method of calculating time is accurate, we now want to add a time calculation feature to many functions like this.
import time
def range_loop(a,b):
time_flag=time.time()
for i in range(a,b):
temp_result=a+b
print(time.time()-time_flag)
return temp_result
def range_loop1(a,b):
time_flag=time.time()
for i in range(a,b):
temp_result=a+b
print(time.time()-time_flag)
return temp_result
def range_loop2(a,b):
time_flag=time.time()
for i in range(a,b):
temp_result=a+b
print(time.time()-time_flag)
return temp_result
At first glance, we think, hmm, Ctrl+C, Ctrl+V. Emmmm, now don't you think this code is particularly messy? How can we make it cleaner?
We thought about it and, based on the previously mentioned concept of First-Class Members, we wrote the following code.
import time
def time_count(func,a,b):
time_flag=time.time()
temp_result=func(a,b)
print(time.time()-time_flag)
return temp_result
def range_loop(a,b):
for i in range(a,b):
temp_result=a+b
return temp_result
def range_loop1(a,b):
for i in range(a,b):
temp_result=a+b
return temp_result
def range_loop2(a,b):
for i in range(a,b):
temp_result=a+b
return temp_result
time_count(range_loop,a,b)
time_count(range_loop1,a,b)
time_count(range_loop2,a,b)
Hmm, it looks somewhat reasonable. Now, we have a new problem. We are assuming that all our functions only take two parameters. What if we want to support passing any number of parameters? We furrowed our brows and wrote the following code.
import time
def time_count(func,*args,**kwargs):
time_flag=time.time()
temp_result=func(*args,**kwargs)
print(time.time()-time_flag)
return temp_result
def range_loop(a,b):
for i in range(a,b):
temp_result=a+b
return temp_result
def range_loop1(a,b):
for i in range(a,b):
temp_result=a+b
return temp_result
def range_loop2(a,b):
for i in range(a,b):
temp_result=a+b
return temp_result
time_count(range_loop,a,b)
time_count(range_loop1,a,b)
time_count(range_loop2,a,b)
Now it looks somewhat decent, but let's think again. This code actually changes our function call method. For example, if we directly run range_loop(a,b)
, we still cannot get the execution time of the function. So how can we get the function's running time without changing the function's calling method?
It's simple; we just need to replace it.
import time
def time_count(func):
def wrap(*args,**kwargs):
time_flag=time.time()
temp_result=func(*args,**kwargs)
print(time.time()-time_flag)
return temp_result
return wrap
def range_loop(a,b):
for i in range(a,b):
temp_result=a+b
return temp_result
def range_loop1(a,b):
for i in range(a,b):
temp_result=a+b
return temp_result
def range_loop2(a,b):
for i in range(a,b):
temp_result=a+b
return temp_result
range_loop=time_count(range_loop)
range_loop1=time_count(range_loop1)
range_loop2=time_count(range_loop2)
range_loop(1,2)
range_loop1(1,2)
range_loop2(1,2)
Emmmm, doesn’t it feel much better? It neither changes the original running method nor outputs the function's running time.
But... don’t you think manually replacing is too annoying??? Meow meow meow??? Is there anything we can simplify further?
Alright, Python knows we are kids who love candy, and it provides us with a new syntactic sugar, which is today’s leading character, the Decorator.
Let's Talk About Decorators#
We have already implemented adding new functionality to existing code without changing the function's characteristics, but we also feel that this manual replacement is too annoying. Yes, the Python official also thinks this is quite annoying, so the new syntactic sugar has arrived.
We can write the above code like this.
import time
def time_count(func):
def wrap(*args,**kwargs):
time_flag=time.time()
temp_result=func(*args,**kwargs)
print(time.time()-time_flag)
return temp_result
return wrap
@time_count
def range_loop(a,b):
for i in range(a,b):
temp_result=a+b
return temp_result
@time_count
def range_loop1(a,b):
for i in range(a,b):
temp_result=a+b
return temp_result
@time_count
def range_loop2(a,b):
for i in range(a,b):
temp_result=a+b
return temp_result
range_loop(1,2)
range_loop1(1,2)
range_loop2(1,2)
Wow, writing to this point, do you feel enlightened! まさか??? Yes, the @
symbol is actually a syntactic sugar that hands over the manual replacement process to the environment. To put it in simpler terms, the role of @
is to pass the wrapped function as a variable to the decorator function/class and replace the original function with the value returned by the decorator function/class.
@decorator
def abc():
pass
As mentioned earlier, a special replacement process occurs: abc=decorator(abc)
. Now let's do a few exercises to practice.
def decorator(func):
return 1
@decorator
def abc():
pass
abc()
What will happen in this code? Answer: It will throw an exception. Why? Answer: Because during decoration, a replacement occurs: abc=decorator(abc)
, and after the replacement, the value of abc
is 1. An integer cannot be called as a function by default.
def time_count(func):
def wrap(*args,**kwargs):
time_flag=time.time()
temp_result=func(*args,**kwargs)
print(time.time()-time_flag)
return temp_result
return wrap
def decorator(func):
def wrap(*args,**kwargs):
temp_result=func(*args,**kwargs)
return temp_result
return wrap
def decorator1(func):
def wrap(*args,**kwargs):
temp_result=func(*args,**kwargs)
return temp_result
return wrap
@time_count
@decorator
@decorator1
def range_loop(a,b):
for i in range(a,b):
temp_result=a+b
return temp_result
How does this code get replaced? Answer: time_count(decorator(decorator1(range_loop)))
.
Hmm, now do you have a basic understanding of decorators?
Let's Expand a Bit#
Now, I want to modify the previously written time_count
function to support passing a flag
parameter. When flag
is True
, it outputs the function's running time; when False
, it does not output the time.
Let's take it step by step. We will assume the new function is called time_count_plus
.
The effect we want to achieve is like this.
@time_count_plus(flag=True)
def range_loop(a,b):
for i in range(a,b):
temp_result=a+b
return temp_result
Looking at this, we first call time_count_plus(flag=True)
once, and use its returned value as a decorator function to replace range_loop
. Okay, so time_count_plus
needs to accept a parameter and return a function, right?
def time_count_plus(flag=True):
def wrap1(func):
pass
return wrap1
Now we have returned a function to serve as the decorator function. We mentioned that @
actually triggers a replacement process. So now our replacement is range_loop=time_count_plus(flag=True)(range_loop)
. Alright, now everyone should be clear that we should also have a function inside wrap1
and return it.
Hmm, the final code looks like this.
def time_count_plus(flag=True):
def wrap1(func):
def wrap2(*args,**kwargs):
if flag:
time_flag=time.time()
temp_result=func(*args,**kwargs)
print(time.time()-time_flag)
else:
temp_result=func(*args,**kwargs)
return temp_result
return wrap2
return wrap1
@time_count_plus(flag=True)
def range_loop(a,b):
for i in range(a,b):
temp_result=a+b
return temp_result
Isn't it much clearer now?
Let's Expand a Bit More#
Now we have a new requirement.
m=3
n=2
def add(a,b):
return a+b
def sub(a,b):
return a-b
def mul(a,b):
return a*b
def div(a,b):
return a/b
Now we have a string a
, and the value of a
could be +
, -
, *
, or /
. Now, we want to call the corresponding function based on the value of a
.
We think about it and, hmm, logical judgment.
m=3
n=2
def add(a,b):
return a+b
def sub(a,b):
return a-b
def mul(a,b):
return a*b
def div(a,b):
return a/b
a=input('Please enter any one of + - * /\n')
if a=='+':
print(add(m,n))
elif a=='-':
print(sub(m,n))
elif a=='*':
print(mul(m,n))
elif a=='/':
print(div(m,n))
But isn’t there too much if-else in this code? We think about it and use the First-Class Member feature along with a dict to establish a relationship between operators and functions.
m=3
n=2
def add(a,b):
return a+b
def sub(a,b):
return a-b
def mul(a,b):
return a*b
def div(a,b):
return a/b
func_dict={"+":add,"-":sub,"*":mul,"/":div}
a=input('Please enter any one of + - * /\n')
func_dict[a](m,n)
Emmmm, it looks good, but can we simplify the registration process? Hmm, at this point, the decorator's syntactic feature can be used.
m=3
n=2
func_dict={}
def register(operator):
def wrap(func):
func_dict[operator]=func
return func
return wrap
@register(operator="+")
def add(a,b):
return a+b
@register(operator="-")
def sub(a,b):
return a-b
@register(operator="*")
def mul(a,b):
return a*b
@register(operator="/")
def div(a,b):
return a/b
a=input('Please enter any one of + - * /\n')
func_dict[a](m,n)
Hmm, remember we mentioned that using the @
syntax actually triggers a replacement process? Here we utilize this feature to register function mappings when the decorator is triggered, allowing us to directly retrieve the function to process data based on the value of 'a'. Also, please note that we do not need to modify the original function, so we do not need to write a third-level function.
If you are familiar with Flask, you will know that when registering routes using the route
method, this feature is also used. You can refer to another article I wrote a long time ago Beginner's Reading of Flask Source Code Series (1): An Initial Exploration of Flask's Router.
Conclusion#
Throughout this article, you should have learned one thing. Decorators in Python are actually a further application of the First-Class Member concept, where we pass functions to other functions, wrap them with new functionality, and then return them. The @
symbol simply simplifies this process. In Python, decorators are everywhere, and many implementations in official libraries also rely on decorators, such as the article I wrote a long time ago Beginner's Reading of Flask Source Code Series (1): An Initial Exploration of Flask's Router.
Well, let's stop here for today!