Location via proxy:   [ UP ]  
[Report a bug]   [Manage cookies]                

Decorators aren't always functions PREMIUM

Series: Decorators
Trey Hunner smiling in a t-shirt against a yellow wall
Trey Hunner
6 min. read 4 min. video Python 3.9—3.13

Decorators aren't always implemented using functions.

Decorators are usually implemented with functions

It's helpful to think of a decorator as a function that accepts a function and returns a function to replace our original function with:

from functools import wraps

def log_me(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        print("Calling with", args, kwargs)
        return_value = func(*args, **kwargs)
        print("Returning", return_value)
        return return_value
    return wrapper

But decorators aren't necessarily implemented using functions. A decorator can be any callable that accepts a function and returns a replacement object of some sort.

Let's try to use a class as a decorator

But what would happen if we implemented a decorator using a class?

class log_me:
    def __init__(self, func):
        self.func = func

When we call a function, we get its return value. When we call a decorator function we get a replacement function.

But our decorator isn't a function here, it's a class. So what do you think will happen when we use this class as a decorator? What will greet be?

>>> @log_me
... def greet(name):
...     """Greet a user."""
...     print("Hello", name)

When we call a class we get an instance of that class.

So our greet "function" is now an instance of the log_me class:

>>> greet
<__main__.log_me object at 0x7f35d8feb2b0>

If our class is a decorator it will be called with a function and it needs to return a replacement function. That means our class instances need to act like replacement functions. So we somehow need to make our class instances act like functions.

Making class instances callable

Normally, instances of classes aren't callable.

But they can be if we implement a __call__ method:

class log_me:
    def __init__(self, func):
        self.func = func

    def __call__(self, *args, **kwargs):
        print("Calling with", args, kwargs)
        return_value = self.func(*args, **kwargs)
        print("Returning", return_value)
        return return_value

We've now implemented a decorator using a class.

We can use this log_me class as a decorator:

>>> @log_me
... def greet(name):
...     """Greet a user."""
...     print("Hello", name)

And when we call our decorated function, it prints out the arguments that we've called the function with and it will, it does whatever the original function did, and then it prints out the return value:

>>> greet("Trey")
Calling with ('Trey',) {}
Hello Trey
Returning None

But this greet function isn't actually a function; it's an instance of this log_me class:

>>> greet
<__main__.log_me object at 0x7f0d346db250>

Decorator functions compared to decorator classes

There is a very big downside to implementing a decorator with a class.

When we ask for help on a decorated function, we normally expect to see:

  1. The name of the original function
  2. The type signature of the original function

And in the case of our decorator function because we're using functools.wraps that is what we see:

>>> help(greet)

Help on function greet in module __main__:

greet(name)
    Greet a user.

Note: if you're unfamiliar with functools.wraps, see making a well-behaved decorator function.

When you implement a decorator using a class, it can't use functools.wrap to borrow the name and type signature because we can't change that information on class instances.

So if we decorate our greet function with our log_me class:

>>> log_me
<class '__main__.log_me'>
>>> @log_me
... def greet(name):
...     """Greet a user."""
...     print("Hello", name)

And then we ask for help on this new greet "function", it tells us information about log_me objects (which isn't very helpful):

>>> help(greet)

Help on log_me in module __main__ object:

class log_me(builtins.object)
 |  log_me(func)
 |
 |  Methods defined here:
 |
 |  __call__(self, *args, **kwargs)
 |      Call self as a function.
 |
 |  __init__(self, func)
 |      Initialize self.  See help(type(self)) for accurate signature.
 |
 |  ----------------------------------------------------------------------
 |  Data descriptors defined here:
 |
 |  __dict__
 |      dictionary for instance variables (if defined)
 |
 |  __weakref__
 |      list of weak references to the object (if defined)

When is it useful to implement a decorator using a class?

It's not usually recommended implementing a decorator using a class, unless you were making a decorator that accepts arguments.

You can think of a decorator that accepts arguments as a function that accepts arguments that returns a decorator.

Here we have a restrict_return decorator which accepts one argument (the expected return value):

from functools import wraps

def restrict_return(expected):
    def decorator(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            value = func(*args, **kwargs)
            if value is not expected:
                raise ValueError(f"Returned {value!r}, not {expected!r}")
            return value
        return wrapper
    return decorator

We can use this restrict_return decorator by giving it our expected return value and then using the decorator we get back to decorate our function:

>>> @restrict_return(True)
... def is_odd(n):
...     """Return True if n is odd."""
...     return n % 2 == 1

When we call this restrict_return-decorated function, if it returns the expected value of True, then we'll get that return value:

>>> is_odd(3)
True

But whenever this function returns something that isn't True, our function raises a ValueError instead:

>>> is_odd(4)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/home/trey/restrict_return_func.py", line 9, in wrapper
    raise ValueError(f"Returned {value!r}, not {expected!r}")
ValueError: Returned False, not True

So if we were to use a class to implement this decorator that accepts arguments, we would need to make a class that gives us back a decorator when we call it. That means our class instances need to be decorators.

Here we have a restrict_return class that accepts a value:

from functools import wraps


class restrict_return:
    def __init__(self, value):
        self.value = value

    def __call__(self, func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            value = func(*args, **kwargs)
            if value is not self.value:
                raise ValueError(f"Returned {value!r}, not {self.value!r}")
            return value
        return wrapper

And instances of this class are callable (due to __call__): we can call them with a function (func) and we'll give back a replacement function (wrapper).

So class instances act as a decorator here, not the class itself.

Since we're returning a function (wrapper), we can use functools.wraps to borrow the original information from our original function.

    def __call__(self, func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            ...
        return wrapper

If we decorate our is_odd function just like before:

>>> @restrict_return(True)
... def is_odd(n):
...     """Return True if n is odd."""
...     return n % 2 == 1

The is_odd function will have the correct function name, even though we're using a class to implement the restrict_return decorator:

>>> is_odd
<function is_odd at 0x7f9072f77040>

And if we ask for help on is_odd, we'll see that it knows its type signature as well:

>>> help(is_odd)

Help on function is_odd in module __main__:

is_odd(n)
    Return True if n is odd.

Summary

Decorators are not necessarily implemented using functions. You could also make a decorator using a class or an instance of a class.

A decorator is really a callable that accepts a function or a class, and returns some object to replace that function or class with.

This is a free preview of a premium screencast. You have 2 previews remaining.