DEV Community

Fred
Fred

Posted on

Python Decorators Demystified

So what are decorators? Well basically decorators are function that takes another function and wraps it. You may have seen a decorator if you've used any web development framework be it Flask or Django in Flask it looks like this.

@app.route('/home/')
def index():
    return render_template('index.html')
Enter fullscreen mode Exit fullscreen mode

Functions as first-class objects

To go in depth on how decorators work we need to understand how functions in python behave. In python functions are treated as first-class objects, what that means is that functions behave like normal python objects like list, sets, integers, dict e.t.c. and can be passed to variables and stored in containers such as list and also have attributes some example.

def func(arg):
    return arg * 2

a = func
print(a(6))
print(func.__name__)
Enter fullscreen mode Exit fullscreen mode

Output:

12
func
Enter fullscreen mode Exit fullscreen mode

In the above code we created a function called func which returns the double of any number passed in, we then assigned the variable a to the function which means the variable a points to the address of the func object in memory. So calling a(6) will be the same as calling func(6). The last line calls the __name__ attribute of the function which returns the name of the function.

Inner Functions

Functions in python can be defined in another function and can be accessed only inside the outer function. But to access the inner function in the global scope we return it and pass it to a variable in the global scope which will be used as reference to it.

def some_function():

    def other_function(arg):
        if arg % 2 == 0: return True
        return False

    return other_function

ref = some_function()

print(ref(16))
Enter fullscreen mode Exit fullscreen mode

Output:

True
Enter fullscreen mode Exit fullscreen mode

As seen above the other_function is been passed to the ref variable when the some_function function is called. But if the other_function is called outside the function scope it will raise an error, so to access the other_function outside the some_function scope we return it and pass it to a variable which reference the function in memory.

Use Case

Let begin to see how all we've learned so far about functions is going to build up to decorators and an actual use case of how it's used in the real world. So we are going to create a factorial function and time it to see how long it takes on different inputs.

import time
def factorial(n):
    res = 1
    while n > 0:
        res *= n
        n -= 1
    return res


start = time.time()
factorial(2000)
finish = time.time() - start
print("Execution factorial(%d) finished in %5f(s)"%(2000, finish)) 

start = time.time()
factorial(50000)
finish = time.time() - start
print("Execution factorial(%d) finished in %5f(s)"%(50000, finish)) 

start = time.time()
factorial(100000)
finish = time.time() - start
print("Execution factorial(%d) finished in %5f(s)"%(100000, finish))
Enter fullscreen mode Exit fullscreen mode

Output:

Execution factorial(2000) finished in 0.015591(s)
Execution factorial(50000) finished in 2.854175(s)
Execution factorial(100000) finished in 15.139478(s)
Enter fullscreen mode Exit fullscreen mode

The factorial function is passed different input and the time elapsed in calculating the output is what we care about. So the run time got printed out, but this code seems a lot messy, and we are not abiding by the DRY (Do not repeat yourself) principle. So to make this cleaner we use what we learned above about inner functions and utilize it for our goal.

def timer(func):

    def wrapper(*args, **kwargs):
        start = time.time()
        func(*args, **kwargs)
        finish = time.time() - start
        print("Execution %s(%s) finished in %5f(s)"%(func.__name__,args[0], finish))

    return wrapper
Enter fullscreen mode Exit fullscreen mode

Ok there's a lot to unpack here but I'll make sure to break it down bits by bits. So basically the timer function takes another function as an argument as func and the wrapper function is an inner function that takes any amount of positional and keyword arguments, it's common to see the wrapper function written with *args and **kwargs more on positional and keyword arguments here: https://realpython.com/python-kwargs-and-args/. The use of args and kwargs in wrapper is because we want to be able to use the decorator on different functions with varying number of arguments so in order to handle that we use args and kwargs. We create our start timer and call the function passed to the timer function with the arguments passed to the wrapper after that we print the time elapsed for calling the function, then we return the wrapper function. Here's how to use the timer function.

factorial = timer(factorial)
factorial(2000)
factorial(50000)
factorial(100000)
Enter fullscreen mode Exit fullscreen mode

Output:

Execution factorial(2000) finished in 0.000000(s)
Execution factorial(50000) finished in 2.691417(s)
Execution factorial(100000) finished in 14.838543(s)
Enter fullscreen mode Exit fullscreen mode

We called the timer function and pass it the factorial function as argument and the wrapper function will be returned and passed to the factorial variable so the factorial is now a reference to the wrapper function and when executing factorial(n) is the same as executing wrapper(n). The reason we pass in the original factorial function to the timer is for it to be available only in the local scope as func which is accessible to wrapper since its in the timer function. So when we call the factorial of any number we are calling the wrapper function and accessing the original factorial function pass the values to it and record its runtime and printing it out.

Python Syntax Sugar

So what we built is a decorator in a sense, but to make this more pythonic, python have a syntax to make decorators easier to write using the @ sign. The decorator is put above the function it wants to wrap and that's basically it.

@timer
def factorial(n):
    res = 1
    while n > 0:
        res *= n
        n -= 1
    return res


factorial(2000)
factorial(50000)
factorial(100000)
Enter fullscreen mode Exit fullscreen mode

It works the same as previous just more clean and pythonic and now the timer can be used for any function and that's why we needed to add args and kwargs to the wrapper because we don't know how many arguments may be passed in, as seen below we created a function that takes two parameters and the wrapper gets the value and pass it to the func.

@timer
def waist_time(s,t):
    for _ in range(t):
        time.sleep(s)
Enter fullscreen mode Exit fullscreen mode

Output:

Execution waist_time(1) finished in 10.009515(s)
Enter fullscreen mode Exit fullscreen mode

Some Constraints

There's a caveat when using decorators the function which is being wrapped by the decorator attributes will be changed to the wrapper function attributes as seen below.

print(waist_time.__name__)
Enter fullscreen mode Exit fullscreen mode

Output:

wrapper
Enter fullscreen mode Exit fullscreen mode

This may not be a big deal to you or even change the functionality of your code, but its necessary to retain the function name for debugging purpose, so to retain the properties of the original function we wrap, to do this we use the wrap function in the functools module and decorate the wrapper function with it like this.

import functools

def timer(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        start = time.time()
        func(*args, **kwargs)
        finish = time.time() - start
        print("Execution %s(%s) finished in %5f(s)"%(func.__name__,args[0], finish))

    return wrapper
Enter fullscreen mode Exit fullscreen mode

So running the defining the decorator like this and retains the attribute of the wrapped function and calling the __name__ attribute it returns the function name.

Nested Decorators

A function can be decorated with more than one decorator and the top decorator wraps the one below and the one below wraps what's below it.

def pad_0(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        a = func()
        res = '0' + a + '0'
        return res
    return wrapper

def pad_5(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        a = func()
        res = '5' + a + '5'
        return res
    return wrapper

@pad_5
@pad_0
def greet(name):
    return "Hello %s"%(name)

print(greet("sarah"))
Enter fullscreen mode Exit fullscreen mode

Output:

50Hello sarah05
Enter fullscreen mode Exit fullscreen mode

As seen above the pad_0 function wraps the greet function output then the pad_5 function wraps the pad_0 function and returns the output. More decorators can be stacked on top, but it begins to get difficult to manage or debug the function so you'll mostly use 3 to 1 decorators. And another useful thing to note is that the order of how the decorators are staked matters and changes the output take a look at this.

@pad_0
@pad_5
def greet(name):
    return "Hello %s"%(name)

print(greet("sarah"))
Enter fullscreen mode Exit fullscreen mode

Output:

05Hello sarah50
Enter fullscreen mode Exit fullscreen mode

As you can see the output changes if the order of the decorator is changed slightly so it's a good thing to note when using stacked decorators.

Decorator Argument

Decorators can be passed in arguments like normal function but doing this requires two or more nested functions in the decorator.
some example is @app.route('/home/'), here '/home/' is our argument passed to the route decorator, Here's how to create one.

def repeat_n_times(n):
    def dec_wrapper(func):
        def wrapper(*args, **kwargs):
            for _ in range(n):
                print(func(*args, **kwargs))
        return wrapper
    return dec_wrapper

@repeat_n_times(5)
def say_hello():
    return "hello world!"
say_hello()
Enter fullscreen mode Exit fullscreen mode

Output:

hello world!
hello world!
hello world!
hello world!
hello world!
Enter fullscreen mode Exit fullscreen mode

Advanced Use Case

One advanced use case of decorators are for memoization used in dynamic programming, basically what the memoization is trying to do is reduce redundant function calls to improve speed of a program by storing computed output and access them in the future instead of re-computing it. so an example will be calculating Fibonacci sequence. An understanding of Fibonacci sequence and how to write a recursive algorithm to solve it in python is required, so you can check this out https://www.edureka.co/blog/python-fibonacci-series/ or follow along.

def memoize_fibb(func):

    @functools.wraps(func)
    def wrapper(num):
        if len(wrapper.memo) == 0:
            wrapper.memo = [None] * (num+1)
        elif wrapper.memo[num]:
            return wrapper.memo[num]
        wrapper.memo[num] = func(num)
        return wrapper.memo[num]

    wrapper.memo = []
    return wrapper

@memoize_fibb
def fibonacci(n):
    if n < 2:
        return n
    return fibonacci(n-1) + fibonacci(n-2)

print(fibonacci(13))
Enter fullscreen mode Exit fullscreen mode

The wrapper.memo is an attribute of the wrapper function and its a list which is initialized with n+1 values of none on the first call, then we check if the nth value is something other than None then we return the value in the list else we calculate it and store its value in the list on every function call.

Top comments (1)

Collapse
 
bdcoder profile image
Bikash Jain

The artciel is well written, u have created the hook in this article.

And also I went to Edureka Article - read the article and feel like not fully articulated.

Then I read this article scaler.com/topics/fibonacci-series... on Scaler Topics, I would suggest you to read this article once.

Hope this comment add some value.