Skip to content
wannacode

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
@dataclass
class Product:
name: str
price: float
@dataclass
class Box:
name: str
packaging: float
items: list

The 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 result
order = [
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}")
Output
Total: €17.50

The 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 method total(). 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):

@dataclass
class 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}")
Output
Total: €24.50

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}")
Step by step, in words
  1. The tree is put together: every item is on its own line, and every box holds its children.
  2. total() asks the root. The order doesn't know what is inside: it starts from its packaging and calls total() on every child.
  3. Candle is a product: it answers with its price, 5.00, and the answer goes back up.
  4. Holiday box is a box: it does exactly the same, packaging first, then one child at a time.
  5. Small box is on the third level. Nothing disappears here: it asks its own children.
  6. Small box: 1.00 + 3.50 + 2.50 = 7.00. Its total goes up to its parent.
  7. Holiday box: 2.00 + 4.50 + 6.00 + 7.00 = 19.50.
  8. 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:

@dataclass
class WeighedProduct:
name: str
per_kg: float
kg: float
def total(self):
return self.per_kg * self.kg
coffee = Box("Coffee box", 1.00,
WeighedProduct("Coffee beans", 24.00, 0.25),
Product("Mug", 8.00))
print(f"Coffee box: €{coffee.total():.2f}")
Output
Coffee box: €15.00

Not 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())
Error
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.

If you want the shape checked without inheritance, 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}")
Answer

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}")
Output
Holiday box: €12.50

The holiday box costs €19.50, and the program says €12.50. Which line is the bug on?

Pick the line with the bug.

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":

Output
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.50

More 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

1. The shop starts selling coffee by the kilo. What do you write with Composite?
2. How many levels can the tree have for the total to be right?
3. A box adds its packaging and the prices of only the products directly inside it. What happens to a box in the box?
4. When don't you need Composite?

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