Decorator
In short: Decorator wraps an object in another one with the same shape and adds behaviour without touching the class; in Python a @decorator wraps a function the same way.
Problem
The menu that grows with every add-on
The coffee shop sells espresso for €2. A customer can add milk (+€0.40), syrup (+€0.60) and an extra shot (+€0.80). Someone writes a class for every combination:
class Espresso: def cost(self): return 2.00
def description(self): return "Espresso"
class EspressoWithMilk(Espresso): def cost(self): return super().cost() + 0.40
def description(self): return super().description() + ", milk"
class EspressoWithMilkAndSyrup(EspressoWithMilk): def cost(self): return super().cost() + 0.60
def description(self): return super().description() + ", syrup"drink = Espresso()print(f"{drink.description()}: €{drink.cost():.2f}")drink = EspressoWithMilk()print(f"{drink.description()}: €{drink.cost():.2f}")drink = EspressoWithMilkAndSyrup()print(f"{drink.description()}: €{drink.cost():.2f}")Espresso: €2.00Espresso, milk: €2.40Espresso, milk, syrup: €3.00It works for the three drinks on the menu. But three add-ons make eight combinations, and with cappuccino and tea that's 24 classes. One more add-on doubles the count.
Python allows a class with several parents, but that doesn't help: you still need a class for every combination, and the syrup code lives in several classes.And the customer wants syrup twice. There is no such class. A class for one, two and three syrups is not a solution.
The idea
Decorator: a wrapper with the same shape
An add-on isn't a kind of drink. It is something that wraps a drink: it takes the drink and adds its own part.
So they all share one shape: the methodscost() and description(). The espresso has them and knows its own price. Milk has them too, but keeps another drink in a field. When asked for the price, it asks the inner drink and adds €0.40. No interface is needed: any object with these two methods can be wrapped.This is the Decorator pattern. It has three roles:
- the common shape: the methods
cost()anddescription(); - the base: the espresso, which wraps nothing;
- the decorators: milk, syrup, the extra shot. Each keeps an inner drink, calls the same method on it and adds its own part.
Since the wrapper has the same shape as the drink, a wrapper can wrap a wrapper. Double syrup is two layers of syrup. The code that asks for the price doesn't know how many layers there are.
See it
Layer by layer
The espresso and two add-ons. Every add-on gets the inner drink in its constructor:
class Espresso: def cost(self): return 2.00
def description(self): return "Espresso"
class Milk: def __init__(self, inner): self.inner = inner
def cost(self): return self.inner.cost() + 0.40
def description(self): return self.inner.description() + ", milk"
class Syrup: def __init__(self, inner): self.inner = inner
def cost(self): return self.inner.cost() + 0.60
def description(self): return self.inner.description() + ", syrup"The order is put together from the inside out: first the espresso, then every layer wraps what there is so far.
drink = Espresso()drink = Milk(drink)drink = Syrup(drink)drink = Syrup(drink)print(f"{drink.description()}: €{drink.cost():.2f}")Espresso, milk, syrup, syrup: €3.60drink = Espresso()drink = Milk(drink)drink = Syrup(drink)drink = Syrup(drink)print(f"{drink.description()}: €{drink.cost():.2f}")Step by step, in words
- The espresso is the base. It wraps nothing and knows its price: 2.00.
- Milk wraps the espresso and keeps it in its field inner. drink now points to the milk.
- Syrup wraps the milk. The milk and the espresso inside are not touched.
- Syrup once more: a second layer of the same class. That is the double syrup.
- cost() goes to the outer layer. It adds nothing yet: first it calls self.inner.cost(), and so does every layer on the way in.
- The espresso answers 2.00. On the way out every layer adds its part to what came from inside.
- 3.60 comes out of the outer layer. description() goes the same way and collects the words.
The call to cost() goes in layer by layer down to the espresso, and the sum grows on the way out. None of the three classes knows about the others.
See it
A new layer without touching the old ones
The extra shot and the happy hour discount are two new classes. The discount doesn't add, it multiplies everything inside by 0.9:
class ExtraShot: def __init__(self, inner): self.inner = inner
def cost(self): return self.inner.cost() + 0.80
def description(self): return self.inner.description() + ", extra shot"
class Discount: def __init__(self, inner): self.inner = inner
def cost(self): return self.inner.cost() * 0.9
def description(self): return self.inner.description() + ", 10% off"order = Discount(ExtraShot(Milk(Espresso())))print(f"{order.description()}: €{order.cost():.2f}")Espresso, milk, extra shot, 10% off: €2.88Espresso, Milk and Syrup didn't change. The new layer goes around any drink, including one that is already wrapped.
You read Discount(ExtraShot(Milk(Espresso()))) from the inside out: an espresso, wrapped in milk, wrapped in a shot, wrapped in a discount. The order matters: the discount applies only to what is inside it.
Here nobody checks what gets wrapped. A wrapper around a piece of text is created without an error, and the program stops only when someone asks for the price:
drink = Milk("Espresso")print(drink.cost())AttributeError: 'str' object has no attribute 'cost'. Did you mean: 'count'?Python even suggests count, because text has a method with that name. The error points at the line in Milk, not at the line where the wrong order was put together. So every new wrapper is run at least once with a real drink.
See it
A wrapper around a method
The same idea works outside coffee too. The order service works, and now every call has to be visible: what was asked and what came back. The code of the service shouldn't be touched: it is tested, and the logs are temporary.
In Python this is needed so often that the language has a syntax for it: @decorator. A function decorator is a function that takes a function and returns a new function, a wrapper around it. @logged above def place means place = logged(place):
import functoolsimport itertools
numbers = itertools.count(1001)
def logged(func): @functools.wraps(func) def wrapper(*args): print(f"[log] {func.__name__}({', '.join(map(str, args))})") result = func(*args) print(f"[log] {func.__name__} returned {result}") return result return wrapper
@loggeddef place(drink, quantity): print(f"Preparing: {quantity} × {drink}") return next(numbers)wrapper takes whatever arguments come (*args), logs, calls the real function and returns its result. This is the same pattern as the milk around the espresso, only the layer is around a function.
place("Cappuccino", 2)place("Espresso", 1)[log] place(Cappuccino, 2)Preparing: 2 × Cappuccino[log] place returned 1001[log] place(Espresso, 1)Preparing: 1 × Espresso[log] place returned 1002
The code that calls place doesn't know it is wrapped. You remove the logs by removing the wrapper. The same goes for measuring time, for a cache or for a retry after an error: each one is a separate layer.
More precisely, if you are curious
@functools.wraps(func) copies the name and the description of the original function onto the wrapper:
print("The function name: " + place.__name__)The function name: placeWithout it place.__name__ is wrapper, and errors and logs show the name of the wrapper instead of the real function. There are many built-in decorators: @functools.cache keeps results that were already computed, @property lets a method be read like a field.
Try it
Predict the output
The discount and the syrup are the same in both orders, only in a different order. What does the program print?
a = Discount(Syrup(Espresso()))b = Syrup(Discount(Espresso()))print(f"€{a.cost():.2f}")print(f"€{b.cost():.2f}")Try it
Find the bug
Someone rewrites the syrup:
class Syrup: def __init__(self, inner): self.inner = inner
def cost(self): return 0.60
def description(self): return self.inner.description() + ", syrup"drink = Syrup(Milk(Espresso()))print(f"{drink.description()}: €{drink.cost():.2f}")Espresso, milk, syrup: €0.60The description is right, and the sum is €0.60. Which line is the bug on?
Pick the line with the bug.
The syrup returns only its own price and doesn't ask the inner drink. Everything under it (the espresso and the milk) disappears from the sum, without an error. The description is right, because description() does ask the inner drink, and that is exactly why the bug is easy to miss. A decorator has to call the same method on the object it wraps: return self.inner.cost() + 0.60. Then the sum is €3.
The idea
When you don't need it
Decorator has a cost: a class for every layer, and the order is an object in an object in an object. It isn't worth it when:
- every add-on only adds a price and a name. Then the add-ons are data: a list of "name, price" pairs, and the sum is the price of the base plus the total of the list. A new syrup is a new row in the menu, not a new class;
- there is one layer and it is never removed. Its code can live in the class itself;
- you wrap an object with many methods and change one. Every wrapper has to forward the rest too, or they are missing.
Watch out for deep stacks too: six layers are six lines in a stack trace, and their order is visible only where they are put together.
It is worth it when the layers do different things (a discount, logging, a cache, a retry), are put together differently for different cases, and you don't want to touch what you wrap.
Exercise
Cappuccino with whipped cream
Solve the task in your editor.
Cappuccino with whipped cream. The coffee shop adds a cappuccino for €2.80 and whipped cream for €0.50. Write the classes Cappuccino and WhippedCream and order a cappuccino with whipped cream twice. The old classes are not touched.
Cappuccino, whipped cream, whipped cream: €3.80More tasks
- How long it takes. Write a decorator
@timedthat measures the time withtime.perf_counter()and prints how many milliseconds the call took. Use it together with the logging. Which layer is on the outside, and why does it matter? - A large cup. A large cup costs 30% more. Add a layer
Large. Put it once around the espresso and once around the whole order with milk and syrup. Which one is fair to the customer? - A counter. A wrapper around the service counts how many orders went through and prints the count at the end. Think about where the counter lives: in the wrapper or in the service.
Test
Check yourself
Related lessons
Section titled "Related lessons"- Builds on: Interfaces as abstract classes · Exceptions · Lambdas · Design patterns
- Continues in: The shop: which pattern where · Caching
- Tasks on this topic: Design patterns