"目录树""组织架构""菜单和子菜单""DOM"—— 凡是有"整体由部分组成,部分又可以由更小的部分组成"这种结构的地方,组合模式就在那里。它的核心目的是:让客户端用统一方式对待"单个对象"和"对象的组合"。这篇文章带你从最朴素的文件系统例子讲起,看清组合模式在 DOM / 菜单 / 表达式树 / 公司组织里的相同骨架,以及它和访问者、迭代器、装饰器的边界。
问题:对待"单个"和"集合"用不同代码
看一个朴素实现:
class File {
String name;
long size;
public long getSize() { return size; }
}
class Folder {
String name;
List<File> files = new ArrayList<>();
List<Folder> subFolders = new ArrayList<>(); // 嵌套自己
public long getSize() {
long total = 0;
for (File f : files) total += f.getSize();
for (Folder sf : subFolders) total += sf.getSize(); // 递归
return total;
}
}
问题:
- 客户端要分别处理 File 和 Folder。打印目录树时要 if(isFile) ... else ...。
- 新加一种"叶子节点"(比如 Symlink、Shortcut)要在所有地方加一个分支。
- Folder 内部要维护两个列表,代码冗余。
组合模式提出:让叶子(File)和容器(Folder)实现同一个接口,客户端只调接口方法,递归在容器内部自然发生。
组合模式的标准结构
// 1. 共同接口:Component
interface FsNode {
String getName();
long getSize();
void print(String indent);
}
// 2. 叶子:Leaf
class File implements FsNode {
private final String name;
private final long size;
public File(String name, long size) { this.name = name; this.size = size; }
public String getName() { return name; }
public long getSize() { return size; }
public void print(String indent) {
System.out.println(indent + name + " (" + size + " B)");
}
}
// 3. 容器:Composite
class Folder implements FsNode {
private final String name;
private final List<FsNode> children = new ArrayList<>();
public Folder(String name) { this.name = name; }
public String getName() { return name; }
public void add(FsNode node) { children.add(node); }
public void remove(FsNode node) { children.remove(node); }
public long getSize() {
long total = 0;
for (FsNode child : children) total += child.getSize();
return total;
}
public void print(String indent) {
System.out.println(indent + name + "/");
for (FsNode child : children) child.print(indent + " ");
}
}
// 客户端:不区分 File 和 Folder,只用 FsNode 接口
FsNode root = new Folder("/");
Folder docs = new Folder("docs");
docs.add(new File("a.txt", 100));
docs.add(new File("b.txt", 200));
Folder src = new Folder("src");
src.add(new File("main.go", 1500));
src.add(new File("util.go", 800));
((Folder) root).add(docs);
((Folder) root).add(src);
((Folder) root).add(new File("README.md", 50));
System.out.println("总大小: " + root.getSize()); // 2650
root.print("");
关键变化:getSize 和 print 这两个递归操作完全藏在结构内部。客户端调用 root.getSize() 不需要知道 root 是文件还是目录 —— 内部递归无感发生。
透明 vs 安全:子节点管理方法放哪里
组合模式有个经典争论:add / remove / getChildren 这些"管理子节点"的方法,应该放在 Component 接口上,还是只放在 Composite 上?
透明方式:Component 接口包含 add/remove
interface FsNode {
long getSize();
void add(FsNode node); // 即使是 File 也要"实现"这个方法
void remove(FsNode node);
}
class File implements FsNode {
public void add(FsNode node) {
throw new UnsupportedOperationException("不能给文件加子节点");
}
// ...
}
优点:客户端可以完全统一处理。
缺点:File 被迫提供它不该有的方法,运行时才暴露错误。
安全方式:只在 Composite 暴露 add/remove
这就是上面例子的写法 —— Folder 有 add,File 没有。客户端用 File 的引用调不到 add,编译期就发现错误。
代价是客户端有时需要"我知道这是 Folder 才能加东西"的局部类型判断。
GoF 原书倾向透明,但实际工程多用安全。Java 集合框架就是安全派:List 接口不继承自 Collection"加孩子"的方法。
实战 1:HTML DOM
浏览器 DOM 是组合模式的教科书例子:
// 抽象:Node
// 叶子:Text、Comment
// 容器:Element、Document(可以有子节点)
// 所有操作都通过 Node 接口
let root = document.body;
console.log(root.textContent); // 递归收集所有后代的文本
root.querySelectorAll('a').forEach(...) // 递归遍历
// 添加 / 删除子节点
let div = document.createElement('div');
div.appendChild(document.createTextNode('hello')); // Text 是叶子
div.appendChild(document.createElement('span')); // span 是容器
root.appendChild(div);
// 计算所有后代的"渲染高度":递归调用 getBoundingClientRect
你写 document.querySelectorAll('a') 时不需要管它要穿过多少层 div、section、article —— DOM 内部递归处理。这正是组合模式给你的力量。
实战 2:菜单系统
interface MenuItem {
String getTitle();
void render(String indent);
void onClick();
}
class Action implements MenuItem {
private final String title;
private final Runnable handler;
public Action(String title, Runnable handler) { this.title = title; this.handler = handler; }
public String getTitle() { return title; }
public void render(String indent) { System.out.println(indent + "• " + title); }
public void onClick() { handler.run(); }
}
class Menu implements MenuItem {
private final String title;
private final List<MenuItem> items = new ArrayList<>();
public Menu(String title) { this.title = title; }
public String getTitle() { return title; }
public Menu add(MenuItem item) { items.add(item); return this; } // 链式
public void render(String indent) {
System.out.println(indent + title + " ▼");
for (MenuItem item : items) item.render(indent + " ");
}
public void onClick() { render(""); } // 点击菜单 = 展开
}
Menu file = new Menu("File")
.add(new Action("New", () -> System.out.println("new")))
.add(new Action("Open", () -> System.out.println("open")))
.add(new Menu("Recent")
.add(new Action("a.txt", () -> System.out.println("a.txt")))
.add(new Action("b.txt", () -> System.out.println("b.txt"))));
Menu root = new Menu("Menu").add(file);
root.render("");
Menu 里可以放 Action,也可以放 Menu —— 任意嵌套不需要特殊代码。VSCode、IntelliJ、Office 的菜单系统都是这套结构。
实战 3:表达式树
编译器或计算器里,数学表达式天然是树:
interface Expr {
double eval();
}
class Num implements Expr {
private final double value;
public Num(double v) { this.value = v; }
public double eval() { return value; }
}
class BinOp implements Expr {
private final char op;
private final Expr left, right;
public BinOp(char op, Expr left, Expr right) { this.op = op; this.left = left; this.right = right; }
public double eval() {
return switch (op) {
case '+' -> left.eval() + right.eval();
case '-' -> left.eval() - right.eval();
case '*' -> left.eval() * right.eval();
case '/' -> left.eval() / right.eval();
default -> throw new IllegalStateException();
};
}
}
// (3 + 5) * (10 - 4)
Expr e = new BinOp('*',
new BinOp('+', new Num(3), new Num(5)),
new BinOp('-', new Num(10), new Num(4))
);
System.out.println(e.eval()); // 48
Num 是叶子,BinOp 是容器(嵌套两个子 Expr)。eval() 递归求值,客户端完全不感知层次。这就是 LISP、Haskell 操作 AST 时的常见模式。
实战 4:组织架构与权限
interface OrgUnit {
String getName();
int getEmployeeCount();
BigDecimal getTotalSalary();
boolean has(String userId);
}
class Employee implements OrgUnit {
private final String id, name;
private final BigDecimal salary;
// ...
public int getEmployeeCount() { return 1; }
public BigDecimal getTotalSalary() { return salary; }
public boolean has(String userId) { return id.equals(userId); }
}
class Department implements OrgUnit {
private final String name;
private final List<OrgUnit> members = new ArrayList<>(); // 既可以是 Employee 也可以是子部门
public int getEmployeeCount() {
return members.stream().mapToInt(OrgUnit::getEmployeeCount).sum();
}
public BigDecimal getTotalSalary() {
return members.stream().map(OrgUnit::getTotalSalary).reduce(BigDecimal.ZERO, BigDecimal::add);
}
public boolean has(String userId) {
return members.stream().anyMatch(m -> m.has(userId));
}
}
"我能不能访问这个资源" — 给资源挂一个所属部门,然后递归查"用户是不是在这个部门或其子部门里"。组合模式让权限系统的递归判断变得极其干净。
组合 + 迭代器 / 访问者
组合模式经常和这两个搭配:
组合 + 迭代器:统一遍历
// 给 Folder 加一个递归迭代器,叶子先出
class FolderIterator implements Iterator<FsNode> {
private final Deque<Iterator<FsNode>> stack = new ArrayDeque<>();
public FolderIterator(Folder root) { stack.push(root.children().iterator()); }
public boolean hasNext() {
while (!stack.isEmpty() && !stack.peek().hasNext()) stack.pop();
return !stack.isEmpty();
}
public FsNode next() {
FsNode node = stack.peek().next();
if (node instanceof Folder f) stack.push(f.children().iterator());
return node;
}
}
组合 + 访问者:对树上每个节点做事
interface FsVisitor {
void visit(File f);
void visit(Folder f);
}
// 给 FsNode 加 accept 方法
class File implements FsNode { public void accept(FsVisitor v) { v.visit(this); } }
class Folder implements FsNode {
public void accept(FsVisitor v) {
v.visit(this);
for (FsNode child : children) child.accept(v);
}
}
// 一个计算总大小的访问者
class SizeVisitor implements FsVisitor {
long total = 0;
public void visit(File f) { total += f.getSize(); }
public void visit(Folder f) {}
}
SizeVisitor v = new SizeVisitor();
root.accept(v);
System.out.println(v.total);
访问者让"对树做事"和"树的结构"分离 —— 加一种新操作(查找 / 序列化 / 转换格式)只需要写一个新 Visitor,不动 FsNode 类层次。
共享叶子:享元模式与组合结合
组合树里如果有大量重复叶子(同一个文件被链接到多处、同一个字符在文档里出现千万次),叶子可以做成不可变 + 共享(享元模式)。这能把"看似 N 个叶子"变成"实际只有几个原型 + N 个引用"。文档编辑器、游戏地图、CAD 系统都用这种组合 + 享元。
组合的常见坑
坑 1:循环引用导致栈溢出。 如果不小心让 Folder A 包含 Folder B,Folder B 又包含 A,递归 getSize() 立刻爆栈。add 时必须检测环(用 IdentityHashMap 记录已访问的节点)。
坑 2:深度过大栈溢出。 极深(几万层)的递归会撑爆 JVM 栈。改用显式栈迭代:
public long iterativeSize() {
long total = 0;
Deque<FsNode> stack = new ArrayDeque<>();
stack.push(this);
while (!stack.isEmpty()) {
FsNode n = stack.pop();
if (n instanceof File f) total += f.getSize();
else if (n instanceof Folder f) f.getChildren().forEach(stack::push);
}
return total;
}
坑 3:父节点引用维护忘记同步。 子节点要不要知道自己的父节点?知道的话方便上溯,但 add 时必须设置 parent,remove 时必须清空 parent —— 否则容易内存泄漏(被删的子树还引用着旧父节点)或孤儿(parent 是错的)。要么彻底单向,要么严格维护双向。
坑 4:性能黑洞。 getSize 每次都递归整个子树,N 个节点 + M 次调用 = O(N×M)。常见优化:在 Folder 里缓存 size,子节点变更时 invalidate;或者完全只读时一次性算完缓存。
什么时候不用组合
- 结构不是真的树:平面列表用普通集合就够,别硬套。
- 叶子和容器的操作差异巨大:强行用统一接口会把所有方法都退化到"通用空壳"或"unsupported"。
- 层次永远只有两层(根 + 直接子):简单的"父-子"对象模型更直观,不需要组合。
写在最后
组合模式的本质是"用接口抹平层次差异",让客户端写出 node.getSize() 这种代码时不用关心 node 是叶子还是分支。这种"统一抽象"是构建任何树形系统的基石 —— 文件系统、UI 组件、表达式求值、组织架构、游戏场景图,只要存在嵌套结构,组合模式就在不远处等着。
下次设计一个对象模型时,如果你发现要写"if 是 X 类就这样,if 是 Y 类就那样"的分支,而 X 和 Y 在概念上是"一个是单个,一个是一群" —— 停下来,把它们抽象到同一个接口下。客户端代码会瞬间清爽,新加节点类型也变成"只动一处"的小事。这是设计模式里少数"几乎没有副作用、几乎全是收益"的好牌。
—— 别看了 · 2026