Decorators are an extremely useful feature in Python language that allows programmers to change the behaviour of a function without changing the function. Sounds strange? Well, don't worry because
In this post, We are going to:
Understand what decorators are and how they work.
How to create your own decorators
See the practical use case of decorators.
So, let’s jump into it.
We all have used functions in Python. The most basic example, print(), is a function that lets us see the output on the screen. Functions are basically a reusable block of code that performs a specific task or set of tasks. Functions are a fundamental concept in programming and are used to organize and modularize code, making it more manageable, readable, and efficient.
The basic structure of a Python function is shown below.
All Python functions start with the keyword "def" followed by the function name and parentheses.
For example
def hello(name):
print(f"Hello, {name}")
hello("Shekhar")
Output
Hello, Shekhar
This function simply prints a hello message with the given argument.
But what if we simply call the function like this, what will happen now?
print(hello)
We will get an output which will look something like this
<function hello at 0x000001EC6A3F8CC0>
As with most of the things in Python, functions are also an object, and the function name is just a reference to that object. The above output tells us that there is a function object at the memory location "0x000001EC6A3F8CC0".
That means that you can use functions as arguments to other functions, store functions as dictionary values, or return a function from another function. This leads to many powerful ways to use functions. 😲
Don't get confused; let's start one by one.
Let's look at this baby example to understand the concept that function names are just references.
print("Shekhar")
myfunction = print
myfunction("Hello")
print(print)
print(myfunction)
Output
Shekhar
Hello
<built-in function print>
<built-in function print>
When we write myfunction = print, we are referencing the print object through myfunction so myfunction acts as a print function.
Now, let's look at another example. We can do something like this
def add_one(number):
return number + 1
def multiply_10(number):
return number * 10
function_list = [add_one, multiply_10]
print(add_one(10))
print(multiply_10(10))
print(function_list[0](10))
print(function_list[1](10))
We get this output
11
100
11
100
Now that we have understood the basics of functions in Python, let's dive a little deeper.
Take this function as an example.
def prefix(prefix):
def hello(name):
print(f"{prefix} Hello, {name}!")
return hello
print_debug = prefix("DEBUG :")
print_debug("shekhar")
print_warning = prefix("Warning :")
print_warning("Opps!")
Output
DEBUG : Hello, shekhar
Warning :Hello, Opps!
Here, prefix("DEBUG:") returns the reference to function hello with prefix argument as "DEBUG :"
Let's understand decorators through an example. Consider this code below.
def reverse(func):
def reverse_caller(text):
func(text[::-1])
return reverse_caller
rev_print = reverse(print)
rev_print("Hello Shekhar!")
rev_print = reverse(print_warning)
rev_print("Shekhar!")
!rahkehS olleH
Warning : Hello, !rahkehS!
Now, this function reverse() is taking a function reference as a parameter and returning a function 😯
This is what we call a Decorator. It's a function that takes a function as a parameter, modifies it, and returns the function.
Another way to do that is as follows.
@reverse
def greet(name):
print(f"Hello {name}")
greet("Shekhar")
Hello rahkehS
This is exactly the same as greet=reverse(greet).
The @reverse is just a syntax to make things tidy and easier to read
The general template to make a decorator is as follows
def decorator_name(func):
def wrapper(func):
#DO SOMETHING
return wrapper
Now that we have understood the basics let's write a decorator that will Print "BEFORE" runs the function and print "AFTER".
I'd highly recommend you try it on your own before beginning further.
Following our decorator template, we have the following
def before_after(func):
def wrapper(name):
print("Before")
func(name)
print("After")
return wrapper
@before_after
def greet(name):
print(f"Hello {name}")
greet("shekhar")
Output
And it works as expected 😃
Before
Hello shekhar
After
Now, take our previous example greet and try to add a default argument to it and see what happens.
@before_after
def greet(name="Shekhar"):
print(f"Hello {name}")
What do you think? Will it work or not?
Let's see the output.
TypeError: before_after.<locals>.wrapper() missing 1 required positional argument: 'name'
It failed because when we decorate a function and use it, then we are not actually using our original function.
We are using the nested wrapper function. If we pass greet() with no arguments, then our wrapper function will also receive no arguments, but it requires one "name" argument to function properly.
That's why the error says that "wapper() missing 1 required positional argument: name"
Let's take another example to understand this concept more.
def greet(name):
print(f"Hello {name}")
greet("Shekhar")
print(greet.__name__)
@before_after
def greet(name):
print(f"Hello {name}")
greet("Shekhar")
print(greet.__name__)
Hello Shekhar
greet
Hello rahkehS
wrapper
The
One interesting thing to notice here is that when we add our decorator to the function, the name of the function changes to the wrapper (because that was the name of the wrapper function in our decorator). This shows that when we use the decorated function, we are actually using the wrapper function from the decorator.
As we saw, it is really important to make our decorators in such a way that they can handle these kinds of cases. We will face a similar error if we try to pass more than one argument to our decorated function.
To solve these kinds of issues, we use *args and **kwargs, they translate to argument and keyword arguments, respectively. I will be explaining these in much more detail in an upcoming blog in which I will share how Python actually runs and how functions are executed and actually hold meaning in Python; these are called unpacking operators.
For example
a = [1,2,3]
b = [3,4,5]
c1 = [*a,*b]
c2 = [a,b]
dict1 = {'a':1,'b':2}
dict2 = {'b':2,'c':3}
dict3 = {**dict1,**dict2}
print(f"c1:{c1}\n c2:{c2}")
print(dict3)
c1:[1, 2, 3, 3, 4, 5]
c2:[[1, 2, 3], [3, 4, 5]]
{'a': 1, 'b': 2, 'c': 3}
The *args enables us to send any number of arguments to the function, and **kwargs enables us to pass in any number of keyworded arguments when calling the function.
For example
def testing(*args,**kwargs):
print(f"Args = {args}")
print(f"KWargs = {kwargs}")
testing("hello",1,name="shekhar")
Args = ('hello', 1)
KWargs = {'name': 'shekhar'}
"args" and "kwargs" is the general naming convention. You can use any other name following the same * and ** pattern, and it will work the same way.
def before_after(func):
def wrapper(*args, **kwargs):
print("Before")
func(*args, **kwargs)
print("After")
return wrapper
@before_after
def greet(name="Shekhar"):
print(f"Hello {name}")
greet()
greet("joey")
Before
Hello Shekhar
After
Before
Hello joey
After
Let's write a decorator to run the decorated function thrice and return a 3-value tuple with results.
import random
def do_thrice(func):
print(f"Adding decorator to {func.__name__}")
def wrapper():
return (func(), func(), func())
return wrapper
@do_thrice
def roll_dice():
return random.randint(1, 6)
print(roll_dice())
Output
Adding decorator to roll_dice
(2, 2, 1)
Now that we have seen enough examples of creating decorators, let's see what are some real use cases in which decorators are extremely useful.
Suppose you have many functions in your code, and you want to optimize your code. Now, instead of writing the code for getting the runtime and memory usage for each function, we can simply write a decorator and apply it to all the functions we want to test.
Let's take this example in which we are calculating function runtime using perf_counter (It's just like a stopwatch) and calculating memory usage using tracemalloc function. (It's part of the standard library, I will be making a dedicated blog on this topic).
from functools import wraps
import tracemalloc
from time import perf_counter
def measure_performance(func):
@wraps(func) # It is a inbuilt decorator in python
# When used, the decorated function name remains the same
def wrapper(*args, **kwargs):
tracemalloc.start()
start_time = perf_counter()
func(*args, **kwargs)
current, peak = tracemalloc.get_traced_memory()
finish_time = perf_counter()
print(f"Function: {func.__name__}")
print(
f"Memory usage:\t\t {current / 10**6:.6f} MB \n"
f"Peak memory usage:\t {peak / 10**6:.6f} MB "
)
print(f"Time elapsed is seconds: {finish_time - start_time:.6f}")
print(f'{"-"*40}')
tracemalloc.stop()
return wrapper
@measure_performance
def function1():
lis = []
for a in range(1000000):
if a % 2 == 0:
lis.append(1)
else:
lis.append(0)
@measure_performance
def function2():
lis = [1 if a % 2 == 0 else 0 for a in range(1000000)]
function1()
function2()
Output
Function: function1
Memory usage: 0.000000 MB
Peak memory usage: 8.448768 MB
Time elapsed is seconds: 0.329556
----------------------------------------
Function: function2
Memory usage: 0.000000 MB
Peak memory usage: 8.448920 MB
Time elapsed is seconds: 0.294334
----------------------------------------
We simply added two decorators to our functions instead of adding the whole code to each function. This makes our code much more concise and readable.
Many Libraries, such as Flask or FastAPI, use decorators extensively to create API routes. For example, in FastAPI, we use something like this:
@app.get("/helloWorld", tags=["Testing"])
def read_root():
return {"Hello": "World"}
This creates a get endpoint /helloworld which triggers the read_root() function whenever called.
That was it for this blog.
I hope you learned something new today.
If you did, please like/share so that it reaches others as well.
Connect with me on @twitter where I write regularly about software development and solopreneurship.
✅ Here are some more posts you might be interested in.
Thank you for reading 😄
Also published here.