Decorators are one of the nicest features of Python, yet for the beginner Python programmer, they can seem like magic. The purpose of this article is to understand, in depth, the mechanism behind Python decorators.
Here's what you'll learn:
- what are Python decorators and what they are good for
- how to define our own decorators
- examples of real-world decorators and how they work
- how to write better code using decorators
Introduction
In case you haven't seen one yet (or perhaps you didn't know you were dealing with one), decorators look like this:
@decorator def function_to_decorate(): pass
You usually encounter them above the definition of a function, and they're prefixed by @
. Decorators are especially good for keeping your code DRY (Don't Repeat Yourself), and they do that while also improving the readability of your code.
Still fuzzy? Don't be, since decorators are just Python functions. That's right! You already know how to create one. In fact, the fundamental principle behind decorators is function composition. Let's take an example:
def x_plus_2(x): return x + 2 print(x_plus_2(2)) # 2 + 2 == 4 def x_squared(x): return x * x print(x_squared(3)) # 3 ^ 2 == 9 # Let's compose the two functions for x=2 print(x_squared(x_plus_2(2))) # (2 + 2) ^ 2 == 16 print(x_squared(x_plus_2(3))) # (3 + 2) ^ 2 == 25 print(x_squared(x_plus_2(4))) # (4 + 2) ^ 2 == 36
What if we wanted to create another function, x_plus_2_squared
? Trying to compose the functions would be futile:
x_squared(x_plus_2) # TypeError: unsupported operand type(s) for *: 'function' and 'function'
You cannot compose functions in this way because both functions take numbers as arguments. However, this will work:
# Let's now create a proper function composition without actually applying the function x_plus_2_squared = lambda x: x_squared(x_plus_2(x)) print(x_plus_2_squared(2)) # (2 + 2) ^ 2 == 16 print(x_plus_2_squared(3)) # (3 + 2) ^ 2 == 25 print(x_plus_2_squared(4)) # (4 + 2) ^ 2 == 36
Let's redefine how x_squared
works. If we want x_squared
to be composable by default, it should:
- Accept a function as an argument
- Return another function
We'll name the composable version of x_squared
simply squared
.
def squared(func): return lambda x: func(x) * func(x) print(squared(x_plus_2)(2)) # (2 + 2) ^ 2 == 16 print(squared(x_plus_2)(3)) # (3 + 2) ^ 2 == 25 print(squared(x_plus_2)(4)) # (4 + 2) ^ 2 == 36
Now that we've defined the squared
function in a way that makes it composable, we can use it with any other function. Here are some examples:
def x_plus_3(x): return x + 3 def x_times_2(x): return x * 2 print(squared(x_plus_3)(2)) # (2 + 3) ^ 2 == 25 print(squared(x_times_2)(2)) # (2 * 2) ^ 2 == 16
We can say that squared
decorates the functions x_plus_2
, x_plus_3
, and x_times_2
. We are very close to achieving the standard decorator notation. Check this out:
x_plus_2 = squared(x_plus_2) # We decorated x_plus_2 with squared print(x_plus_2(2)) # x_plus_2 now returns the decorated squared result: (2 + 2) ^ 2
That's it! x_plus_2
is a proper Python decorated function. Here's where the @
notation comes into place:
def x_plus_2(x): return x + 2 x_plus_2 = squared(x_plus_2) # ^ This is completely equivalent with: @squared def x_plus_2(x): return x + 2
In fact, the @
notation is a form of syntactic sugar. Let's try that out:
@squared def x_times_3(x): return 3 * x print(x_times_3(2)) # (3 * 2) ^ 2 = 36. # It might be a bit confusing, but by decorating it with squared, x_times_3 became in fact (3 * x) * (3 * x) @squared def x_minus_1(x): return x - 1 print(x_minus_1(3)) # (3 - 1) ^ 2 = 4
If squared
is the first decorator you've ever written, give yourself a big pat on the back. You've grasped one of the most complex concepts in Python. Along the way, you learned another fundamental feature of functional programming languages: function composition.
Build Your Own Decorator
A decorator is a function that takes a function as an argument and returns another function. That being said, the generic template for defining a decorator is:
def decorator(function_to_decorate): # ... return decorated_function
In case you didn't know, you can define functions inside functions. In most cases, the decorated_function
will be defined inside decorator
.
def decorator(function_to_decorate): def decorated_function(*args, **kwargs): # ... Since we decorate `function_to_decorate`, we should use it somewhere inside here return decorated_function
Let's look at a more practical example:
import pytz from datetime import datetime def to_utc(function_to_decorate): def decorated_function(): # Get the result of function_to_decorate and transform the result to UTC return function_to_decorate().astimezone(pytz.utc) return decorated_function @to_utc def package_pickup_time(): """ This can come from a database or from an API """ tz = pytz.timezone('US/Pacific') return tz.localize(datetime(2017, 8, 2, 12, 30, 0, 0)) @to_utc def package_delivery_time(): """ This can come from a database or from an API """ tz = pytz.timezone('US/Eastern') return tz.localize(datetime(2017, 8, 2, 12, 30, 0, 0)) # What a coincidence, same time different timezone! print("PICKUP: ", package_pickup_time()) # '2017-08-02 19:30:00+00:00' print("DELIVERY: ", package_delivery_time()) # '2017-08-02 16:30:00+00:00'
Sweet! Now you can be sure that everything inside your app is standardised for the UTC timezone.
A Practical Example
Another really popular and classic use-case for decorators is caching the result of a function:
import time def cached(function_to_decorate): _cache = {} # Where we keep the results def decorated_function(*args): start_time = time.time() print('_cache:', _cache) if args not in _cache: _cache[args] = function_to_decorate(*args) # Perform the computation and store it in cache print('Compute time: %ss' % round(time.time() - start_time, 2)) return _cache[args] return decorated_function @cached def complex_computation(x, y): print('Processing ...') time.sleep(2) return x + y print(complex_computation(1, 2)) # 3, Performing the expensive operation print(complex_computation(1, 2)) # 3, SKIP performing the expensive operation print(complex_computation(4, 5)) # 9, Performing the expensive operation print(complex_computation(4, 5)) # 9, SKIP performing the expensive operation print(complex_computation(1, 2)) # 3, SKIP performing the expensive operation
If you look at the code shallowly, you might object. The decorator isn't reusable! If we decorate another function (say another_complex_computation
) and call it with the same parameters then we'll get the cached results from the complex_computation function
. This won't happen. The decorator is reusable, and here's why:
@cached def another_complex_computation(x, y): print('Processing ...') time.sleep(2) return x * y print(another_complex_computation(1, 2)) # 2, Performing the expensive operation print(another_complex_computation(1, 2)) # 2, SKIP performing the expensive operation print(another_complex_computation(1, 2)) # 2, SKIP performing the expensive operation
The cached
function is called once for every function it decorates, so a different _cache
variable is instantiated every time and lives in that context. Let's test this out:
print(complex_computation(10, 20)) # -> 30 print(another_complex_computation(10, 20)) # -> 200
Decorators in the Wild
The decorator we just coded, as you may have noticed, is very useful. It's so useful that a more complex and robust version already exists in the standard functools
module. It is named lru_cache
. LRU is the abbreviation of Least Recently Used, a caching strategy.
from functools import lru_cache @lru_cache() def complex_computation(x, y): print('Processing ...') time.sleep(2) return x + y print(complex_computation(1, 2)) # Processing ... 3 print(complex_computation(1, 2)) # 3 print(complex_computation(2, 3)) # Processing ... 5 print(complex_computation(1, 2)) # 3 print(complex_computation(2, 3)) # 5
One of my favourite uses of decorators is in the Flask web framework. It is so neat that this code snippet is the first thing you see on the Flask website. Here's the snippet:
from flask import Flask app = Flask(__name__) @app.route("/") def hello(): return "Hello World!" if __name__ == "__main__": app.run()
The app.route
decorator assigns the function hello
as the request handler for the route "/"
. The simplicity is amazing.
Another neat use of decorators is inside Django. Usually, web applications have two types of pages:
- pages you can view without being authenticated (front page, landing page, blog post, login, register)
- pages you need to be authenticated to view (profile settings, inbox, dashboard)
If you try to view a page of the latter type, you'll usually get redirected to a login page. Here's how to implement that in Django:
from django.http import HttpResponse from django.contrib.auth.decorators import login_required # Public Pages def home(request): return HttpResponse("<b>Home</b>") def landing(request): return HttpResponse("<b>Landing</b>") # Authenticated Pages @login_required(login_url='/login') def dashboard(request): return HttpResponse("<b>Dashboard</b>") @login_required(login_url='/login') def profile_settings(request): return HttpResponse("<b>Profile Settings</b>")
Observe how neatly the private views are marked with login_required
. While going through the code, it is very clear to the reader which pages require the user to log in and which pages do not.
Conclusions
I hope you had fun learning about decorators because they represent a very neat Python feature. Here are some things to remember:
- Correctly using and designing decorators can make your code better, cleaner, and more beautiful.
- Using decorators can help you DRY up your code—move identical code from inside functions to decorators.
- As you use decorators more, you'll find better, more complex ways to use them.
Remember to check out what we have available for sale and for study on Envato Market, and don't hesitate to ask any questions and provide your valuable feedback using the feed below.
Well, that's that about decorators. Happy decorating!
No comments:
Post a Comment