profile

Rodrigo Girão Serrão

🐍📝 the first decorator I ever wrote

published3 months ago
5 min read

Hey there, how are you doing?

In this issue of the Mathspp Insider we will talk about the first decorator I ever wrote in Python.


Decorators in Python

Python decorators are often seen as an “advanced” Python topic, but I don't think they are that complicated.
To help you see that, I want to tell you about the first decorator I ever wrote in Python, a decorator that helped me debug my code.

But first, what is a decorator?

A Python decorator is a pattern that lets you modify your functions without having to actually change the way you define them.
In a way, a decorator lets you patch a function as if you had forgotten to add some functionality to it, without you having to change the code that defines the function.
This is useful, for example, when you want to add some extra functionality to a function that would be nice to have, but that really isn't aligned with the actual work the function does.

In those cases, modifying your function definition to add that functionality would create a mess inside the function body, obscuring the purpose of the function.
The functionality added by decorators is also very easy to add/remove, because adding a decorator to a function is just a single line of code:

@decorator  # This is applying a decorator to f
def f(a, b):
    ...

Decorators can work on objects other than functions, but the most common use case is for functions, so we'll focus on that.

So, now I want to show you the first decorator I ever wrote, a decorator that helped me debug my code!
This decorator will modify a function to print its arguments whenever the function is called and to print the return value of that function.
This way, we will always know when a function is called, what arguments it had, and what value it returned.

How do we do this?

A decorator is used by adding the line @decorator on top of a function definition, assuming “decorator” is actually the name of the decorator.
In this case, we will call our decorator “print_debugger”, because it helps us debug things through printing.
Then, to define a decorator you can start by defining a function with the correct name.
A decorator is just a function that receives a function argument and returns a function argument:

def print_debugger(f):
    return f

Function arguments and return values

In Python, everything is an object.
Functions are also objects.
As such, functions can be passed in as arguments to functions and can be returned from functions.
A good example of a function that accepts other functions is the built-in help.
Try opening the REPL and using it, if you don't know it:

>>> help(print)
Help on built-in function print in module builtins:

print(...)
    print(value, ..., sep=' ', end='\n', file=sys.stdout, flush=False)

    Prints the values to a stream, or to sys.stdout by default.
    # more stuff here...

Thus, the decorator above does exactly nothing: it accepts one function as argument and returns it unchanged!
So, in a way, this is _already_ a decorator!

How decorators are applied

Let's just add a print to the decorator body, so we know when it runs, and we can add it to our functions:

>>> def print_debugger(f):
...     print(f"Inside print debugger with {f.__name__}.")
...     return f
...
>>> @print_debugger
... def add(a, b):
...     return a + b
...
Inside print debugger with add.
>>> add(3, 4)
7

As we can see, the decorator runs when the function is first defined.
Then, when we run the function normally, the decorator doesn't run.
That is because the line @print_debugger is actually the same as this:

@print_debugger
def f():
    ...

# Same as:

def f():
    ...
f = print_debugger(f)

Using the at sign @ to apply a decorator is just syntactic sugar for calling the decorator on the function and assigning the result back to the original function name.
If you don't believe me, try it yourself.

Checkpoint

Believe it or not, we are nearly there!
This is what we covered so far:

  • decorators tweak functions without modifying their underlying implementation;
  • decorators are just functions that receive function arguments and return (the modified) functions;
  • decorators run once immediately after the function definition; and
  • decorators can be applied with an at sign @ on top of the function definition, but that is just syntactic sugar for calling the decorator and doing an assignment.

I'm trying to make this sound easy and simple, and it isn't too complex, but be gentle on yourself and take your time to process this.
Type the code I'm showing and play around with it, that should help.
Another thing that might help is the remote decorators workshop I will give on the 3rd of December, where one of the things I will do is go through these concepts with you while answering all the questions you might have.

Nested functions?!

Anyway, we know that the decorator print_debugger needs to return the modified function, and the modified function should do this:

  • print the arguments to the function when the function is called; and
  • print the return value of that function.

To do this, we can do yet another cool thing: define a new function inside the decorator print_debugger!
This new function will do three things:

  • print something when it is called;
  • call the original function f; and
  • print something when f has finished executing.

Here is the first version of this:

>>> def print_debugger(f):
...     def new_function():
...         print(f"I'm going to call {f.__name__}.")
...         f()
...         print("Done.")
...     return new_function
...
>>> @print_debugger
... def say_hi():
...     print("Hi!")
...
>>> say_hi()
I'm going to call say_hi.
Hi!
Done.

Notice how we define a new function called new_function inside print_debugger and then return that new function from the decorator.
This is good progress, but our decorator doesn't work with all functions:

>>> @print_debugger
... def add(a, b):
...     return a + b
...
>>> add(3, 4)
Traceback (most recent call last):
  File "", line 1, in 
TypeError: new_function() takes 0 positional arguments but 2 were given

What is the issue, here?

Handling arbitrary arguments

The issue is that the original function add expects two arguments, which is why I called it with add(3, 4).
However, the decorator print_debugger replaced our function with a modified function, and the modified function (called new_function) takes 0 arguments!

To fix this, we need to realise we do not know, beforehand, how many arguments the function f expects.
To cope with this, new_function needs to be prepared to receive any number of arguments, which we can do with *args and **kwargs.
Now that we expect arguments, we can print them!

>>> def print_debugger(f):
...     # The new function can take any number of arguments...
...     def new_function(*args, **kwargs):
...         # ... then prints those...
...         print(f"Calling {f.__name__} with {args = } and {kwargs = }.")
...         f(*args, **kwargs)  # ... and then gives them back to f.
...         print("Done.")
...     return new_function
...
>>> @print_debugger
... def say_hi():
...     print("Hi")
...
>>> say_hi()
Calling say_hi with args = () and kwargs = {}.
Hi
Done.
>>> @print_debugger
... def add(a, b):
...     return a + b
...
>>> add(3, 4)
Calling add with args = (3, 4) and kwargs = {}.
Done.

Handling return values

Our decorator is getting there, but it has one other major flaw:

>>> print(add(3, 4))
Calling add with args = (3, 4) and kwargs = {}.
Done.
None  # I am pretty sure 3 + 4 == 7!

For some reason, the return value isn't “getting out”...
Can you see why?
Give yourself a second to think about this...

The issue is that new_function calls f but then does nothing with the return result of f!
Instead, new_function should grab that return result, print it, and then return it as well:

>>> def print_debugger(f):
...     def new_function(*args, **kwargs):
...         print(f"Calling {f.__name__} with {args = } and {kwargs = }.")
...         ret = f(*args, **kwargs)             # Save the result...
...         print(f"Done with result = {ret}.")  # ... print it...
...         return ret                           # ... and return it!
...     return new_function
...
>>> @print_debugger
... def add(a, b):
...     return a + b
...
>>> seven = add(3, 4)
Calling add with args = (3, 4) and kwargs = {}.
Done with result = 7.
>>> seven
7

Congratulations!
You wrote your first decorator!
Here it is:

def print_debugger(f):
    def new_function(*args, **kwargs):
        print(f"Calling {f.__name__} with {args = } and {kwargs = }.")
        ret = f(*args, **kwargs)
        print(f"Done with result = {ret}.")
        return ret
    return new_function

This may have been a bit fast-paced, so make sure you reply to this email with all the questions you might have and I will do my best to clear any doubts you might have!

Understanding things by reading is also much harder than listening, asking questions, and solving practical exercises, which is exactly what you'll do in the remote decorators workshop, so make sure you sign up for that :)


Thanks for reading, and I'll see you next time!

Rodrigo.