Function decorators allow us  to modify the behavior of a function or method  without having to change the source code of the function being decorated. 

They are used to wrap a function, modify its behavior, and return the modified function or a new function with the same or different name.

Decorators are quite common in Python and programming in general, Python offers an easier and more convenient syntax to  work with them. The syntax as shown below:

@decorator_function
def function_to_decorate():
      <block> 

A decorator function is a higher order function that takes a function as an argument and  returns a new function commonly referred to as a wrapper . The wrapper function is used to modify or extend the behavior of the original function, it is also responsible of invoking the original function.

Example:

def my_decorator(func):
    def wrapper(): 
        print("before calling the function.")  
        func()
        print("after calling the function.")
    return wrapper

@my_decorator
def my_function():
    print("Hello, World!")

my_function()
//Before calling the function.
//Hello, World!
//After calling the function. 

Without using the short hand syntax, we would need to explicitly pass the function to decorate as an argument to the decorator function, as shown below:

def my_decorator(func):
    def wrapper(): 
        print("before calling the function.")  
        func()
        print("after calling the function.")
    wrapper()

def my_function():
    print("Hello, World!")

my_decorator(my_function)
//Before calling the function.
//Hello, World!
//After calling the function. 

When you call a decorated function with arguments, the arguments are first passed to the wrapper function, which then forwards them to the original function.

def addition_decorator(func):
    def wrapper(a, b):
        return "%s + %s = %s"%(a, b, str(func(a, b)))
    return wrapper

@addition_decorator
def add(a, b):
    return a + b

print(add(60, 50))
//60 + 50 = 110

If the wrapper function does not define any parameters and arguments are given to the decorated function, an error will be raised . Example: 

def addition_decorator(func):
    def wrapper():
        return func()
    return wrapper

@addition_decorator
def add(a, b):
    return a + b

print(add(60, 50))
//TypeError: addition_decorator.<locals>.wrapper() takes 0 positional arguments but 2 were given

It is common to define wrapper functions which take arbitrary arguments as shown below:

def addition_decorator(func):
    def wrapper(*args, **kwargs):
        return func(*args, **kwargs)
    return wrapper

@addition_decorator
def add(a, b):
    return a + b

print(add(60, 50))
//110

A timing decorator function:

from datetime import datetime
def timer(func):
    def wrapper(*args, **kwargs):
        start = datetime.now()
        print("Execution started at {}".format(start.strftime('%H:%M:%S.%f')))
        func(*args,**kwargs)
        stop = datetime.now()
        print("Execution stopped at", stop.strftime('%H:%M:%S.%f'))
        print("Function '{}' took {}".format(func.__name__, stop - start ))
    return wrapper

@timer
def add(a, b):
    print("%s + %s = %s"%(a, b, a + b))

add(9, 10)
//Execution started at 13:16:51.718734
//9 + 10 = 19
//Execution stopped at 13:16:51.721756
//Function 'add' took 0:00:00.003022
@timer
def delayed_add(a, b):
    import time
    time.sleep(2)
    print(a + b)
    time.sleep(2)

delayed_add(20, 40)
//Execution started at 13:18:27.228801
//60
//Execution stopped at 13:18:31.232093
//Function 'delayed_add' took 0:00:04.003292

Decorator functions with arguments:

Decorator functions can also take additional arguments besides the function being decorated. The syntax of calling such a decorator is as shown below;

@my_decorator(arguments)
def my_function():
    <block>

Defining a decorator function that takes additional arguments requires a slightly different syntax. Instead of defining the decorator function to take a single function as an argument, we define it to take any number of arguments, including the function being decorated. Then we define an inner function that takes the function as its argument and returns a wrapper function that uses the additional arguments passed to the decorator. Example:

def repeat(times):
    def decorator(func):
         def wrapper(*args, **kwargs):
             for i in range(times):
                 func()
         return wrapper
    return decorator

@repeat(5)
def func():
    print("Hello, World!")

func()
//Hello, World!
//Hello, World!
//Hello, World!
//Hello, World!
//Hello, World!

Multiple decorator functions

When multiple decorations are applied to a Python function, they are evaluated and applied in the order in which they appear. The first decoration will wrap the original function, and each successive decoration will wrap the result of the previous decoration. This means that the innermost decorator will be evaluated and applied first, followed by the decorators that wrap it from the outside in. Example:

def decorator1(func):
    def wrapper():
        print("Decorator 1 before function call")
        func()
        print("Decorator 1 after function call")
    return wrapper

def decorator2(func):
    def wrapper():
        print("Decorator 2 before function call")
        func()
        print("Decorator 2 after function call")
    return wrapper

@decorator1
@decorator2
def my_function():
    print("Hello, World!")

my_function()
//Decorator 1 before function call
//Decorator 2 before function call
//Hello, World!
//Decorator 2 after function call
//Decorator 1 after function call

When to use decorator Functions

Decorators can be used to add extra functionality to an existing function, such functionality can include: 

  1. Logging
  2. Caching
  3. Rate limiting
  4.  Authentication/Authorization
  5. Input/Output Sanitization
  6. Executing functions in parallel.
  7. Adding additional features to functions like argument binding, error handling and retry logic.
  8. Adding context managers to functions.