What is a decorator?

Share
Copied to clipboard.
Series: Decorators
Trey Hunner smiling in a t-shirt against a yellow wall
Trey Hunner
5 min. read Watch as video Python 3.8—3.12

A decorator is a function that accepts a function and returns a function.

Decorators accept a function and return a function

We've defined an is_prime function here:

>>> def is_prime(number):
...     for n in range(2, number//2):
...             if number % n == 0:
...                     return False
...     return True

This function accept a number and returns either True or False depending on whether that number is prime or not.

When we call is_prime with a really big number, it takes a while (a half second or so) to check if number is prime:

>>> is_prime(73729259)
True

If we call it again with the same number (73729259) it still takes a while because it does the same exact work again:

>>> is_prime(73729259)
True

We'd like to we speed up is_prime so that whenever it won't do the same work twice if it's called with the same number again.

We can do this with the lru_cache decorator (from Python's functools module):

>>> from functools import lru_cache

The lru_cache decorator accepts a function and returns a new function that wraps around the original function:

>>> is_prime = lru_cache(is_prime)

We're now pointed our is_prime variable to whatever lru_cache gave back to us (yes this is a little bit weird looking).

We can call is_prime just as before, but we're not calling our original is_prime function, we're calling the function that lru_cache returned to us:

>>> is_prime(73729259)
True

The first time we call is_prime with any number, it'll be slow. But the second time is_prime will be fast (you can try it out yourself to see):

>>> is_prime(73729259)
True

The lru_cache decorator returned a new function object to us which we're pointing our is_prime variable to. When we call this new function, the new function calls our original is_prime function (which we had passed to lru_cache) and it caches the return value for each argument that it sees.

Every time this new function is called, it stores the inputs (the given function arguments) and the output (return value) that corresponds to those inputs.

Note: lru_cache accepts a function as of Python 3.8 (before Python 3.8 you needed to call lru_cache(128)(my_function) to get the same behavior).

The decorator syntax: using the @ symbol to apply decorators

This syntax for using a decorator is a little unusual:

>>> is_prime = lru_cache(is_prime)

We don't usually use decorators like this in Python. Instead, we tend to use a special decorator syntax, using an @ symbol.

If you want to make a function and then apply a decorator to your function you can use the @ symbol:

>>> from functools import  lru_cache
>>> @lru_cache
... def is_prime(number):
...     for n in range(2, number//2):
...             if number % n == 0:
...                     return False
...     return True
...

We're defining our is_prime function here anew and we're decorating it with the lru_cache decorator.

We're indicating to Python that we'd like to:

  1. Define the is_prime function
  2. Take that function object and pass it to the lru_cache decorator
  3. Take the return value we get from calling lru_cache and point our is_prime variable to that new function

So the is_prime variable now points to the function that came back from calling lru_cache (with our original is_prime function):

Just as before, if we call is_prime, the first time it's going to be slow:

>>> is_prime(73729259)
True

But if we call it with the same number again, it'll be fast the second time:

>>> is_prime(73729259)
True

And the reason is, is_prime points to the function that came back from calling lru_cache:

>>> is_prime
<functools._lru_cache_wrapper object at 0x0000017FD7EFE7C0>

The is_prime variable doesn't point to our original is_prime function. Instead it points to another function (a callable object technically) which wraps around our original is_prime function.

Decorator functions, decorator classes, function decorators, and class decorators

I previously defined a decorator as a function that accepts a function and returns a function. That was a bit of a lie. All three of the "function" nouns in that sentence can instead be "class".

A decorator can be a class which accepts a function and returns an instance of that class (a "decorator class" that can be used as a "function decorator").

A decorator can also be a function which accepts a class (a "decorator function" that can be used as a "class decorator").

Decorators could be implemented as functions or classes

Let's look at a decorator that is implemented using a class rather than a function.

We have a Square class that uses the property decorator for making an auto-updating attribute:

class Square:
    def __init__(self, width):
        self.width =  width
    @property
    def area(self):
        return self.width**2

That property decorator accepted a function but it didn't return a function, it returned a property object:

>>> Square.area
<property object at 0x000001CED0DC1360>

The property decorator that's built-in to Python is actually a class:

>>> property
<class 'property'>

The built-in property decorator is a class which accepts a function (the area method in our case) and returns an instance of the property class.

So this function decorator (a decorator used for decorating functions) isn't a decorator function so much as a decorator class (meaning it's implemented using a class under the hood). But the thing we care about is that it's a callable that accepts a function, so we can use it the same way as any other decorator.

Aside: while functools.lru_cache isn't a function but it actually returns a callable class instance (a functools._lru_cache_wrapper object) rather than a function.

Decorating a class with a class decorator

We can also decorate classes.

We can make this same Square class as before by using the dataclass decorator:

@dataclass
class Square:
    width: float

    @property
    def area(self):
        return self.width**2

Square is now a class that accepts a width argument (even though we didn't make have a __init__ method):

>>> s = Square(2)

The dataclass class decorator made our initializer for us. It also made a __repr__ method, so our Square objects have a nice string representation:

>>> s
Square(width=2)

This all happened because we decorated our our Square class with the dataclass decorator.

Summary

A decorator is a callable (usually a function though sometimes a class) that accepts either a function or a class and returns a new function or class that wraps around the original one.

You can decorate a function or decorate a class while defining that function or class by using an @ symbol. The decorator syntax uses an @ symbol, the name of the decorator, and then the body of your function or class starting on the next line.

A Python Tip Every Week

Need to fill-in gaps in your Python skills? I send weekly emails designed to do just that.