Skip to content
wannacode

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}")
Output
Espresso: €2.00
Espresso, milk: €2.40
Espresso, milk, syrup: €3.00

It 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 methods cost() 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() and description();
  • 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}")
Output
Espresso, milk, syrup, syrup: €3.60

drink = Espresso()
drink = Milk(drink)
drink = Syrup(drink)
drink = Syrup(drink)
print(f"{drink.description()}: €{drink.cost():.2f}")
Step by step, in words
  1. The espresso is the base. It wraps nothing and knows its price: 2.00.
  2. Milk wraps the espresso and keeps it in its field inner. drink now points to the milk.
  3. Syrup wraps the milk. The milk and the espresso inside are not touched.
  4. Syrup once more: a second layer of the same class. That is the double syrup.
  5. 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.
  6. The espresso answers 2.00. On the way out every layer adds its part to what came from inside.
  7. 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}")
Output
Espresso, milk, extra shot, 10% off: €2.88

Espresso, 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())
Error
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 functools
import 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
@logged
def 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)
Output
[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__)
Output
The function name: place

Without 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}")
Answer

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}")
Output
Espresso, milk, syrup: €0.60

The description is right, and the sum is €0.60. Which line is the bug on?

Pick the line with the bug.

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.

Output
Cappuccino, whipped cream, whipped cream: €3.80

More tasks

  • How long it takes. Write a decorator @timed that measures the time with time.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

1. A customer wants an espresso with milk and syrup twice. What do you write with Decorator?
2. What does Milk do when asked for cost()?
3. place is wrapped with @logged (in JavaScript: logged(function place…)). What changes in the body of place to get logs?
4. When don't you need Decorator?

Made by Ivo Gergov as part of Evo Tech's campaign for accessible programming education ·