Introduction

Python has an interesting feature called decorators to add functionality to an existing code. This is also called metaprogramming as a part of the program tries to modify another part of the program at compile time. Even though it is the same underlying concept, we have two different kinds of decorators in Python

  • Function decorators
  • Class decorators

In Decorators, functions are taken as the argument into another function and then called inside the wrapper function. Everything in Python 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.

#1 Assigning Functions to Variables
def first(msg):
  print(msg)    

first("Hello")

second = first
second("Hello")

#2 Functions as Parameters
def inc(x):
  return x + 1

def dec(x):
  return x - 1

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

# Invoking function
operate(inc,3)
operate(dec,3)


#3 Function returning another function
def is_called():
  def is_returned():
    print("Hello")
  return is_returned

new = is_called()

new()

#4 Functions inside Functions
def parent():

  print("Parent() function")

  def first_child():
    print("first_child() function")

  def second_child():
    print("second_child() function")

  second_child()
  first_child()

 

With these prerequisites out of the way, coming back to decorator.  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")
    
    
ordinary()

# Decorate this ordinary function
pretty = make_pretty(ordinary)

pretty()

 

In the example shown above, make_pretty() is a decorator. 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")

# Above is equivalent to
def ordinary():
  print("I am ordinary")

ordinary = make_pretty(ordinary)

 

Multiple Decorators

We can use multiple decorators to a single function. However, the decorators will be applied in the order that we’ve called them. Below we’ll define two decorator,  and then apply these decorator to a single function.

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

  return wrapper
    
def split_string(function):
  def wrapper():
    func = function()
    splitted_string = func.split()
    return splitted_string

  return wrapper

@split_string
@uppercase_decorator
def say_hi():
    return 'hello there'

print(say_hi())

 

Accepting Arguments

Arguments passing to a decorator can be achieved by passing the arguments to the wrapper function. The arguments will then be passed to the function that is being decorated at call time.

def decorator_with_arguments(function):
  def wrapper_accepting_arguments(arg1, arg2):
    print("My arguments are: {0}, {1}".format(arg1,arg2))
    function(arg1, arg2)

  return wrapper_accepting_arguments


@decorator_with_arguments
def cities(city_one, city_two):
  print("Cities I love are {0} and {1}".format(city_one, city_two))

cities("Nairobi", "Accra")

 

General Purpose Decorators

To define a general purpose decorator that can be applied to any function we use args and **kwargs. args and **kwargs collect all positional and keyword arguments (arguments are passed using keywords) and stores them in the args and kwargs variables. args and kwargs allow us to pass as many arguments as we would like during function calls.

def a_decorator_passing_arbitrary_arguments(function_to_decorate):
  def a_wrapper_accepting_arbitrary_arguments(*args,**kwargs):
    print('Positional arguments', args)
    print('Keyword arguments', kwargs)
    function_to_decorate(*args)
  return a_wrapper_accepting_arbitrary_arguments

@a_decorator_passing_arbitrary_arguments
def function_with_no_argument():
  print("Arguments here.")
  
@a_decorator_passing_arbitrary_arguments
def function_with_arguments(a, b, c):
  print(a, b, c)

function_with_arguments(1,2,3)

@a_decorator_passing_arbitrary_arguments
def function_with_keyword_arguments():
  print("This has shown keyword arguments")

function_with_keyword_arguments(first_name="Hello", last_name="Welcome")

 

Returning Values

To return value from decorator, you need to make sure the wrapper function returns the return value of the decorated function.

def do_twice(func):
  def wrapper_do_twice(*args, **kwargs):
      func(*args, **kwargs)
      return func(*args, **kwargs)
  return wrapper_do_twice
  

@do_twice
def return_greeting(name):
  print("Creating greeting")
  return f"Hi {name}"
  
print(return_greeting("Welcome"))

 

Class as Decorator

We can define a decorator as a class, we have to introduce the __call__ method of classes. We mentioned already that a decorator is simply a callable object that takes a function as an input parameter. A function is a callable object. A callable object is an object which can be used and behaves like a function but might not be a function. It is possible to define classes in a way that the instances will be callable objects. The __call__ method is called, if the instance is called “like a function”, i.e. using brackets.

class A:    
  def __init__(self):
    print("An instance of A was initialized")
  
  def __call__(self, *args, **kwargs):
    print("Arguments are:", args, kwargs)
              
x = A()
x(3, 4, x=11, y=10)

# Output
An instance of A was initialized
Arguments are: (3, 4) {'x': 11, 'y': 10}

Decorator implemented as a class

class decorator2:
  def __init__(self, f):
    self.f = f
      
  def __call__(self):
    print("Decorating", self.f.__name__)
    self.f()

@decorator2
def foo():
  print("inside foo()")

foo()