Python Decorators for Beginners
Decorators are a reasonably advanced part of the Python programming language. Like most things once you grasp how they work and use them a few times, they become very straightforward, but as a beginner, they can be a little daunting and hard to wrap your mind around.
When it finally clicked for me how Python's decorators worked, back in 2015, it was the first time I really stopped thinking of myself as a beginner. I had tried - and repeatedly failed - to understand them for some time. There are a lot of resources out there explaining this cool feature, each taking its own view on how they work. I will try to explain decorators here in the way I wish they had been explained to me.
You can only really understand something like this, if you understand the problem that it solves. For example, I can just state the definition of a decorator outright:
A decorator is a function which takes another function as an argument and returns a modified version of it, enhancing its functionality in some way.
If you already understand what a decorator is, this definition is perfectly clear, but if you don't - maybe not so much. And crucially, the definition alone doesn't tell you when you would use decorators, or how Python would be poorer without them.
So, to business. We are going to start with a hypothetical scenario, and watch the problems that can unfold if you don't use decorators.
You have just been hired as a developer at 'Pro String Inc. Ltd' - a company which provides advanced string manipulation code to its clients. On your first day, your boss comes up to you and asks you to write a function which takes a string and makes it into a palindrome: a string that reads the same forwards as backwards. You write something like this:
def make_palindrome(string):
"""Makes a palindromic mirror of a string."""
return string + string[::-1]
So far so good. An hour later the boss returns and asks for more functions: one to add a credits string to the end of any string, one to convert a string in snake_case
to one in camelCase
, and one to insert commas into a string. All vitally important functions. You get to work.
Here's what you produce:
def add_credits(string):
"""Adds the company's credits to the end of any string."""
return f"{string} (string created by Pro String Inc.)"
def snake_to_camel(string):
"""Converts a string in snake_case to camelCase."""
words = string.split("_")
if len(words) > 1:
words = [words[0]] + [word.title() for word in words[1:]]
return "".join(words)
def insert_commas(string, spacing=3):
"""Inserts commas between every n characters."""
sections = [string[i: i + spacing] for i in range(0, len(string), spacing)]
return ",".join(sections)
Beautiful. You have now written four functions which all take a string as an argument and output another string as an output - and it's only your first day.
After lunch though, a problem arises. The same boss looks at your code, and reminds you that all Pro String Inc. Ltd functions have to be able to take integers as inputs, and that they should be converted to strings. He recommends adding a line to the start of each function which checks if the input is an integer, and converts it if so.
This demoralises you - if your boss is to be believed, you have to go through each function and add something like this to the start...
if isinstance(string, int):
string = str(string)
Or do you? This is (sort of) ok when we have four functions we have to modify, but what if we had ten? Or a thousand? And work aside, having all the functions start with the same two lines violates the sacred 'Don't Repeat Yourself' law guideline. Is there not a way to just modify all these functions without adding extra lines to them?
Well yes, obviously, or I wouldn't be writing any of this. To see how, let's take a step back and look at Python functions.
Despite their special syntax, a Python function is just an object, like a string or a list. You can check their attributes, assign them to new variables, and - crucially - pass them to another function as an argument. For example, you might make a function which takes another function, and checks whether it has any keyword arguments...
def func_has_kwargs(func):
return len(func.__defaults__) > 0
Don't worry about __defaults__
if you haven't seen it before - the key thing here is that the function is taking another function as an argument, checking to see if it has any keyword arguments (i.e. if the length of its __default__
property is greater than 0), and returning True
if so, False
otherwise.
Functions can also create and return new functions. Take a look at the following example closely:
def make_divider_function(divisor):
def divide_by_x(n):
return n / divisor
return divide_by_x
divide_by_3 = make_divider_function(3)
divide_by_7 = make_divider_function(7)
print(divide_by_3(42)) # prints 14
print(divide_by_7(42)) # prints 6
If you haven't seen anything like this before, it might take a few attempts at following it. Let's take it line by line.
We start by creating a function called make_divider_function
. This function takes a number as its argument and creates a new function every time it is called. The functions it creates all divide a given number by some constant, and that constant is determined by whatever you pass to the original function. You can think of the outer function as being a 'factory' - it makes new functions capable of dividing numbers.
We put this factory to work by using it to create two new functions. We make a function that divides any number by 3, and then a function that divides any number by 7. And just to demonstrate that these new functions are like any other function (despite the strange way we created them), we then use these new functions to divide 42 by 3, and then by 7.
It's really worth going though the example above until you are sure you understand it. Type it out yourself and see it in action, modify it, play with it - get it comfortable in your head.
And then, we will return to our original problem. We have our three string manipulation functions we so carefully crafted, and we need to modify all of them so that they will accept integers too. What we need, it turns out, is a new function - one that will take our existing functions as inputs, and create a modified version of them that checks for integers. We need a decorator function.
def accept_integers(func): # Define decorator function.
def new_func(s): # Which creates a new function.
if isinstance(s, int): # This new function converts any...
s = str(s) # ...integer inputs to string.
return func(s) # New function calls the original.
return new_func # New function is returned.
Let's look carefully at what is happening here. accept_integers
is our decorator function - it takes a function as input and returns another function as output. In its body it creates a new function, which should do everything the input function does, but with an extra step at the start. If you look in the body of this function, you can see that it checks a string that it is given to see if it is an integer, converts it if it is, and then passes this string to the original function.
There is one step missing here - we need to actually use this decorator...
def accept_integers(func):
def new_func(s):
if isinstance(s, int):
s = str(s)
return func(s)
return new_func
def make_palindrome(string):
"""Makes a palindromic mirror of a string."""
return string + string[::-1]
def add_credits(string):
"""Adds the company's credits to the end of any string."""
return f"{string} (string created by Pro String Inc.)"
def snake_to_camel(string):
"""Converts a string in snake_case to camelCase."""
words = string.split("_")
if len(words) > 1:
words = [words[0]] + [word.title() for word in words[1:]]
return "".join(words)
def insert_commas(string, spacing=3):
"""Inserts commas between every n characters."""
sections = [string[i: i + spacing] for i in range(0, len(string), spacing)]
return ",".join(sections)
make_palindrome = accept_integers(make_palindrome)
add_credits = accept_integers(add_credits)
snake_to_camel = accept_integers(snake_to_camel)
insert_commas = accept_integers(insert_commas)
The three lines at the bottom here are the actual application of the decorators. Let's walk through what happens to the make_palindrome
function...
- A function called
make_palindrome
is created in the usual way, which takes a string, and returns a palindrome version of it. It has no means of checking if the string it is given is an integer. - This
make_palindrome
function is passed to theaccept_integers
decorator function. - The
accept_integers
function creates a new function which also takes an argument. It then checks if the argument is an integer, converts it to string if it is, and passes it to the originalmake_palindrome
if it is. Note that neither the type checking nor themake_palindrome
function executes at this point. It is just defining how the next function should work. - The new function - an enhanced version of
make_palindrome
- is returned. Because we want the new function to have the same name as the old one, we assign it to a variable of the same name, so thatmake_palindrome
is still the function's name - though we could have called it anything. Note that you have to assign it to something! The decorator is making a new function, it does not actually modify the old one. It is the assigning of the new function to the same old name which actually has the effect.
Finally, it is worth pointing out that while the syntax above is perfectly valid, and arguably more readable, Python provides a shortcut in the form of the @
symbol. You can add @accept_integers
to the front of any function to decorate it.
# This does exactly the same thing as the previous block of code.
# It just looks nicer.
def accept_integers(func):
def new_func(s):
if isinstance(s, int):
s = str(s)
return func(s)
return new_func
@accept_integers
def make_palindrome(string):
"""Makes a palindromic mirror of a string."""
return string + string[::-1]
@accept_integers
def add_credits(string):
"""Adds the company's credits to the end of any string."""
return f"{string} (string created by Pro String Inc.)"
@accept_integers
def snake_to_camel(string):
"""Converts a string in snake_case to camelCase."""
words = string.split("_")
if len(words) > 1:
words = [words[0]] + [word.title() for word in words[1:]]
return "".join(words)
@accept_integers
def insert_commas(string, spacing=3):
"""Inserts commas between every n characters."""
sections = [string[i: i + spacing] for i in range(0, len(string), spacing)]
return ",".join(sections)
This is how you usually see decorators 'in the wild' - but just remember that it's just another way of passing a function to another function. Under the hood, when Python sees that @ symbol, it does the calling of the decorator for you.
Many Python libraries offer decorators as a way to quickly enhance functions you write without having to type a lot of repetitive code. A good example is in the Django web framework library. Briefly, part of creating a website with Django involves writing views - functions (usually) which take a web request and return a web response, such as a web page. For example, there might be a view which returns the home page response, and a view which returns a user profile page response. A common modification to make to these functions is to add some logic at the beginning to check that the request is from a logged in user, and if not, to redirect them. Django provides a decorator, login_required
, which does just that.
It is tempting, when looking through the less 'beginner-friendly' parts of Python (or any programming language) to take a step back, tell yourself you can get by just fine without this, and carry on as you were. And at first that's fine. But ultimately to do this is to accept that you've gotten as good as you're ever going to get. My advice for decorators, and for all new programming features, is to first learn to recognise the situations in which you would use that feature - the problems it solves, and the pain that arises from not using it - and then worry about learning how it works.
And as always, the only way to really understand, is to code one yourself. So go and do that.