The decorator design pattern in software development is where you can extend something- usually a function or an object- with additional functionality. You can think of it as having a "power-up" or getting one of those stones from Thanos.
A decorator in Python is a special function that modifies the behavior of another function without changing its code. It’s like adding extra features to an existing function in a clean and reusable way.
In python, most decorators are a wrapper function giving you a powered up function- it may take additional args, or do extra things before and/or after you call that function. Let's look at some real examples.
Let's start with a basic one:
def greet():
print("Hello, world!")
Very basic. Now, what if you want to add extra behavior, like printing "---Start---" before and "---End---" after the message?
def my_decorator(func):
def wrapper():
print("---Start---")
func()
print("---End---")
return wrapper
@my_decorator # This applies the decorator to greet()
def greet():
print("Hello, world!")
greet()
@my_decorator wraps the greet() function inside wrapper().
When greet() is called, it first prints "---Start---", then runs the original function, and finally prints "---End---".
That's fine, but what about functions that take arguments?
def my_decorator(func):
def wrapper(*args, **kwargs): # Accept any arguments
print("---Start---")
func(*args, **kwargs) # pass all args to inner func
print("---End---")
return wrapper # return a wrapped func (note it is not run yet!)
@my_decorator
def greet(name):
print(f"Hello, {name}!")
greet("Alice")
and the output will be:
---Start---
Hello, Alice!
---End---
If you are happy with this, and are happy to accept that decorators give functions- and classes- some form of superpower, then stop reading now. If you want to dig, and peer down into the abyss before it looks back at you- read on...
What about a really practical example and we'll take a few steps back, and work forward to the point of using the decorator.
For implementing certain algorithms, it can be really useful to check the runtime of different implementations. Even a variation on two implementations with the "same" Big-O can in practise have small but important differences in "real" runtime. A common and useful example is comparing the runtime of Fibonacci using a loop, using recursion and using recursion with a dict. We won't do that right now, but start with a simple nested loop instead.
This will work fine for this function, but what if I wanted to be able to time many functions? Well, we could place a start-time variable into the first line of all these functions, and just before returning anything, take the end-time, do the calculation, print out the result and then it's all good....isn't it? Well.... NO. It is adding a lot of code that we might want to be able to quickly remove, or put back in again. It might also mess up our application, having all this "timing" stuff going on and print statements talking about something executing in some floating point time period..... Whatever can we do? The first thing we might try is to write a timer function like this:
def time_it(inner_func):
start = time.time()
inner_func()
end = time.time()
duration = end - start # time in seconds
print("Executed in {dur:.4f} seconds".format(dur=duration))
def do_something(n=0):
pass
def do_something_else(n=10, s="abba"):
pass
def do_something_mad(n=10, s="banana", mad=True):
pass
time_it(do_something)
time_it(do_something_else)
time_it(do_something_mad)
This at first glance looks good, but note that whatever the three dummy functions are doing, the functions only run with the defaults, as we are passing a reference to the function. so currently they all take no time to run as n is always zero. You may add some test logic into the functions in an IDE and run it and see that it is not working as we might hope.
"Wait" I hear a voice in the distance.... can't we do this:
time_it(do_something(100))
time_it(do_something_else(55, "apple"))
time_it(do_something_mad(1000, "spidermonkey"))
Then what happens (very annoying) is that the inner functions evaluate first, and whatever is returned gets passed to the time_it function. If that function receives None it will complain that it cannot execute a None, like it would a function, and imply that you are mentally deficient, all while giving you a side look with a tilted head.
What we really want is for the time_it function to actually extend the inner functions with a timer, and give us a new modified function that we can use in it's place.
Now, lets look at what is happening here...
timed_do_something is a variable referencing the time_it function, with the do_something function as an argument
Inside the time_it function, a new function that accepts any number of args is created.
the inner function do_something now accepts any number of args.
the time that it would take to execute the do_something function will be calculated when this function is executed.
the new, unused function is returned, having not been executed
the timed_do_something function is called (line 21) and passed an arg/param of 25
Inside the timed_do_something function, the arg is wrapped in a tuple and passed to the do_something as the first (only) arg called n
the timed_do_something function executes, after the start time is initialised
The nested for loops execute.
the end time is initialised, and duration is calculated.
The execution time is printed out.
Now we have a way to use this method with any function, regardless of how many arg/params are used. But now, there is an easier way to do this- instead of typing all that tedious code such as we have with timed_do_something. It is a syntax-shortcut that lets us use the @ symbol with the name of the decorator function above the function we want to wrap, instead of manually having to do the wrap.
There are endless ways that we can use decorator that we create, but many python modules/libraries have their own decorators. Here are some home-made decorators based on a demo video by pixiegami, shown below.