Mastering the Magic of Python Decorators: A Powerful Tool for Clean Code

Today, we will talk about Python decorators one of the most beautiful and magical features of Python.

You have probably seen or used decorators before. They are signaled by the @ sign followed by a name and then a function. For example, @classmethod is used within a class to say that the function that follows is a class method. Other decorators you may have seen include @property, which creates a managed property, and @staticmethod, which is a function that is in a class (for organization purposes) but is not part of a class.

However, I wrongly assumed they were like keywords. Therefore, I did not spend much time researching. However, I learned this is not the case. Decorators are actually a powerful tool that allows you to transform a function with very little code. This ability to transform can add powerful functionality while not writing the same code over and over (Don’t Repeat Yourself). In addition, Python has many more built in decorators than the ones I listed above.

In this post, we will cover the basic concept of decorators, how to create a decorator, and how to use a decorator.

The Basic Concept

To demonstrate the basic concept, we are going to use a timer decorator that outputs how long a function took to run. Before we get to the decorator, let’s start with the following code:

import time

def WaitFunction():
   print("starting function . . .")
   x = 0
   
   for i in range(10000):
       for j in range(10000):
           x += i*j

   print("ending function . . .")

def RunFunction():
   start_time = time.time()
   WaitFunction()
   end_time = time.time()

   print(f"Function took {end_time-start_time} to execute.")

 WaitFunction is just a function designed to take a long time to run. It doesn’t do anything productive, but you can imagine a productive function here. In the RunFunction, we capture the start time, run the function, and then capture the end time. This works, but if we wanted to time a different function, we would need to repeat the start_time, end_time, and print statements. This would be repetitive and ugly.

This is where decorators come in. Decorators are based on the idea that Python functions are just objects, which means we can pass a function to another function. In this case, we are going to pass the RunFunction to a timer function. In the timer function, we will wrap the input function with our timer statements.

import time

def FunctionTimer(func):
   start_time = time.time()
   result = func()
   end_time = time.time()

   print(f"Function took {end_time-start_time} to execute.")

def WaitFunction():
   print("starting function . . .")
   x = 0
   for i in range(10000):
       for j in range(10000):
           x += i*j
   print("ending function . . .")

def RunFunction():
   FunctionTimer(WaitFunction)

Creating a Python Decorator

The code above significantly simplifies our RunFunction and allows us to reuse the FunctionTimer code for every function we want to time, but we can simplify even further. If we change the FunctionTimer code to the following, we can now use @FunctionTimer in front of any function we want to time.

def FunctionTimer(func):
   def wrap(*args,**kwargs):
       start_time = time.time()
       result = func()
       end_time = time.time()

       print(f"Function took {end_time-start_time} to execute.")

       return result
   return wrap

The basic structure of a property is an outer function that takes a function as a parameter and an inner function that takes two parameters, *args and **kwargs.

Args and Kwargs

Within the inner function, you see that it takes two parameters with a different notation than a normal parameter. The first parameter has a * and the second parameter has **. The *args means it takes a variable length of arguments. You can name this whatever you like (e.g., *pizza), but by convention *args is used.  **kwargs is a variable list of keywords. For example, using the following code:

def TestArgs(*args, **kwargs):
   print(args)
   print(kwargs)

If we type:

TestArgs(‘pizza’,10,’cat’)

It results in:

('pizza', 10, 'cat')
{}

If we type:

TestArgs(food='pizza', age=10, pet='cat')

It results in:

()
{'food': 'pizza', 'age': 10, 'pet': 'cat'}

As you can see, if the parameters have no keywords, args is a tuple with the parameters. If keywords are supplied, then args is an empty tuple, but kwargs is a dictionary with the keywords and values.

Using a Python Decorator

Now we can simply add @FunctionTimer before the declaration of any function that we want to time.

@FunctionTimer
def WaitFunction():
   print("starting function . . .")

   x = 0

   for i in range(10000):
       for j in range(10000):
           x += i*j

   print("ending function . . .")

With this, we just have to call WaitFunction() and it results in:

starting function . . .
ending function . . .
Function took 6.915534019470215 to execute.

Conclusion

As you can see, decorators add substantial functionality to Python. You can change a function without actually changing the function itself. The ideas are endless, but to get you started, here are some interesting ideas for decorators:

Hopefully, this post was helpful. If you have a great decorator that you use or an idea for a decorator, drop it in the comments below.