Mastering Python Function Decorators: Boosting Functionality and Code Efficiency

Written by shekharverma | Published 2023/10/25
Tech Story Tags: python | python-tutorials | learn-python | python-tips | python-development | python-decorators | python-libraries | python-guide

TLDRIn this blog post, I will explain what are function decorators in python and how to use them. We start by understand how functions work, how to nest functions and how to create function decoratos.via the TL;DR App

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:

  1. Understand what decorators are and how they work.

  2. How to create your own decorators

  3. See the practical use case of decorators.

So, let’s jump into it.

Understanding Functions in Python!

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.

Function names are just references to the function

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

Nested Functions

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 :"

Creating Function Decorators

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!")
Output
!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

Exercise

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

Handling arguments in Decorators

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__)

Output

Hello Shekhar
greet
Hello rahkehS
wrapper

The name is an in-built property in Python that returns the name of the object, and as I mentioned before, function names are references to the function objects we can get their names (or you can say the name of the function object).

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.

How to improve our decorators?

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.

  • * Expands any variable as List
  • ** Expands any variable as Dictionary

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.

Using Args and Kwargs in Decorators

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

Another Exercise

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)

Real use Cases

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.

Measuring Performance of Other Functions

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.

Dynamically adding functionality

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.

Wrap-up

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.

  1. How I improved my python code Performance by 371%!

  2. RaspberryPi PICO and PYUSB: Unlocking the Power of Custom USB Endpoints

  3. Why I Started This Blog?

Thank you for reading πŸ˜„


Also published here.


Written by shekharverma | Hi i am Shekhar, I Talk about programming, Computer vision and embedded systems.
Published by HackerNoon on 2023/10/25