编码 · 设计模式
组合模式详解与示例
简短回答
组合是一种结构型设计模式,它让你把对象组合成树形结构,然后以相同的方式对待单个对象和对象组。叶子(例如文件)和组合体(例如包含文件和其他文件夹的文件夹)实现同一个接口,比如 size()。组合体通过对其子节点调用该操作来实现它,因此客户端代码不用检查节点的类型,就能处理整棵树。
意图
将对象组合成树形结构以表示“部分—整体”的层次结构。组合使客户端可以一致地对待单个对象和对象的组合。它是《设计模式》(四人组,GoF)一书中七种结构型模式之一。
它要解决的问题
设想一个存储应用,需要显示任何一项内容的总大小。文件有大小。文件夹的大小是其中所有内容之和,而文件夹可以包含文件和其他文件夹,嵌套任意深度。
如果没有统一的接口,每一段遍历这棵树的代码都必须问“这是文件还是文件夹?”并分别处理,递归往往要一遍遍重复地写。增加一种新的节点类型,比如快捷方式或压缩包,就得找出并修改所有这些判断。组合模式把递归放进节点自身。
结构与参与者
| 参与者 | 职责 | 示例中的对应 |
|---|---|---|
| 组件(Component) | 为树中所有对象声明共同的接口。 | FsNode |
| 叶子(Leaf) | 没有子节点的对象;直接实现该操作。 | FileNode |
| 组合体(Composite) | 保存子组件,并通过委托给它们来实现该操作。 | Folder |
| 客户端(Client) | 通过组件接口使用任意节点。 | report |
TypeScript 实现
interface FsNode {
readonly name: string;
size(): number;
print(indent?: string): string[];
}
class FileNode implements FsNode {
constructor(readonly name: string, private bytes: number) {}
size(): number { return this.bytes; }
print(indent = ""): string[] { return [`${indent}${this.name} (${this.bytes})`]; }
}
class Folder implements FsNode {
private children: FsNode[] = [];
constructor(readonly name: string) {}
add(...nodes: FsNode[]): this { this.children.push(...nodes); return this; }
size(): number { return this.children.reduce((sum, c) => sum + c.size(), 0); }
print(indent = ""): string[] {
const lines = [`${indent}${this.name}/ (${this.size()})`];
for (const child of this.children) lines.push(...child.print(indent + " "));
return lines;
}
}
const root = new Folder("project").add(
new FileNode("README.md", 120),
new Folder("src").add(new FileNode("main.ts", 900), new FileNode("util.ts", 300)),
);
// The client treats a single file and a whole tree the same way.
const report = (node: FsNode) => console.log(node.print().join("\n"));
report(root);
// project/ (1320)
// README.md (120)
// src/ (1200)
// main.ts (900)
// util.ts (300)
report(new FileNode("notes.txt", 42)); // notes.txt (42)report 从不检查节点类型。新增一种节点,比如大小报告为零的 Symlink,只需要实现 FsNode。
设计取舍:透明性与安全性
add()、remove() 这类管理子节点的方法应该放在哪里?GoF 一书描述了这里的权衡:
| 选择 | 含义 | 权衡 |
|---|---|---|
| 安全性 | 只有 Composite 拥有 add() 和 remove(),就像上面的示例。 | 类型安全:无法给文件添加子节点。但客户端必须知道自己什么时候拿到的是组合体。 |
| 透明性 | Component 声明 add() 和 remove(),叶子对它们抛出错误或直接忽略。 | 在客户端看来所有节点都一样,但错误只会在运行时才暴露。 |
在 TypeScript 中,安全性方案通常效果更好,因为类型检查器能在编译时发现错误。
何时使用,何时不用
以下情况适合使用组合:
- 你的数据天然就是树:文件和文件夹、容器里的 UI 控件、菜单和子菜单、组织结构图、绘图中的图形和分组。
- 客户端应该可以忽略单个对象与对象组之间的差别。
以下情况应避免使用:
- 结构是扁平的,或者始终只有一层。普通数组更简单。
- 叶子和分组需要的操作差别很大。把它们硬塞进一个接口,会产生对某些节点毫无意义的方法。
- 你需要限制组合体可以持有哪些子节点,比如只允许某些节点类型。统一的接口让这类规则更难在类型系统中强制执行。
实际应用
- DOM:每个元素和文本节点都是一个
Node。元素可以包含其他节点,许多操作,如textContent或cloneNode(true),对单个节点和整棵子树的作用方式是一样的。 - React、Vue 和 Angular 等 UI 组件框架把界面构建成树,其中的组件可以渲染其他组件。
- Java AWT 和 Swing:
Container是Component的子类,并且持有其他组件,所以装满按钮的面板本身也是一个组件。 - 编译器中的抽象语法树、游戏引擎中的场景图,以及嵌套菜单,遵循的都是同样的思路。
相关模式
- 迭代器:遍历组合体的节点,而不暴露子节点是如何存储的。
- 访问者:在不修改节点类的前提下,为组合树添加新的操作(导出、校验、统计)。
- 装饰:同样有组件接口并递归地包装对象,但装饰恰好只有一个子节点并且增加行为,而组合体有多个子节点并合并它们的结果。
- 建造者:常用于一步步构造复杂的组合树。
- 责任链:常常利用组合体的父节点链接,把请求从子节点一路向上传给它的各级父节点,就像浏览器事件沿着 DOM 向上冒泡一样。
常见问题
叶子和组合体有什么区别?
叶子没有子节点,自己完成工作。组合体持有子组件,通常通过对每个子节点调用同一个操作并合并结果来完成工作。
组合模式只适用于树吗?
它是为树形的“部分—整体”层次结构设计的。含有环的一般图需要额外小心,比如记录已访问的节点,以避免无限递归。
组合与装饰有什么区别?
两者都使用共享接口和递归组合。装饰恰好包装一个对象来增加行为,而组合体把许多子节点聚合在一起,当作一个整体来对待。
叶子类上也应该有 add() 吗?
只有在你想要完全透明,并且接受给叶子添加子节点时出现运行时错误的情况下才需要。在有类型的语言中,通常只在组合体上放 add() 更安全。