This article was published as a part of the Data Science Blogathon
Decorators are simply callables for decorating a function. It helps add new functionalities to a function without changing its original structure. In this article, we are going to learn the hows, whats, and whys of decorators. But before delving into Decorators we must get familiarized with certain concepts like first-class citizens, nesting of functions, closures, nonlocal scopes, etc. These topics are essential for understanding Python Decorators. We will go through each topic one by one to ensure complete clarity.
In Python, any object that can be assigned to a variable, passed as an argument, returned from a function will be considered as a first-class citizen. in short, there are almost no restrictions on their uses. Some of the examples are data types like int, floats, strings, etc, data structures like lists, tuples, etc. Python functions also satisfy the requirements for being a first-class citizen. This is a fundamental concept to understand the creation of Decorators.
Like any other object like lists, tuples, or dictionaries Python functions can also be assigned to variables. For example:
def greet(msg): return f'hello! {msg}' var = greet #function greet is assigned to var var('Peter') # var is called
output: 'hello! Peter'
def upper_text(msg): return msg.upper() def greet(func): var = func('hello! Peter') return var greet(upper_text) #func was sent as a parameter to my_func
Output: 'HELLO! PETER'
def outer(): def inner(): return 'Freedom in thought' return inner var = outer() print(var())
output: 'Freedom in thought'
def outer(x): print(f'Hey! {x} this is outer function') def inner(): print(f'Hey! {X} this is inner function') inner() outer('Jose')
output: Hey! Jose this is outer function HEy! Jose this is inner function
Hence, we learned that the nested functions are also able to access the objects that are present in the enclosing scope(outer() in this case). But the opposite isn’t true, Objects in the inner() scope can not be accessed by outer().
The nonlocal scope comes into the picture when we deal with nested functions. The scope of nested functions is called nonlocal scopes and variables defined inside of them are called nonlocal variables. These variables can neither be in local scope nor in global. Let’s understand this by an example.
def outer(x): def inner(): x = 'Tom' print(f"{x}'s spider-man is the best") inner() print(f"{x}'s spider-man is the best") outer('Tobey')
output: Tom's spider-man is the best Tobey's spider-man is the best
def outer(x): def inner(): nonlocal x x = 'Tom' print(f"{x}'s spider-man is the best") inner() print(f"{x}'s spider-man is the best") outer('tobey')
output: Tom's spider-man is the best Tom's spider-man is the best
Note: Changing the value of a nonlocal variable will also be reflected in the local scope.
In the above example change in the value of a variable in nonlocal scope also changed the variable in the local scope.
def outer(text): "enclosing function" def inner(): "nested function" print(text) return inner var = outer('food for brain') var()
output: food for brain
The technique by which some data is attached to some code even after the execution of the original function is finished is called closures. Even if we delete the original function the values in the enclosing scope are remembered.
def outer(f): def inner(): msg = f() return msg.upper() return inner def func(): return 'hello! Peter' func = outer(func) print(func())
output: HELLO! PETER
However, Python has a better way to implement this, we will use @ symbol before the decorator function. This is nothing but syntactic sugar. Let’s see how it’s done
def outer(f): def inner(): msg = f() return msg.upper() return inner @outer def func(): return 'hello! Peter' func()
output: HELLO! PETER
def outer(func): def inner(args): return [func(var[0],var[1]) for var in args] return inner @outer def func(a,b): return a if a>b else b print(func([(1,4),(5,3)]))
output: [4, 5]
We can also pass arguments to the decorators themselves, see the following example
def meta_decorator(x): def outer(func): def inner(args): return [func(var[0],var[1])**x for var in args] return inner return outer @meta_decorator(2) def func(a,b): return a if a>b else b print(func([(1,4),(5,3),(6,5)]))
output: [16, 25, 36]
Just as any other function we can take the help of *args and **kwargs to generalize decorators for multiple parameters intake. *args will be a tuple of positional arguments and **kwargs will be a dictionary for keyword arguments. Let’s see an example.
def outer(func): def inner(*args,**kwargs): func() print(f'poistional arguments {args}') print(f'keyword argumenrs are {kwargs}') return inner @outer def func(): print('arguments passed are shown below') func(6,8,name='sunil',age=21)
output: arguments passed are shown below poistional arguments (6, 8) keyword argumenrs are {'name': 'sunil', 'age': 21}
From the above example, we learnt how to pass multiple parameters to the decorator function. Here we passed both positional arguments as well as keyword arguments in a single line. Remember the convention is to use positional arguments before keyword arguments.
Original functions or those that are to be decorated can also take arguments, but those arguments need to be passed to the function from the wrapper or inner function.
def outer(func): def inner(*args,**kwargs): func(2) #arguments passed to the original function print(f'poistional arguments {args}') print(f'keyword argumenrs are {kwargs}') return inner @outer def func(a): print(f'arguments for {a} cases are shown below') func(6,9,name='sunil',age=21)
output: arguments for 2 cases are shown below poistional arguments (6, 9) keyword argumenrs are {'name': 'sunil', 'age': 21}
def square(func): def inner_one(): prime_nums = func() return [i**2 for i in odd_nums] return inner_one def find_prime(func): def inner_two(): prime = [] for i in func(): count = 0 for j in range(1,i): if i%j==0: count+=1 if count<2: prime.append(i) return prime return inner_two @square @find_prime def printer(): return [5,8,4,3,11,13,12] printer()
output: [25,9,121,169]
Here, find_prime() was executed first and then square(). If we change the order the result will be an empty list(why?).
So far so good but there is a problem that we have overlooked. See the below example
def outer(func): def inner(): 'inside inner function' msg = func() return msg.upper() return inner @outer def function(): 'inside original function' return 'hello! Peter' #if we run this we gwt print(function.__name__) print(function.__doc__)
In the above example, we saw function.__name__ showed inner while it should have been ‘function’ and same for docstrings too. The function() got replaced by inner(). It overrode the name and docstring of the original function, but we want to retain the information of our original function. So to do that Python provides a simple solution i.e. functools.wraps().
from functools import wraps def outer(func): @wraps(func) def inner(): 'inside inner function' msg = func() return msg.upper() return inner @outer def function(): 'inside original function' return 'hello! Peter' #if we run this we gwt print(function.__name__) print(function.__doc__)
output:function inside original function
In the above example, we used the wraps() method of functions inside the outer(). Observe that the wraps() method here itself was used as a decorator with func() as the argument. This decorator stores the metadata(name, docstring, etc) of the function to be decorated. Not doing this will not be harmful but will make debugging tedious, So it is prudent to use functools.wraps() whenever decorators are used.
Lorem ipsum dolor sit amet, consectetur adipiscing elit,