cv.cm

编码 · 设计模式

组合模式详解与示例

阅读约 6 分钟

简短回答

组合是一种结构型设计模式,它让你把对象组合成树形结构,然后以相同的方式对待单个对象和对象组。叶子(例如文件)和组合体(例如包含文件和其他文件夹的文件夹)实现同一个接口,比如 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() 更安全。

全部编码教程