What are decorators and how to use them?

In this post, I first give a motivating example where decorators help simplify code maintenance. Then I explain what decorators are and how they work.

Motivating example

Suppose I own a business selling minions. The more minions you buy, the cheaper price you have to pay per minion (like bulk-buying in Costco). Additionally, I have 4 types of minions, each does a different kind of work (cleaning, cooking, driving, and general-tasking). At the moment, my pricing scheme looks like this:

  • Base price: $2.0, $10.0, $5.0, $20.0 per minion in the categories cleaning, cooking, driving, and general, respectively, if you buy fewer than 5 minions
  • Small order price: 2% discount off the base price, if you buy between 11 and 80, with at least 2 of each kind
  • Medium order price: 5% discount off the base price, if you buy between 51 and 100 minions, with at least 8 general-tasking minions
  • Large order price: 10% discount off the base price, if you buy between 80 and 200 minions, with at least 20 of each of cooking and general-tasking minions

To compute the total price of your order, I may use a function like this:

from collections import Counter

def order_price(order):
    # order is a list storing the numeric-code of minions
    # in the order, where cleaning, cooking, driving, and general are
    # denoted by 0, 1, 2, and 3, respectively

    # Counter(order) generates a dictionary, each entry corresponds to kind:count
    kind_quantity = Counter(order)

    total_units = len(order)

    base_prices = {0:2.0, 1:10.0, 2:5.0, 3:20.5}
    total_price = sum([base_prices[kind] * count for kind, count in kind_quantity.iteritems()])   

    if total_units <= 10:
        return total_price
    elif 10 < total_units <= 80 and kind_quantity.values() > 2:
        return total_price * 0.98
    elif 51 < total_units <= 100 and kind_quantity[3] > 8:
        return total_price * 0.95
    elif 80 < total_units <= 200 and kind_quantity[1] > 10 and kind_quantity[3] > 10:
        return total_price * 0.90

What are some problems with the above solution? Here are the major three that come to my mind:

  • The if ... elif ... statements do not explicitly tell you the applicable scenarios - their purposes aren’t clear without comments. Too many elifs also make code hard to read and maintain.
  • If suddenly I have a new customer (like Gru) who frequently orders more than 200 minions at a time and demands even more discount, I would have to go in and edit the body of order_price. Such change may become a hassle as I have new customers and need to add more and more discounting scenarios.
  • Given an order, it is difficult to see which discount strategy may yield the most money.

The solution to these issues is to write each discount strategy as a separate function, and store them in a list. This is where decorators really help.

Consider the following rewrite of the previous code.

from collections import Counter

base_prices = {0:1.0, 1:1.5, 2:2.0, 3:2.5}

class Order:
    def __init__(self):
        self.items = []
        self.kind_quantity = dict()
        self.total_units = 0
        self.total_price = 0

    def add(self, item):
        self.items.append(item)
        if item in self.kind_quantity.keys():
            self.kind_quantity[item] += 1
            self.total_units += 1
            self.total_price += base_prices[item]
        else:
            self.kind_quantity[item] = 1

    def remove(self, item):
        try:
            self.items.remove(item)
            self.kind_quantity[item] -= 1
            self.total_units -= 1
            self.total_price -= base_prices[item]
        except ValueError:
            print '{} is not in order'.format(item)

    def __repr__(self):
        return str(self.items)

discount_scenarios = []

def discount_order(discount_scenario_func):
    discount_scenarios.append(discount_scenario_func)
    return discount_scenario_func

@discount_order
def base(order):
    if order.total_units <= 10:
        return order.total_price
    return 0

@discount_order
def small(order):
    if  10 < order.total_units <= 80 and order.kind_quantity.values() > 2:
        return order.total_price * 0.98
    return 0

@discount_order
def medium(order):
    if 51 < order.total_units <= 100 and order.kind_quantity[3] > 8:
        return order.total_price * 0.95
    return 0

@discount_order
def large(order):
    if 100 < order.total_units <= 200 and order.kind_quantity[1] > 10 and order.kind_quantity[3] > 10:
        return order.total_price * 0.90
    return 0

@discount_order
def gru(order):
    if 200 < order.total_units:
        return order.total_price * 0.85

This solution is admittedly quite longer than the original, but it is much more clear what the intents of the program are. It is also easier to add new pricing strategies later on, by just prepending the strategy definitions with the decorator tag @discount_order. Importantly, now we can iterate through the list of scenarios to see which results in the largest total price.

a = Order()
for i in range(49):
    a.add(0)
for i in range(3):
    a.add(1)
for i in range(9):
    a.add(3)
for i in range(6):
    a.add(2)

[price(a) for price in discount_scenarios]
[0, 79.38, 76.95, 0, None]

So the small and medium order sizes are applicable and the small oder size results in the largest total price.

As you can see, the ability to iterate through a list of functions is pretty useful. Of course, we could have just append the functions to discount_scenarios, as the example below demonstrates:

def villain_order(order):
    return 0

discount_scenarios.append(villain_order)
discount_scenarios[-1](a)
0

But this requires that the line that does the append follow the function definition, so the intent of the function isn’t clear: we don’t know that the function is meant to be an item in discount_scenarios just by looking at its definition. The @discount_order line makes this intent clear at the very beginning of the function definition.

An informal definition of decorators

Informally, decorators are functions that take a function as input and return a function. The input and output functions can be the same, as in the example above, but more often, they are different. The input function is called the decorated function. In the example above, discount_order is a decorator, and base, small, medium, large, and gru are decorated functions (decorated with discount_order).

Since decorators are just function, we can have them accept input the normal way, i.e. with the argument inside a pair of parentheses:

def some_order(order):
    return 0

discount_order(some_order)
discount_scenarios
[<function __main__.base>,
 <function __main__.small>,
 <function __main__.medium>,
 <function __main__.large>,
 <function __main__.gru>,
 <function __main__.villain_order>,
 <function __main__.some_order>]

Thus, the above lines of code do the same thing as the following syntax, which we have seen from the beginning:

@discount_order
def some_order(order):
    return 0

The fact that decorators accept a function and return a function means that they can replace a function with a different function. This is why decorators are called decorators: they ‘decorate’ the input function in some way and return that decorated function. This has a subtle implication for how decorators are used. For example, the following (incorrect) example attempts to use a decorator to implement a ‘generic base function’ to compute order price.

def discount_order(discount_scenario_func):
    def order_price(order):
        kind_quantity = Counter(order)
        total_units = len(order)
        base_prices = {0:2.0, 1:10.0, 2:5.0, 3:20.5}
        total_price = sum([base_prices[kind] * count for kind, count in kind_quantity.iteritems()])   
        discount_scenario_func(kind_quantity, total_units, total_price)
        return total_price
    return order_price

@discount_order
def base(kind_quantity, total_units, total_price):
    if total_units <= 10:
        return total_price
    return 0

The above code will results in a compilation error, but the key thing to note is that the example’s usage of decorator is fundamentally wrong. Here base (the decorated function) attempts to decorate the decorator discount_order by doing something with the results of the function order_price inside the decorator. That contradicts the usage pattern explained above, i.e. discount_order should decorate base by doing something with the results of base.

One other important thing to note about decorators is that they executed as soon as they are imported or loaded. This is illustrated in the following small example:

def decorator(func):
    print 'execute decorator()'
    return func

@decorator
def f():
    print 'execute f()'
execute decorator()
f()
execute f()

Summary

Decorators are functions that take a function as input and return a (possibly different) function. Decorators ‘decorate’ the decorated funtion usually by taking the results of the decorated function and do something with it. This facilitates code maintenance, expansion, and readability.

A common helpful usage of decorator is to create and expand list of functions that implement a shared functionality under different scenarios. Compared to appending directly to a list, decorator has the advantage of making clear the intent that a function is an item in that list.