Python Decorators#

A decorator takes in a function, adds some functionality and returns it. In this tutorial, you will learn how you can create a decorator and why you should use it.

Decorators in Python#

Python has an interesting feature called decorators to add functionality to an existing code.

A decorator is a design pattern in Python that allows a user to add new functionality to an existing object without modifying its structure. Decorators are usually called before the definition of a function you want to decorate.

This is also called metaprogramming because a part of the program tries to modify another part of the program at compile time.

Prerequisites for learning decorators#

In order to understand about decorators, we must first know a few basic things in Python.

We must be comfortable with the fact that everything in Python (Yes! Even classes), are objects. Names that we define are simply identifiers bound to these objects. Functions are no exceptions, they are objects too (with attributes). Various different names can be bound to the same function object.

For example:

def first(msg):
    print(msg)


first("Hello")

second = first
second("Hello")
Hello
Hello

When you run the code, both functions first and second give the same output. Here, the names first and second refer to the same function object.

Now things start getting weirder.

Functions can be passed as arguments to another function.

If you have used functions like map, filter and reduce in Python, then you already know about this.

Such functions that take other functions as arguments are also called higher order functions. Here is an example of such a function.

>>> def inc(x):
>>>     return x + 1


>>> def dec(x):
>>>     return x - 1


>>> def operate(func, x):
>>>     result = func(x)
>>>     return result

We invoke the function as follows:

>>> operate(inc,3)
4
>>> operate(dec,3)
2

Furthermore, a function can return another function.

def is_called():  # created 1st function
    def is_returned():  # Created 2nd function (nested)
        print("Hello")
    return is_returned


new = is_called()

# Outputs "Hello"
new()
Hello

Here, is_returned() is a nested function which is defined and returned each time we call is_called().

Finally, we must know about Closures in Python.

# Normal function
def greeting():
    return 'Welcome to Python'
def uppercase_decorator(function):
    def wrapper():
        func = function()
        make_uppercase = func.upper()
        return make_uppercase
    return wrapper
g = uppercase_decorator(greeting)
print(g())          # WELCOME TO PYTHON
WELCOME TO PYTHON

Let us implement the example above with a decorator

'''This decorator function is a higher order function
that takes a function as a parameter'''
def uppercase_decorator(function):
    def wrapper():
        func = function()
        make_uppercase = func.upper()
        return make_uppercase
    return wrapper
@uppercase_decorator
def greeting():
    return 'Welcome to Python'
print(greeting())   # WELCOME TO PYTHON
WELCOME TO PYTHON

Getting back to Decorators#

Functions and methods are called callable as they can be called.

In fact, any object which implements the special __call__() method is termed callable. So, in the most basic sense, a decorator is a callable that returns a callable.

Basically, a decorator takes in a function, adds some functionality and returns it.

def make_pretty(func):
    def inner():
        print("I got decorated")
        func()
    return inner


def ordinary():
    print("I am ordinary")

When you run the following codes in shell,

ordinary()
I am ordinary
pretty = make_pretty(ordinary)
pretty()
I got decorated
I am ordinary

In the example shown above, make_pretty() is a decorator. In the assignment step:

>>> pretty = make_pretty(ordinary)

The function ordinary() got decorated and the returned function was given the name pretty.

We can see that the decorator function added some new functionality to the original function. This is similar to packing a gift. The decorator acts as a wrapper. The nature of the object that got decorated (actual gift inside) does not alter. But now, it looks pretty (since it got decorated).

Generally, we decorate a function and reassign it as,

>>> ordinary = make_pretty(ordinary).

This is a common construct and for this reason, Python has a syntax to simplify this.

We can use the @ symbol along with the name of the decorator function and place it above the definition of the function to be decorated. For example,

>>> @make_pretty
>>> def ordinary():
>>>     print("I am ordinary")

is equivalent to

>>> def ordinary():
>>>     print("I am ordinary")
>>> ordinary = make_pretty(ordinary)

This is just a syntactic sugar to implement decorators.

Decorating Functions with Parameters#

The above decorator was simple and it only worked with functions that did not have any parameters. What if we had functions that took in parameters like:

def divide(a, b):
    return a/b

This function has two parameters, a and b. We know it will give an error if we pass in b as 0.

divide(2,5)
0.4
divide(2,0)
---------------------------------------------------------------------------
ZeroDivisionError                         Traceback (most recent call last)
<ipython-input-10-030e2eec799d> in <module>
----> 1 divide(2,0)

<ipython-input-8-7507bdc665d5> in divide(a, b)
      1 def divide(a, b):
----> 2     return a/b

ZeroDivisionError: division by zero

Now let’s make a decorator to check for this case that will cause the error.

def smart_divide(func):
    def inner(a, b):
        print("I am going to divide", a, "and", b)
        if b == 0:
            print("Whoops! cannot divide with 0")
            return

        return func(a, b)
    return inner


@smart_divide
def divide(a, b):
    print(a/b)

This new implementation will return None if the error condition arises.

divide(2,5)
I am going to divide 2 and 5
0.4
divide(2,0)
I am going to divide 2 and 0
Whoops! cannot divide with 0
# Example:

def decorator_with_parameters(function):
    def wrapper_accepting_parameters(para1, para2, para3):
        function(para1, para2, para3)
        print("I live in {}".format(para3))
    return wrapper_accepting_parameters

@decorator_with_parameters
def print_full_name(first_name, last_name, country):
    print("I am {} {}. I love to teach.".format(
        first_name, last_name, country))

print_full_name("Milaan", "Parmar",'London')
I am Milaan Parmar. I love to teach.
I live in London

In this manner, we can decorate functions that take parameters.

A keen observer will notice that parameters of the nested inner() function inside the decorator is the same as the parameters of functions it decorates. Taking this into account, now we can make general decorators that work with any number of parameters.

In Python, this magic is done as function(*args, **kwargs). In this way, args will be the tuple of positional arguments and kwargs will be the dictionary of keyword arguments. An example of such a decorator will be:

>>> def works_for_all(func):
>>>     def inner(*args, **kwargs):
>>>         print("I can decorate any function")
>>>         return func(*args, **kwargs)
>>>     return inner

Chaining Decorators in Python#

Multiple decorators can be chained in Python.

This is to say, a function can be decorated multiple times with different (or same) decorators. We simply place the decorators above the desired function.

def star(func):
    def inner(*args, **kwargs):
        print("*" * 30)
        func(*args, **kwargs)
        print("*" * 30)
    return inner


def percent(func):
    def inner(*args, **kwargs):
        print("%" * 30)
        func(*args, **kwargs)
        print("%" * 30)
    return inner


@star
@percent
def printer(msg):
    print(msg)


printer("Hello")
******************************
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
Hello
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
******************************

The above syntax of,

>>> @star
>>> @percent
>>> def printer(msg):
>>>     print(msg)

is equivalent to

>>> def printer(msg):
>>>     print(msg)
>>> printer = star(percent(printer))

The order in which we chain decorators matter. If we had reversed the order as,

>>> @percent
>>> @star
>>> def printer(msg):
>>>     print(msg)

The output would be:

%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
******************************
Hello
******************************
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%

Applying Multiple Decorators to a Single Function

'''These decorator functions are higher order functions
that take functions as parameters'''

# First Decorator
def uppercase_decorator(function):
    def wrapper():
        func = function()
        make_uppercase = func.upper()
        return make_uppercase
    return wrapper

# Second decorator
def split_string_decorator(function):
    def wrapper():
        func = function()
        splitted_string = func.split()
        return splitted_string

    return wrapper

@split_string_decorator
@uppercase_decorator     # order with decorators is important in this case - .upper() function does not work with lists
def greeting():
    return 'Welcome to Python'
print(greeting())   # WELCOME TO PYTHON
['WELCOME', 'TO', 'PYTHON']

Built-in Higher Order Functions#

Some of the built-in higher order functions that we cover in this part are map() and filteR(). Lambda function can be passed as a parameter and the best use case of lambda functions is in functions like map() and filter().

Python - map Function#

The map() function is a built-in function that takes a function and iterable as parameters.

    # syntax
    map(function, iterable)
# Example 1: 

numbers = [1, 2, 3, 4, 5] # iterable
def square(x):
    return x ** 2
numbers_squared = map(square, numbers)
print(list(numbers_squared))    # [1, 4, 9, 16, 25]
# Lets apply it with a lambda function
numbers_squared = map(lambda x : x ** 2, numbers)
print(list(numbers_squared))    # [1, 4, 9, 16, 25]
[1, 4, 9, 16, 25]
[1, 4, 9, 16, 25]
# Example 2: 

numbers_str = ['1', '2', '3', '4', '5']  # iterable
numbers_int = map(int, numbers_str)
print(list(numbers_int))    # [1, 2, 3, 4, 5]
[1, 2, 3, 4, 5]
# Example 3: 

names = ['Milaan', 'Arthur', 'Bill', 'Clark']  # iterable

def change_to_upper(name):
    return name.upper()

names_upper_cased = map(change_to_upper, names)
print(list(names_upper_cased))    # ['Milaan', 'Arthur', 'Bill', 'Clark']

# Let us apply it with a lambda function
names_upper_cased = map(lambda name: name.upper(), names)
print(list(names_upper_cased))    # ['Milaan', 'Arthur', 'Bill', 'Clark']
['MILAAN', 'ARTHUR', 'BILL', 'CLARK']
['MILAAN', 'ARTHUR', 'BILL', 'CLARK']

What actually map does is iterating over a list. For instance, it changes the names to upper case and returns a new list.

Python - filter Function#

The filter() function calls the specified function which returns boolean for each item of the specified iterable (list). It filters the items that satisfy the filtering criteria.

    # syntax
    filter(function, iterable)
# Example 1: 

numbers = [1, 2, 3, 4, 5]  # iterable

def is_even(num):
    if num % 2 == 0:
        return True
    return False

even_numbers = filter(is_even, numbers)
print(list(even_numbers))       # [2, 4]
[2, 4]
# Example 2: 

numbers = [1, 2, 3, 4, 5]  # iterable

def is_odd(num):
    if num % 2 != 0:
        return True
    return False

odd_numbers = filter(is_odd, numbers)
print(list(odd_numbers))       # [1, 3, 5]
[1, 3, 5]
# Example 3: Filter long name

names = ['Milaan', 'Arthur', 'Bill', 'Clark']  # iterable
def is_name_long(name):
    if len(name) > 5:
        return True
    return False

long_names = filter(is_name_long, names)
print(list(long_names))         # ['Milaan', 'Arthur']
['Milaan', 'Arthur']