Zebra Codes

Decorators in Python

15th of April, 2024

Python’s documentation isn’t entirely clear on what a decorator is or how it works, so here are the details.

What Is a Decorator?

A decorator is a way to apply a wrapper around function. It’s given the name “decorator” because of the way you attach it to the function being wrapped: like a decoration.

The decorator itself does not actually wrap the function. A better name for it might be a wrapper factory.

Python unfortunately refers to both “wrapper factories” and “wrapper-factory factories” as “decorators”, which is quite confusing.

An Aside: Closures

Closures are important to the workings of the decorator. If you already know what a closure is, skip to the next heading. If not, here is a brief explanation.

A closure allows you to create a function and have it reference variables from its enclosing scope later, when it is executed. Consider the code below:

def create_function(message):

    def say():
        print(message)

   return say

say_hello = create_function("hello")

# Print "hello".
say_hello()

The say() function is keeping a reference to the message variable, even after the create_function function has returned.

The function say() is a closure that closes around the message variable.

It’s equivalent to a class that does this:

class Say:
    __init__(message):
        self.message = message

    __call__()
        print(self.message)

say_hello = Say("hello")

# Print "hello".
say_hello()

Starting Point: A Simple Wrapper

Suppose you wish to wrap my_func() such that it prints a message whenever it is called. This can be achieved by creating a wrapper function that calls my_func(), and then reassigning the variable my_func so it actually points to the wrapper function:

# The function to be wrapped.
def my_func():
    print('Doing some work...')

# The wrapper.
def wrapper():
    print('Hello world.')
    my_func()

# Make it so that calling my_func() actually calls wrapper().
my_func = wrapper

# This will print "Hello world. Doing some work...".
my_func()

Improvement: A Wrapper Factory

The code above achieves the goal, but it has one flaw: Because it calls my_func() directly, the wrapper can only ever wrap my_func(). It would be nice if we could wrap any function in our wrapper.

To achieve this we will create a factory function that returns the wrapper. The function to be wrapped is passed in as a parameter and the wrapper function closes around it, storing a reference to it.

def wrapper_factory(func):

    def wrapper():
        print('Hello world.')
        return func()

    return wrapper

my_func = wrapper_factory(my_func)
my_other_func = wrapper_factory(my_other_func)

This will now print “Hello world.” before any wrapped function.

Syntactic Sugar: @decorator

The @decorator syntax in Python is a simple syntax for the above: It runs the wrapper factory and replaces the function with the wrapped function.

def wrapper_factory(func):

    def wrapper():
        print('Hello world.')
        return func()

    return wrapper

@wrapper_factory
def my_func():
    print('Doing some work...')   

Customizing the Decorator: A Wrapper-Factory-Factory

Suppose now that you wish to customize the wrapper function for each function that you wrap. This is achieved by creating a factory function that returns a wrapper factory. This allows you to pass variables in to the wrapper-factory-factory and have the wrapper-factory or the wrapper close around them.

For example, suppose you wish to customize the message printed by the wrapper:

def wrapper_factory_factory(message):

    def wrapper_factory(func):

        def wrapper():
            print(message)
            return func()

        return wrapper

    return wrapper_factory

@wrapper_factory_factory('HELLO')
def my_func():
    print('Doing some work...')

# This will print "HELLO Doing some work..."
my_func()

This is starting to get a little complicated, so lets go through it step by step:

  1. wrapper_factory_factory('HELLO') is called.
    This closes around the message variable and returns a wrapper_factory function.
  2. wrapper_factory(my_func) is called.
    This closes around the func variable and returns a wrapper function.
  3. my_func is replaced by the wrapper function returned from step 2.
  4. The code calls my_func(), which actually calls wrapper().
    wrapper() prints the message “HELLO”, then calls the original my_func() function.

Decorator or Decorator Factory?

If you are working with existing code, how do you know if you are working with a decorator or a decorator factory?

A decorator will not have brackets (parentheses) following it. A decorator factory will.

# No brackets: It is a decorator and returns a wrapper.
@decorator1

# Brackets: It is a decorator factory and returns a decorator.
@decorator2()

If you are creating your own decorator then:

  • The wrapper is always the same: Use a decorator.
  • The wrapper needs to be customized for each function it wraps: Use a decorator factory.

Parameters and Return Values

There are two ways to pass parameters through your wrapper to the wrapped function. If the functions you are wrapping will always have the same parameters then you can just copy them. Otherwise, you can use Python’s variable-parameters syntax. The return value of the wrapped function can be returned directly from the wrapper.

def decorator(func):

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

@decorator
def my_func(... any parameters ...):
    # ...

Decorators That Aren’t Wrappers

One final pattern that you may encounter is to use a decorator to do some work when the function is defined. The decorator then returns the function directly, instead of returning a wrapper around it.

For example, suppose you wish to add your function to a list of commands that users can invoke:

commands = []

def command_decorator(func):
    commands.append(func.__name__)

    return func

@command_decorator
def help():
    # Do some worker...

The code above will add “help” to the commands list. It uses Python’s built-in __name__ property to get the function’s name.

Note that this happens when the help() function is defined, not when it is called.