Design Pattern: Composite
The Composite Pattern lets you organize objects into a tree structure, then handle them with a single interface. This is very elegant.
For example, a file system is a tree: folders and files form this tree structure, the root is the root directory, which contains folders and files. Any folder can hold files or other folders, nested as deep as you need.
Or take a graphics editor: you can create basic shapes, and then group several shapes together. This group can be moved or resized as one. These groups can also be grouped again, endlessly.
In these cases, we need to handle both "individual" objects (like files, simple shapes) and "composite" objects (like folders, shape groups). And we want to treat both in the same way (for example, view, move, resize, etc.).
The core idea of the Composite Pattern is: treat composite objects the same way as individual ones. It lets us put objects in a tree, and have composites and simple objects share the same interface, which makes client code much simpler.
When you read "tree structure," does it make you excited as an algorithms learner? Algorithms are not just for coding questions, they also appear in design patterns. The problem Lazy Expansion Multitree is actually a composite pattern in disguise.
Let's get back on track. The composite pattern has these key roles:
- Component Interface: Defines a common interface for all objects in the composition (both leaves and composites).
- Leaf: A simple object. It does not have children.
- Composite: A container object. It can contain children, which can be either leaf nodes or other composites. Composite nodes call their children’s methods, then combine the results.
Let’s look at two examples to understand the composite pattern.
Example 1: File System
A file system is made of files and folders. Folders can hold files or other folders, making a tree.
For users, files and folders share some operations, like getting a name or calculating total size. The composite pattern helps us handle both with the same code.
First, we create a common component interface FSNode
, which lists all shared actions.
// Component: interface for both files and folders
public interface FSNode {
// Get the name
String getName();
// Count total number of files
int count();
// Get total disk usage in bytes
long size();
// Print the directory tree structure
String tree(String indent);
}
Next, let's create the leaf node class File
. A file is the smallest unit and cannot contain other nodes.
// Leaf: File class
public class File implements FSNode {
private final String name;
private final long size;
public File(String name, long size) {
this.name = name;
this.size = size;
}
@Override
public String getName() { return name; }
@Override
public int count() {
// A file counts as one file
return 1;
}
@Override
public long size() {
return size;
}
@Override
public String tree(String indent) {
return indent + name + " (" + size + " bytes)\n";
}
}
Now for the composite node Folder
class. A folder can contain children, which are also FSNode
objects. They could be File
or even other Folder
nodes.
// Composite: Folder class
import java.util.ArrayList;
import java.util.List;
public class Folder implements FSNode {
private final String name;
private final List<FSNode> children = new ArrayList<>();
public Folder(String name) {
this.name = name;
}
public void add(FSNode node) {
children.add(node);
}
public void remove(FSNode node) {
children.remove(node);
}
@Override
public String getName() {
return name;
}
@Override
public int count() {
int totalFiles = 0;
// Recursively sum up file count of children
for (FSNode child : children) {
totalFiles += child.count();
}
return totalFiles;
}
@Override
public long size() {
long totalSize = 0;
// Recursively sum up size of children
for (FSNode child : children) {
totalSize += child.size();
}
return totalSize;
}
@Override
public String tree(String indent) {
StringBuilder sb = new StringBuilder();
sb.append(indent).append("+ ").append(name).append("/\n");
// Print the structure of all children
for (FSNode child : children) {
sb.append(child.tree(indent + " "));
}
return sb.toString();
}
}
The composite node's count()
, size()
, and tree()
methods all show the key point of the composite pattern: delegate actions to child nodes and combine their results.
Finally, let's see how to use this file system tree:
public class Main {
public static void main(String[] args) {
Folder root = new Folder("root");
root.add(new File("README.md", 1024));
root.add(new File("LICENSE", 512));
Folder documents = new Folder("documents");
documents.add(new File("index.html", 2048));
documents.add(new File("config.txt", 1536));
root.add(documents);
// Print the whole directory tree
System.out.println(root.tree(""));
// + root/
// README.md (1024 bytes)
// LICENSE (512 bytes)
// + documents/
// index.html (2048 bytes)
// config.txt (1536 bytes)
// Count files and size
System.out.println("Total files: " + root.count()); // Total files: 4
System.out.println("Total size: " + root.size() + " bytes"); // Total size: 5120 bytes
}
}
The client code uses the FSNode
interface to interact with both files and folders, and never needs an if-else
to tell them apart.
When we call root.count()
, the client does not care if root
is a folder or file. It just calls a method, and the whole tree works together to return the right answer.
Scenario 2: Shape Combination
In a graphics editor, users can draw basic shapes (like circles and squares), and they can also combine these basic shapes into a complex shape. The combined shape can be moved, scaled, or displayed as a whole, and it can also be combined further into more complex shapes.
First, we define the component interface Shape
, which declares the operations that both basic shapes and combined shapes have.
// Component: Shape interface
public interface Shape {
// Move the shape
void move(int dx, int dy);
// Print the shape's information
void display(String indent);
}
Next are the leaf nodes: Circle
and Square
. They are basic shapes and do not have children.
// Leaf: Circle
public class Circle implements Shape {
private int x, y;
private final int radius;
public Circle(int x, int y, int radius) {
this.x = x;
this.y = y;
this.radius = radius;
}
@Override
public void move(int dx, int dy) {
this.x += dx;
this.y += dy;
}
@Override
public void display(String indent) {
System.out.println(indent + "Circle at (" + x + ", " + y + ")");
}
}
// Leaf: Square
public class Square implements Shape {
private int x, y;
private final int side;
public Square(int x, int y, int side) {
this.x = x;
this.y = y;
this.side = side;
}
@Override
public void move(int dx, int dy) {
this.x += dx;
this.y += dy;
}
@Override
public void display(String indent) {
System.out.println(indent + "Square at (" + x + ", " + y + ")");
}
}
Then we have the composite node: the Group
class. It can contain multiple child nodes which implement the Shape
interface. The child nodes can be leaf nodes like Circle
and Square
, or they can also be other composite nodes like Group
.
// Composite: Group node
import java.util.ArrayList;
import java.util.List;
public class Group implements Shape {
private final List<Shape> children = new ArrayList<>();
public void add(Shape shape) {
children.add(shape);
}
@Override
public void move(int dx, int dy) {
// When the group is moved, it moves all child shapes
for (Shape child : children) {
child.move(dx, dy);
}
}
@Override
public void display(String indent) {
// Show the group information and then display all children
System.out.println(indent + "Group {");
for (Shape child : children) {
// Increase indent for child shapes
child.display(indent + " ");
}
System.out.println(indent + "}");
}
}
Now, we can create complex shapes and operate on them just like single shapes.
public class Main {
public static void main(String[] args) {
// Create a canvas, which is the top-level group
Group canvas = new Group();
// Create another group and add shapes to it
Group subGroup = new Group();
subGroup.add(new Square(100, 110, 40));
subGroup.add(new Circle(200, 210, 25));
// Add a single shape and a group to the canvas
canvas.add(new Circle(10, 20, 15));
canvas.add(subGroup);
System.out.println("--- Initial State ---");
canvas.display("");
// --- Initial State ---
// Group {
// Circle at (10, 20)
// Group {
// Square at (100, 110)
// Circle at (200, 210)
// }
// }
// Move the whole canvas; all children will be moved
canvas.move(100, -50);
System.out.println("\n--- After Moving (100, -50) ---");
canvas.display("");
// --- After Moving (100, -50) ---
// Group {
// Circle at (110, -30)
// Group {
// Square at (200, 60)
// Circle at (300, 160)
// }
// }
}
}
In this example, when the client calls canvas.move()
, this move operation is passed down by the Group
object. All its child shapes, whether it is a single Circle
or shapes inside subGroup
, will all be moved together.
Summary
The advantages of the composite pattern are:
- Simplifies client code: The client can treat all objects the same way. There is no need to tell the difference between leaf nodes and group nodes.
- Easy to extend: You can easily add new leaf nodes or new group nodes to the structure.
When the objects you are handling can be organized into a tree structure, and all nodes in the tree provide the same interface, the composite pattern is a very simple and clean solution. It lets you handle all objects in a uniform way.