Decorators in Python
5 min read

Decorators in Python

Intro

If you're here, you probably want to learn about decorators or understand more about them. Well, let's start by the beginning, since decorators work around functions, we'll have to ask ourselves, what are functions? Or perhaps how do functions work?
The reason we have to ask this question is because decorators make use of a certain property of python's function: they behave like objects. Even more interestingly, we can pass functions as arguments. Here are two examples:

def add(a, b):
    return a+b
    
print(add)

Output:<function add at 0x0000029C41DA3E20>
or

def add(a, b):
    return a+b
    
def use_func(f):
    return f(10,5)
    
x = use_func
print(x(add))

Output:15

Alright, we can use functions as objects, so what? Well, being able to call a function passed inside another function as an argument is really the whole idea behind decorators. Here is a basic example of how we can do by calling a function inside of another:

def outer(function):
    def wrapper():
        print(f"The {function.__name__} function was called")
        function()
    return wrapper #We return the function as an object, the function is not called yet here
     
def to_do():
    print("Call me to greet you")
    print("Hello there!")

print("output 1:",outer(to_do)) #here, we don't call the returned function "wrapper"
print("output 2:", end=" ")
outer(to_do)() #here, the returned "wrapper" function is called, notice the difference in the output

Output:

output 1: <function outer.<locals>.wrapper at 0x0000023D459813F0>
output 2: The to_do function was called
Call me to greet you
Hello there!

Notice, we actually have to put the two brackets next to the outer() function to call it, as it is handled like an object. This was a basic example of passing a function as an argument through another function to accomplish more things. If every time we called to_do, we wanted to announce that this function was called, we could rewrite is like this:

def outer(function):
    def wrapper():
        print(f"The {function.__name__} function was called")
        function()
    return wrapper #We return the function as an object, the function is not called yet here
     
def to_do():
    print("Call me to greet you")
    print("Hello there!")

to_do = outer(to_do)
to_do()

Output:

The to_do function was called
Call me to greet you
Hello there!

And now, every time we call the to_do function, it will pass through the outer function. And this is precisely what decorators do.

Re-writing decorators correctly

The last structure of the previous paragraph is the idea behind decorators, but is a bit tedious to write:

def dec(f):
    def wrapper():
        #do stuff
        f() #call the function
        #do more stuff
    return wrapper
    
def function():
	pass
    #this function can do whatever

function = dec(function)

To write it "correctly", decorators use the '@' symbol above the affected function:

def dec(f):
    def wrapper():
        #do stuff
        f() #call the function
        #do more stuff
    return wrapper
    
@dec
def function():
    pass
    #this function can do whatever

This block of code and the previous one are exactly the same.

Basically,

@x
def f():
    pass

and

def f():
    pass
f = x(f)

Mean the same thing.

Examples

For now, decorators as I have shown them are pretty basic, meaning we can't pass any argument through the function, and can't get a return value either. Therefore I'll only show basic examples, a bit below, I'll show how to implement those functionalities.
The first example is the decorator used for measuring the time performance of a function

import time

def timeit(func):
    def wrapper():
        t_start = time.perf_counter()
        func()
        t_end = time.perf_counter()
        print(f"elapsed time:{t_end-t_start}")
    return wrapper
        
@timeit
def say_hello():
    print("hello, world!")
    
@timeit
def check_array():
    #I'll show a bit below how to pass arguments with decorators
    array = [[i*j for j in range(100)] for i in range(100)]
    value = 10
    for i in array:
        for j in i:
            if j==value:
                print("value found!")
                
say_hello()
check_array()

Output:

hello, world!
elapsed time:0.00012050004443153739
value found!
value found!
value found!
value found!
elapsed time:0.001046299992594868

Another way I use decorators is for executing functions in another directory. For example, when I'm working with files, sometimes, I'll store different files in different directories, so I need to be able to browse the directory easily, here is what a basic example of this concept would look like:

import os
os.chdir("resources")

def current_dir(func):
    def wrapper():
        os.chdir("../")
        func()
        os.chdir("resources")
    return wrapper
  
@current_dir  
def create_file():
    with open("here.txt", 'w') as f:
        f.write("this file is created in the directory above the /resource folder")
        
with open("there.txt", 'w') as f:
    f.write("this file is created in the /resource folder")
    
create_file()

In this case, say there is a folder named "resource", in which are all the files we need to work with, and as an exception, the create_file() function needs to create a file, not in the "resource" folder but in the same folder as the main python file, this is how we could use a decorator to accomplish that.

But honestly, this version of decorators is pretty basic, let's see how we can go further.

Going further: Args, Kwargs, and return values

Args and Kwargs

Args and Kwargs are a way of passing an arbitrary number of arguments and keyword arguments through a function. I won't dwell on it too much this time, but I'll make a separate article on it.

The way we implement arguments and keyword arguments with a decorator is through the wrapper function, since it is that function that will be returned, it's actually pretty easy:

def say_hi(func):
    def wrapper(*args, **kwargs):
        print("hello from the wrapper function!")
        func(*args, **kwargs)
    return wrapper
    
@say_hi
def multiply(a, b, c=2):
    print("the result is", a*b*c)
    
multiply(10, 5)

Output:

hello from the wrapper function!
the result is 100

Returning a value

We will have to modify the wrapper function as well in this case, the idea is to hold the value of the returned function in a variable and return that variable at the end of the wrapper function. Pretty straightforward right?

def say_hi(func):
    def wrapper(*args, **kwargs):
        print("hello from the wrapper function!")
        temporary_var = func(*args, **kwargs)
        print("if we returned the function directly, this wouldn't have been printed")
        return temporary_var
    return wrapper
    
@say_hi
def multiply(a, b, c=2):
    return a*b*c
    
result = multiply(10, 5, c=3)
print("the result is", result)

Output:

hello from the wrapper function!
if we returned the function directly, this wouldn't have been printed
the result is 150

To finish, here is the previous timeit function with the updated version of the decorators:

import time

def timeit(func):
    def wrapper(*args, **kwargs):
        t_start = time.perf_counter()
        temporary_var = func(*args, **kwargs)
        t_end = time.perf_counter()
        print(f"elapsed time:{t_end-t_start}")
        return temporary_var
    return wrapper
    
@timeit
def check_array(array, value):
    n = 0
    for i in array:
        for j in i:
            if j==value:
                n += 1
    return n
    
array = [[i+j for j in range(100)] for i in range(100)]
value = 7
found = check_array(array, value)
print(f"found {found} instances of the searched value in the array")

Output:

elapsed time:0.0003513999981805682
found 8 instances of the searched value in the array

Thanks a lot for reading until the end! Consider subscribing to my newsletter as it is free and keeps you updated with all my new articles about programming techniques.