Low-Level Design · lesson 11 of 18
Beginner
4Intermediate
10- 5 Design a Parking Lot: Allocating and Freeing Spots
- 6 Design an Elevator System: Scheduling Requests
- 7 Design a Library Management System: Borrowing and Returning Books
- 8 Design an Expense-Splitting App: Balances and Minimal Settlements
- 9 Design a URL Shortener: Sequential Codes vs Real Hash Collisions
- 10 Wrapping Objects: Adapter, Facade, and Proxy in Python
- 11 The Composite Pattern in Python: Sizing a File Tree Without isinstance
- 12 The State Pattern in Python: Refactoring a Vending Machine Into State Classes
- 13 Undo and Redo in Python: the Memento and Command Patterns Compared
- 14 Chain of Responsibility in Python: Expense Approvals Without an if/elif Ladder
Advanced
4- 15 Concurrency in LLD: A Real Race Condition in the Parking Lot, Then the Fix
- 16 The Observer Pattern in Practice: A Stock Ticker Notifying Multiple Displays
- 17 Design Patterns That Are Just Language Features: Strategy, Command, Iterator
- 18 Abstract Factory and Prototype in Python: Matching Families and Cloning Objects
Also part of Low-Level Design
12- + SOLID Principles in Java from Java
- + Composition vs Inheritance in Java from Java
- + Design Patterns in Java from Java
- + Python Design Patterns from Python
- + Design Patterns in TypeScript from TypeScript
- + JavaScript Design Patterns from JavaScript
- + Design Patterns in C# from C#
- + Design Patterns in C++ from C++
- + Design Patterns in Go from Go
- + Design Patterns in Rust from Rust
- + Abstract Classes in Java from Java
- + Sealed Classes and Interfaces in Java from Java
The Composite Pattern in Python: Sizing a File Tree Without isinstance
Watch an isinstance-based folder size silently miss a new node type, then fix it with the Composite pattern: one size() that files and folders share.
By SkillByExample · Published
A folder can hold files and other folders, to any depth. Code that walks such a tree has to decide, for every node, what to do with it, and that decision is where the bugs live.
Sizing a tree by asking “what are you?”
This version checks each node’s type. It worked until someone added zip archives to the tree.
# sizes_isinstance.py: the size function asks "file or folder?" and misses a new node type
class File:
def __init__(self, name, size):
self.name, self.size = name, size
class Folder:
def __init__(self, name, children):
self.name, self.children = name, children
class ZipArchive:
"""Added later: a compressed bundle that knows its own size on disk."""
def __init__(self, name, packed_size):
self.name, self.packed_size = name, packed_size
def total_size(node):
if isinstance(node, File):
return node.size
if isinstance(node, Folder):
return sum(total_size(child) for child in node.children)
return 0 # anything else falls through silently
project = Folder("project", [
File("README.md", 2_000),
Folder("src", [File("app.py", 12_000), File("db.py", 8_000)]),
ZipArchive("assets.zip", 500_000),
])
print("total bytes:", total_size(project))
$ python sizes_isinstance.py
total bytes: 22000
The real total is 522,000 bytes; the function reported 22,000. The archive matched neither
isinstance check and fell into return 0. No error, no warning, and every other function
that switches on node type has the same gap waiting.
Composite: every node answers size() itself
Files and folders share one interface. A file returns its own size, and a folder adds up its children’s, without knowing what kind of children they are.
# composite_size.py: every node answers size() itself, so nobody asks "leaf or branch?"
from abc import ABC, abstractmethod
class Node(ABC):
def __init__(self, name):
self.name = name
@abstractmethod
def size(self): ...
class File(Node):
def __init__(self, name, size):
super().__init__(name)
self._size = size
def size(self):
return self._size
class Folder(Node):
def __init__(self, name, children):
super().__init__(name)
self.children = children
def size(self):
return sum(child.size() for child in self.children)
class ZipArchive(Node):
def __init__(self, name, packed_size):
super().__init__(name)
self._packed = packed_size
def size(self):
return self._packed
project = Folder("project", [
File("README.md", 2_000),
Folder("src", [File("app.py", 12_000), File("db.py", 8_000)]),
ZipArchive("assets.zip", 500_000),
])
print("total bytes:", project.size())
print("src only: ", project.children[1].size())
class Broken(Node): # a new type that forgets size()
pass
try:
Broken("oops")
except TypeError as e:
print("caught:", e)
$ python composite_size.py
total bytes: 522000
src only: 20000
caught: Can't instantiate abstract class Broken without an implementation for abstract method 'size'
The archive is counted now, because it implements size() like everything else. Folder.size
never names a type: it calls child.size() and lets each child decide. Any branch of the tree
answers the same question, so src reports its own 20,000 bytes. The abstract base class
catches the remaining risk: a new node type that forgets size() fails as soon as it’s
created, with a TypeError that names the missing method.
The same recursion, a second operation
Once nodes share an interface, adding an operation is one method per class. Here show() prints
the tree with sizes.
# composite_print.py: the same recursion prints the tree with each node's size
class File:
def __init__(self, name, size):
self.name, self._size = name, size
def size(self):
return self._size
def show(self, depth=0):
print(f"{' ' * depth}{self.name} ({self.size():,} B)")
class Folder:
def __init__(self, name, children):
self.name, self.children = name, children
def size(self):
return sum(child.size() for child in self.children)
def show(self, depth=0):
print(f"{' ' * depth}{self.name}/ ({self.size():,} B)")
for child in self.children:
child.show(depth + 1)
project = Folder("project", [
File("README.md", 2_000),
Folder("src", [File("app.py", 12_000), Folder("tests", [File("test_app.py", 3_500)])]),
])
project.show()
$ python composite_print.py
project/ (17,500 B)
README.md (2,000 B)
src/ (15,500 B)
app.py (12,000 B)
tests/ (3,500 B)
test_app.py (3,500 B)
Each folder prints itself, then asks its children to print one level deeper. The indentation comes
from depth, passed down the recursion; no code anywhere counts levels from the outside.
This is the trade-off to know about. Composite makes new node types cheap (one class) and new operations a little more expensive (a method on every class). If your tree’s types are fixed and you keep adding operations instead, the Visitor pattern flips that trade-off.
Practice
1. Add a count() method that returns how many files are in a tree. An empty folder contributes 0.
# p16_1.py: count() works the same way as size(): a file is 1, a folder sums its children
class File:
def __init__(self, name, size):
self.name, self._size = name, size
def count(self):
return 1
class Folder:
def __init__(self, name, children):
self.name, self.children = name, children
def count(self):
return sum(child.count() for child in self.children)
project = Folder("project", [
File("README.md", 2_000),
Folder("src", [File("app.py", 12_000), Folder("tests", [File("test_app.py", 3_500)])]),
Folder("empty", []),
])
print("files:", project.count())
$ python p16_1.py
files: 3
sum() of an empty generator is 0, so the empty folder needs no special case.
2. Add find(suffix), returning the full paths of files whose names end with it.
# p16_2.py: find(suffix) returns matching paths; folders pass their own name down
class File:
def __init__(self, name):
self.name = name
def find(self, suffix, prefix=""):
path = prefix + self.name
return [path] if path.endswith(suffix) else []
class Folder:
def __init__(self, name, children):
self.name, self.children = name, children
def find(self, suffix, prefix=""):
here = prefix + self.name + "/"
return [p for child in self.children for p in child.find(suffix, here)]
project = Folder("project", [
File("README.md"),
Folder("src", [File("app.py"), File("db.py"), Folder("tests", [File("test_app.py")])]),
])
print(project.find(".py"))
$ python p16_2.py
['project/src/app.py', 'project/src/db.py', 'project/src/tests/test_app.py']
Each folder passes its own path down as the prefix, so files build their full path without any node knowing where it sits in the tree.
3. Add largest(), returning the biggest file below a node as (size, name). What should an empty folder return?
# p16_3.py: largest() returns the biggest file below a node as (size, name)
class File:
def __init__(self, name, size):
self.name, self._size = name, size
def largest(self):
return (self._size, self.name)
class Folder:
def __init__(self, name, children):
self.name, self.children = name, children
def largest(self):
found = [child.largest() for child in self.children]
return max(found) if found else (0, None)
project = Folder("project", [
File("README.md", 2_000),
Folder("src", [File("app.py", 12_000), Folder("tests", [File("test_app.py", 3_500)])]),
Folder("empty", []),
])
print(project.largest())
print(project.children[2].largest())
$ python p16_3.py
(12000, 'app.py')
(0, None)
Tuples compare by their first item, so max() picks the biggest size. An empty folder has nothing
to compare, so it returns (0, None) rather than crashing on max([]).
4. Add depth(): 0 for a file, and 1 plus the deepest child for a folder.
# p16_4.py: depth() is 0 for a file and 1 + the deepest child for a folder
class File:
def __init__(self, name):
self.name = name
def depth(self):
return 0
class Folder:
def __init__(self, name, children):
self.name, self.children = name, children
def depth(self):
return 1 + max((child.depth() for child in self.children), default=0)
project = Folder("project", [
File("README.md"),
Folder("src", [File("app.py"), Folder("tests", [File("test_app.py")])]),
])
print("depth:", project.depth())
print("empty folder:", Folder("tmp", []).depth())
$ python p16_4.py
depth: 3
empty folder: 1
max(..., default=0) handles the empty folder, which is one level deep with nothing inside.