DRY Your Python Code With Decorators

Decorators are one of the nicest features of Python, yet for the beginner Python programmer, they can seem like magic. The purpose of this article is to understand, in depth, the mechanism behind Python decorators.

DRY Your Python Code With Decorators

Here’s what you’ll learn:

  • what are Python decorators and what they are good for
  • how to define our own decorators
  • examples of real-world decorators and how they work
  • how to write better code using decorators

Introduction

In case you haven’t seen one yet (or perhaps you didn’t know you were dealing with one), decorators look like this:

@decorator
def function_to_decorate():
    pass

You usually encounter them above the definition of a function, and they’re prefixed by @. Decorators are especially good for keeping your code DRY (Don’t Repeat Yourself), and they do that while also improving the readability of your code.

Still fuzzy? Don’t be, since decorators are just Python functions. That’s right! You already know how to create one. In fact, the fundamental principle behind decorators is function composition. Let’s take an example:

def x_plus_2(x):
    return x + 2

print(x_plus_2(2))                      # 2 + 2 == 4


def x_squared(x):
    return x * x

print(x_squared(3))                     # 3 ^ 2 == 9


# Let's compose the two functions for x=2
print(x_squared(x_plus_2(2)))           # (2 + 2) ^ 2 == 16
print(x_squared(x_plus_2(3)))           # (3 + 2) ^ 2 == 25
print(x_squared(x_plus_2(4)))           # (4 + 2) ^ 2 == 36

What if we wanted to create another function, x_plus_2_squared? Trying to compose the functions would be futile:

x_squared(x_plus_2)  # TypeError: unsupported operand type(s) for *: 'function' and 'function'

You cannot compose functions in this way because both functions take numbers as arguments. However, this will work:

# Let's now create a proper function composition without actually applying the function
x_plus_2_squared = lambda x: x_squared(x_plus_2(x))

print(x_plus_2_squared(2)) # (2 + 2) ^ 2 == 16
print(x_plus_2_squared(3)) # (3 + 2) ^ 2 == 25
print(x_plus_2_squared(4)) # (4 + 2) ^ 2 == 36

Let’s redefine how x_squared works. If we want x_squared to be composable by default, it should:

  1. Accept a function as an argument
  2. Return another function

We’ll name the composable version of x_squared simply squared.

def squared(func):
    return lambda x: func(x) * func(x)

print(squared(x_plus_2)(2)) # (2 + 2) ^ 2 == 16
print(squared(x_plus_2)(3)) # (3 + 2) ^ 2 == 25
print(squared(x_plus_2)(4)) # (4 + 2) ^ 2 == 36

Now that we’ve defined the squared function in a way that makes it composable, we can use it with any other function. Here are some examples:

def x_plus_3(x):
    return x + 3

def x_times_2(x):
    return x * 2

print(squared(x_plus_3)(2))  # (2 + 3) ^ 2 == 25
print(squared(x_times_2)(2)) # (2 * 2) ^ 2 == 16

We can say that squared decorates the functions x_plus_2x_plus_3, and x_times_2. We are very close to achieving the standard decorator notation. Check this out:

x_plus_2 = squared(x_plus_2)  # We decorated x_plus_2 with squared
print(x_plus_2(2))            # x_plus_2 now returns the decorated squared result: (2 + 2) ^ 2 

That’s it! x_plus_2 is a proper Python decorated function. Here’s where the @ notation comes into place:

def x_plus_2(x):
    return x + 2

x_plus_2 = squared(x_plus_2)

# ^ This is completely equivalent with: 

@squared
def x_plus_2(x):
     return x + 2

In fact, the @ notation is a form of syntactic sugar. Let’s try that out:

@squared
def x_times_3(x):
    return 3 * x

print(x_times_3(2)) # (3 * 2) ^ 2 = 36.
# It might be a bit confusing, but by decorating it with squared, x_times_3 became in fact (3 * x) * (3 * x)

@squared
def x_minus_1(x):
    return x - 1

print(x_minus_1(3)) # (3 - 1) ^ 2 = 4

If squared is the first decorator you’ve ever written, give yourself a big pat on the back. You’ve grasped one of the most complex concepts in Python. Along the way, you learned another fundamental feature of functional programming languages: function composition.

Build Your Own Decorator

A decorator is a function that takes a function as an argument and returns another function. That being said, the generic template for defining a decorator is:

def decorator(function_to_decorate):
    # ...
    return decorated_function

In case you didn’t know, you can define functions inside functions. In most cases, the decorated_function will be defined inside decorator.

def decorator(function_to_decorate):
    def decorated_function(*args, **kwargs):
        # ... Since we decorate `function_to_decorate`, we should use it somewhere inside here
    return decorated_function

Let’s look at a more practical example:

import pytz
from datetime import datetime

def to_utc(function_to_decorate):
    def decorated_function():
        # Get the result of function_to_decorate and transform the result to UTC
        return function_to_decorate().astimezone(pytz.utc)
    return decorated_function

@to_utc
def package_pickup_time():
    """ This can come from a database or from an API """
    tz = pytz.timezone('US/Pacific')
    return tz.localize(datetime(2017, 8, 2, 12, 30, 0, 0))

@to_utc
def package_delivery_time():
    """ This can come from a database or from an API """
    tz = pytz.timezone('US/Eastern')
    return tz.localize(datetime(2017, 8, 2, 12, 30, 0, 0)) # What a coincidence, same time different timezone!

print("PICKUP: ", package_pickup_time())      # '2017-08-02 19:30:00+00:00'
print("DELIVERY: ", package_delivery_time())  # '2017-08-02 16:30:00+00:00'

Sweet! Now you can be sure that everything inside your app is standardised for the UTC timezone.

A Practical Example

Another really popular and classic use-case for decorators is caching the result of a function:

import time

def cached(function_to_decorate):
    _cache = {} # Where we keep the results
    def decorated_function(*args):
        start_time = time.time()
        print('_cache:', _cache)
        if args not in _cache:
            _cache[args] = function_to_decorate(*args) # Perform the computation and store it in cache
        print('Compute time: %ss' % round(time.time() - start_time, 2))
        return _cache[args]
    return decorated_function

@cached
def complex_computation(x, y):
    print('Processing ...')
    time.sleep(2)
    return x + y

print(complex_computation(1, 2)) # 3, Performing the expensive operation
print(complex_computation(1, 2)) # 3, SKIP performing the expensive operation
print(complex_computation(4, 5)) # 9, Performing the expensive operation
print(complex_computation(4, 5)) # 9, SKIP performing the expensive operation
print(complex_computation(1, 2)) # 3, SKIP performing the expensive operation

If you look at the code shallowly, you might object. The decorator isn’t reusable! If we decorate another function (say another_complex_computation) and call it with the same parameters then we’ll get the cached results from the complex_computation function. This won’t happen. The decorator is reusable, and here’s why:

@cached
def another_complex_computation(x, y):
    print('Processing ...')
    time.sleep(2)
    return x * y
    
print(another_complex_computation(1, 2)) # 2, Performing the expensive operation
print(another_complex_computation(1, 2)) # 2, SKIP performing the expensive operation
print(another_complex_computation(1, 2)) # 2, SKIP performing the expensive operation

The cached function is called once for every function it decorates, so a different _cache variable is instantiated every time and lives in that context. Let’s test this out:

print(complex_computation(10, 20))           # -> 30
print(another_complex_computation(10, 20))   # -> 200

Decorators in the Wild

The decorator we just coded, as you may have noticed, is very useful. It’s so useful that a more complex and robust version already exists in the standard functools module. It is named lru_cache. LRU is the abbreviation of Least Recently Used, a caching strategy.

from functools import lru_cache

@lru_cache()
def complex_computation(x, y):
    print('Processing ...')
    time.sleep(2)
    return x + y

print(complex_computation(1, 2)) # Processing ... 3
print(complex_computation(1, 2)) # 3
print(complex_computation(2, 3)) # Processing ... 5
print(complex_computation(1, 2)) # 3
print(complex_computation(2, 3)) # 5

One of my favourite uses of decorators is in the Flask web framework. It is so neat that this code snippet is the first thing you see on the Flask website. Here’s the snippet:

from flask import Flask

app = Flask(__name__)

@app.route("/")
def hello():
    return "Hello World!"

if __name__ == "__main__":
    app.run()

The app.route decorator assigns the function hello as the request handler for the route "/". The simplicity is amazing.

Another neat use of decorators is inside Django. Usually, web applications have two types of pages:

  1. pages you can view without being authenticated (front page, landing page, blog post, login, register)
  2. pages you need to be authenticated to view (profile settings, inbox, dashboard)

If you try to view a page of the latter type, you’ll usually get redirected to a login page. Here’s how to implement that in Django:

from django.http import HttpResponse
from django.contrib.auth.decorators import login_required

# Public Pages

def home(request):
    return HttpResponse("<b>Home</b>")

def landing(request):
    return HttpResponse("<b>Landing</b>")

# Authenticated Pages

@login_required(login_url='/login')
def dashboard(request):
    return HttpResponse("<b>Dashboard</b>")

@login_required(login_url='/login')
def profile_settings(request):
    return HttpResponse("<b>Profile Settings</b>")

Observe how neatly the private views are marked with login_required. While going through the code, it is very clear to the reader which pages require the user to log in and which pages do not.

Conclusions

I hope you had fun learning about decorators because they represent a very neat Python feature. Here are some things to remember:

  • Correctly using and designing decorators can make your code better, cleaner, and more beautiful.
  • Using decorators can help you DRY up your code—move identical code from inside functions to decorators.
  • As you use decorators more, you’ll find better, more complex ways to use them.

Remember to check out what we have available for sale and for study on СodeHolder Market, and don’t hesitate to ask any questions and provide your valuable feedback using the feed below.

Well, that’s that about decorators. Happy decorating!