Python Decorators - A Quick Guide on Making Your Code Festive

Decorators are a difficult concept to wrap your head around (ha, pun intended!). However, once you understand how they work, it becomes a tool added to your arsenal for code design.

I personally love decorators because it’s another way to abstract and refactor your code. Plus, it adds a bit of a ting to how your code looks - makes it looks a bit nicer :^)

What are decorators?

Decorators (or wrappers) allow you to extend behavior of a function or method without having to modify the “extention” portion. Think of it as “I want this function to run this code either before and/or after I call this function”.

Here’s an example of a decorator to time a program:

import time

# our wrapper to time a function
def time_this(func):
    def wrap():
        st = time.time() # start time
        func()
        print(f"Total time: {time.time() - st}")
    return wrap

@time_this
def print_hello():
    print("Hello, world!")
    time.sleep(1)

Decorators, at the Core

If we were to strip this down to expose the different “sections” of a wrapper via comments, it would look something like this:

def decorator(func):
    def wrap():
        # Whatever we want to invoke *before* the function goes here

        func() # this is the function you're wrapping (see example above)

        # Whatever we want to invoke *after* the function goes here.
    
    return wrap

Notice that there are 3 parts: the stuff we want to do before the function we are decorating, the function call, and the stuff we want to after the function is called.

Breakdown of the Code

Let’s step through what’s happening with the timer decorator. To ease reading, I’ve copied and pasted it:

import time

# our wrapper to time a function
def time_this(func):
    def wrap():
        st = time.time() # start time
        func()
        print(f"Total time: {time.time() - st}")
    return wrap

@time_this
def print_hello():
    print("Hello, world!")
    time.sleep(1)
  • @time_this is the decorator, which passes the reference of print_hello into time_this
  • As such, the variable func is set to the reference of print_hello.

For those who aren’t aware, you can pass functions to other functions. A simple example of this is list(set([1, 2, 3])), which just takes [1, 2, 3], converts it to a set, then converts it back to a list.

When you do this, you pass a reference to the function, which is where it is in memory. This is powerful in the sense that it allows you to invoke the function later down the line rather than right then and there.

  • The stuff insde of the wrap function get invoked, starting with setting the variable st.
  • The second line (func()) is invoking our variable, func. If you recall from the note, func is a reference to our function, print_hello, and by adding the parenthesis (i.e. func()), we run whatever that function is.
  • The last line prints the total time it took to run the code end-to-end.

Why use decorators?

We could just write the same logic above this way:

import time

def print_hello():
    print("Hello, world!")
    time.sleep(1)

st = time.time()
print_hello()
print(f"Total time: {time.time() - st}")

And while this does work for this one instance, we can’t really re-use the code used that we used to time print_hello. This is bad when you’re trying to abstract and refactor code to scale. Sure, we could also write it like this:

import time

def start():
    return time.time()

def end(start_time):
    print(f"Total time: {time.time() - st}")

def print_hello():
    print("Hello, world!")
    time.sleep(1)

st = start() 
print_hello()
end(st)

And while we can re-use the code, the issue we’re running into here (or could run into) is we may forget to start timing the code and end the timer when we try and re-use this code across the codebase.

This is where decorators show their colors - they allow us to abstract that functionality of starting and ending the timer without ever having to touch it. Neat-o!

Uses of Wrappers

There are plenty of uses of wrappers:

  • Performance metrics for a program
  • Opening and closing database connections
  • Checking data types and data structures of inputs to the function.
  • Compatability between newer and older versions of the API

… and so much more.

Summary

In this post, we’ve seen:

  • What a decorator is,
  • The different parts of a decorator,
  • How we utilize decorators

Decorators allow us to:

  • Define behavior before the function is invoked
  • Run our function
  • Define behavior after the function is invoked

All while never having to modify the before and after behavior.

If this post has helped you at all, I’d love to hear about it! Tweet at me or shoot me a DM: @WxBDM

Written on August 21, 2022