Composite
In short: Composite gives a single item and a group the same shape, so a tree of products and boxes is handled with one call, however many levels deep it goes.
Problem
A box in a box in a box
The shop sells gift boxes. The holiday box holds tea, honey and a smaller box with candy and chocolate. Every box also has a price for its packaging. The order has a candle and the holiday box. The data looks like this:
from dataclasses import dataclass
@dataclassclass Product: name: str price: float
@dataclassclass Box: name: str packaging: float items: listThe total is computed by checking what each item is: a product or a box. For a box there is a second loop:
def total(order): result = 0 for item in order: if isinstance(item, Product): result += item.price elif isinstance(item, Box): result += item.packaging for inner in item.items: if isinstance(inner, Product): result += inner.price return resultorder = [ Product("Candle", 5.00), Box("Holiday box", 2.00, [ Product("Tea", 4.50), Product("Honey", 6.00), Box("Small box", 1.00, [ Product("Candy", 3.50), Product("Chocolate", 2.50)])])]print(f"Total: €{total(order):.2f}")Total: €17.50The order costs €24.50, and the program says €17.50. The small box is on the third level. The second loop sees it, but it isn't a product, and it disappears from the total together with the candy and the chocolate. There is no error.
A third nested loop fixes this order, but not a box on the fourth level. And the same chain of checks is in counting the products and in the weight for the courier too.
The idea
Composite: the part and the whole with one shape
The product and the box are asked the same thing: how much are you? The product answers with its price. The box answers with its packaging plus the answers of everything inside.
So both have the same methodtotal(). The box keeps a list of items and doesn't ask what they are: it calls total() on each one. No interface is needed, as long as every item has this method.This is the Composite pattern. It has three roles:
- the common shape: the method
total(); - the leaf: the product, which computes on its own;
- the container: the box, which keeps children with the same shape and asks each of them.
A box in the box is just a child that also asks its children. This way the calls go down to the products, however many levels there are: this is recursion through objects. The code that asks for the total doesn't know whether it holds a product, a box or a whole tree.
A folder with files has the same shape: the size of a file is its own, and the size of a folder is the total of everything in it, including the folders inside.
See it
The total goes down to the bottom and comes back
The product and the box. The product is a dataclass, and the box takes any number of items (*items):
@dataclassclass Product: name: str price: float
def total(self): return self.price
class Box: def __init__(self, name, packaging, *items): self.name = name self.packaging = packaging self.items = list(items)
def total(self): return self.packaging + sum(item.total() for item in self.items)The tree is written as nested constructors, one item per line:
order = Box("Order", 0, Product("Candle", 5.00), Box("Holiday box", 2.00, Product("Tea", 4.50), Product("Honey", 6.00), Box("Small box", 1.00, Product("Candy", 3.50), Product("Chocolate", 2.50))))print(f"Total: €{order.total():.2f}")Total: €24.50order = Box("Order", 0, Product("Candle", 5.00), Box("Holiday box", 2.00, Product("Tea", 4.50), Product("Honey", 6.00), Box("Small box", 1.00, Product("Candy", 3.50), Product("Chocolate", 2.50))))print(f"Total: €{order.total():.2f}")Step by step, in words
- The tree is put together: every item is on its own line, and every box holds its children.
- total() asks the root. The order doesn't know what is inside: it starts from its packaging and calls total() on every child.
- Candle is a product: it answers with its price, 5.00, and the answer goes back up.
- Holiday box is a box: it does exactly the same, packaging first, then one child at a time.
- Small box is on the third level. Nothing disappears here: it asks its own children.
- Small box: 1.00 + 3.50 + 2.50 = 7.00. Its total goes up to its parent.
- Holiday box: 2.00 + 4.50 + 6.00 + 7.00 = 19.50.
- Order: 0.00 + 5.00 + 19.50 = 24.50. That is what the program prints.
There is no type check anywhere and no number of levels anywhere. The box does the same thing on every level: the packaging plus the total of the children.
See it
A new kind of item without touching the box
The shop starts selling coffee beans at €24 per kilo. Its price is the price per kilo times the kilos. This is a new class with the same shape:
@dataclassclass WeighedProduct: name: str per_kg: float kg: float
def total(self): return self.per_kg * self.kgcoffee = Box("Coffee box", 1.00, WeighedProduct("Coffee beans", 24.00, 0.25), Product("Mug", 8.00))print(f"Coffee box: €{coffee.total():.2f}")Coffee box: €15.00Not a single line of Box changed. The box calls total() on the coffee, as on any other item.
Here the box takes anything. Text in place of a product goes in without an error, and the program stops only at the total:
box = Box("Box", 1.00, "Tea")print(box.total())AttributeError: 'str' object has no attribute 'total'That is still better than the problem, where such an item was silently skipped: here the error shows up as soon as someone asks for the total.
More precisely, if you are curious
A new kind of item is easy: one class. A new operation costs more: if besides the total you also need a weight for the courier, the method weight() is added to every class. Composite is strong where the kinds of items grow and the operations are few.
typing.Protocol describes "something with a method total()", and tools like mypy check the box before the program starts. Python itself doesn't check it at run time.Try it
Predict the output
The box holds an empty box and a candle. What does the program print?
box = Box("Box", 1.00, Box("Empty", 0.50), Product("Candle", 5.00))print(f"Box: €{box.total():.2f}")Try it
Find the bug
Someone rewrites the box to make it "safer":
class Box: def __init__(self, name, packaging, *items): self.name = name self.packaging = packaging self.items = list(items)
def total(self): return self.packaging + sum( item.total() for item in self.items if isinstance(item, Product) )gift = Box("Holiday box", 2.00, Product("Tea", 4.50), Product("Honey", 6.00), Box("Small box", 1.00, Product("Candy", 3.50), Product("Chocolate", 2.50)))print(f"Holiday box: €{gift.total():.2f}")Holiday box: €12.50The holiday box costs €19.50, and the program says €12.50. Which line is the bug on?
Pick the line with the bug.
The check lets only products through. The small box inside isn't a product, so it, the candy and the chocolate (€7) disappear from the total. This is the same bug as in the problem, only hidden inside the box. The container in Composite doesn't ask what the child is: it calls total() on every one. Remove the check, and the total is €19.50.
The idea
When you don't need it
Composite has a cost: a common shape and recursion that you have to read to understand one total. It isn't worth it when:
- the things are in a flat list. A cart without boxes is a list, and the total is one loop;
- there are exactly two levels and it stays that way: an order with lines. An order line doesn't contain an order;
- the operations become many, and there are two kinds of items. Every new operation is written in every class. Then it is sometimes clearer for one function to walk the tree with a check of the kind, but in one place.
It is worth it when parts and wholes are nested in each other on however many levels come: boxes, folders, categories in a catalog, menus with submenus, elements on a page.
Exercise
The receipt
Solve the task in your editor.
The receipt as a tree. Add a method print(self, indent="") to the product and to the box. The product prints its name and its price. The box prints its name and its total, and then calls print on every child with two more spaces. Print the order from the card "The total goes down to the bottom and comes back":
Order: €24.50 Candle: €5.00 Holiday box: €19.50 Tea: €4.50 Honey: €6.00 Small box: €7.00 Candy: €3.50 Chocolate: €2.50More tasks
- How many products. Add
count(): a product is 1, and a box is the total of its children. The packaging isn't a product. The order has 5. - Folders and files. Make a file with a name and a size in KB, and a folder with files and folders. Print the size of every folder. What does it have in common with the boxes?
- The most expensive product. Find the price of the most expensive product in the whole tree. Think about what an empty box returns.
Test
Check yourself
Related lessons
Section titled "Related lessons"- Builds on: Exceptions · Lists: append, remove and sort · dataclass, match and patterns · Design patterns
- Continues in: Command
- Tasks on this topic: Design patterns