Python decorators explained

The decorator design pattern lets us modify the behavior of existing code without changing it. Instead of modifying the code, we can change the parameters, returned values, run additional code or decide to replace the entire decorated code with something else.

In Python, creating a decorator doesn’t require verbose code or a complex setup. Like almost everything in Python, it’s pretty concise.

What’s a decorator and how to define a decorator in Python

First, we need a function we want to decorate. Let’s define a short function for formatting numbers as monetary values:

# this function uses the double type to store monetary values. Don't do this! It's just an example.
def to_currency(value, currency_symbol, symbol_position, decimal_separator, thousends_separator):
    formatted_number = f'{value:,}'.replace(',', thousends_separator).replace('.', decimal_separator)
    if symbol_position == 'BEFORE':
        return f'{currency_symbol}{formatted_number}'
    elif symbol_position == 'BEFORE_WITH_SPACE':
        return f'{currency_symbol}{formatted_number}'
    elif symbol_position == 'AFTER':
        return f'{formatted_number}{currency_symbol}'
    elif symbol_position == 'AFTER_WITH_SPACE':
        return f'{formatted_number} {currency_symbol}'
    else:
        raise ValueError('Invalid symbol position')

The function requires a few parameters. We need to pass them every time we use the function. However, in most programs, we use the same format every time. Passing the parameters in multiple creates a risk of intruducing bugs. We would like to avoid it.

The simplest way is to create a wrapper function to pass constant values of the parameters we want to keep unchanged. We can define a lambda function to format the given number as a monetary value in Polish Złoty with the format used in Poland:

to_pln = lambda x: to_currency(x, currency_symbol='zł', symbol_position='AFTER_WITH_SPACE', decimal_separator=',', thousends_separator=' ')

Technically, we have created a decorator. What if we want to format more currencies? Will we define a separate lambda function for all of them? We don’t need to.

We can create a general function accepting the parameters we want to predefine and returning the decorated function - the decorator:

def with_currency(func, currency_symbol, symbol_position, decimal_separator, thousends_separator):
    def decorated(value):
        return func(value, currency_symbol, symbol_position, decimal_separator, thousends_separator)
    return decorated

to_pln = with_currency(to_currency, currency_symbol='zł', symbol_position='AFTER_WITH_SPACE', decimal_separator=',', thousends_separator=' ')

What happens? We define the with_currency function that accepts the currency parameters. It returns another function. In the nested function, we use the parameters passed to the outer function. Such a construction is called closure in functional programming. Closures allow us to refer to the outer function’s parameters inside the nested function. In the end, we return the decorated function.

Our function accepts only one parameter, so we can use it like the lambda function from the previous example.

The notation we have seen so far allows us to define multiple currency formatters for different currencies. However, we can simplify the notation when we care about only one currency.

In this case, we have to move the function definitions. We must define the with_currency decorator before the to_currency function. After that, we add the decorator to our function like this:

def with_currency(currency_symbol, symbol_position, decimal_separator, thousends_separator):
    def decorated(func):
        def decorated_with_parameters(value):
            return func(value, currency_symbol, symbol_position, decimal_separator, thousends_separator)
        return decorated_with_parameters
    return decorated

@with_currency(currency_symbol='zł', symbol_position='AFTER_WITH_SPACE', decimal_separator=',', thousends_separator=' ')
def to_currency(value, currency_symbol, symbol_position, decimal_separator, thousends_separator):
    formatted_number = f'{value:,}'.replace(',', thousends_separator).replace('.', decimal_separator)
    if symbol_position == 'BEFORE':
        return f'{currency_symbol}{formatted_number}'
    elif symbol_position == 'BEFORE_WITH_SPACE':
        return f'{currency_symbol}{formatted_number}'
    elif symbol_position == 'AFTER':
        return f'{formatted_number}{currency_symbol}'
    elif symbol_position == 'AFTER_WITH_SPACE':
        return f'{formatted_number} {currency_symbol}'
    else:
        raise ValueError('Invalid symbol position')

Now, our with_currency decorator returns the decorated function. Inside the decorated function, we produce another function. The additional function receives the value parameters and uses closures to refer to both the decorated function and the currency parameters.

We don’t need to wrap our to_currency function anymore before using it. Python already does it, so we can call the currency formatter right away:

print(to_currency(123.45))

Real-life usage of Python decorator

When do we use decorators in Python?

Probably, the most commonly used decorator is the retry library. It allows us to retry a function call in case of a failure. Usually, if a Python function uses an external service, we want to retry the API call a few times in case of an error. Nobody wants to write the code on their own, so we use the retry module like this:

from retry import retry

@retry(tries=3, delay=2)
def this_function_calls_an_api():
    ...

If the this_function_calls_an_api function throws an exception, the retry decorator will wait 2 seconds and repeat the function call. It will try 3 times. After the third failure, it propagates the failure to the calling code.

Older post

What is shuffling in Apache Spark, and when does it happen?

When does an Apache Spark cluster perform the shuffle operation?

Newer post

Selecting rows in Pandas

How to use loc, iloc, slice, and row filtering in Pandas