结构型模式—组合(Composite)模式
定义
将对象组合成树形结构以表示”部分—整体“的层次结构,组合模式使得用户对单个对象和组合对象的使用具有一致性
核心思想
凡是能画成树的"部分—整体"结构,如文件系统,UI控件,组织架构,机器人行为树,都有两类节点:叶子(终止单元)和容器(聚合单元)。朴素做法是让客户端到处if,每增加一种操作就多一批分支。组合模式的承诺是透明性。
类型层:Node(统一接口)◀── File(叶子)/ Directory(容器)
实例层:
project(Directory,根;demo 变量名 root)
├── main.cc File 1200 B
├── src Directory
│ ├── app.cc File 800 B
│ └── util.cc File 500 B
└── README.md File 90 B project.size() = 2590 B
树外单文件(对照用):notes.txt File 7 B demo 变量名 standalone
void report(const Node& node) { // 客户端只认识 Node
printf("%s -> %ld B\n", node.name().c_str(), node.size());
}
report(root); // 整棵树 2590 B
report(standalone);
report(root)打印的值是怎么得到的?
① 目录的"大小"是个递归定义(子树之和):
project.size() = main.cc.size() + src.size() + README.size()
= 1200 + 1300 + 90 = 2590
src.size() = app.cc.size() + util.cc.size() = 800 + 500 = 1300
② 谁来执行
long Directory::size() const { // 算法住在这,全工程只此一份
long total = 0;
for (const auto& child : children_) {
total += child->size(); // 虚调用:File 返回自己的字节,
} // Directory 再递归一层
return total;
}
一次 node.size() 自动沿递归展开整棵树——没有任何客户端写过一行遍历代码。
③ 算法写在谁那——树自己会算账,客户端只要开口问:
| 算法位置 | 后果 | |
|---|---|---|
| 长在结构里(现状) | Directory::size() 一处 | 任何拿到 Node& 的人开口问就有正确答案——任意深度都对 |
| 长在客户端(反面) | 每个想知道总量的地方 | 每个客户端自带"遍历 + 类型判断 + 累加"——N 个客户端 N 份算法 |
这一机理补全了透明性的实质:接口统一(都有 size())只是形式,容器在接口背后自动完成聚合才是 report(root) 能打出 2590 的
原因。会自动聚合的不止 size——行为树聚合 tick 状态(Sequence 递归短路)、场景图聚合变换矩阵(父 world × 子 local)、DOM 聚合文本(textContent 递归拼接):同一台"节点自己会算账"的机器,换一种算的东西(见后文"聚合语义"章节)——这就是"共同骨架"。
问题本质
表象是客户端遍布 dyanmic_cast + if/else,痛点是N个操作xM种节点=NxM处分支散落各客户端,新增一种节点类型要改每一个做过类型判断的客户端,如果是嵌套结构还得层层重复这套判断。
// 客户端①:算总大小
long total_size(FsItem& item) {
if (auto* f = dynamic_cast<File*>(&item)) {
return f->byte_count(); // 分支:是文件
}
if (auto* d = dynamic_cast<Directory*>(&item)) {
long total = 0;
for (auto& c : d->items()) {
total += total_size(c); // 嵌套:递归也得自己写
}
return total; // 分支:是目录
}
return 0; // ← 加 Symlink 那天忘了在这里补分支:
} // 编译照样通过,Symlink 被静默计成 0
// 客户端②:备份——同一套判断、同一个递归,再抄一遍
void backup(FsItem& item) {
if (auto* f = dynamic_cast<File*>(&item)) {
copy_bytes(*f);
return;
}
if (auto* d = dynamic_cast<Directory*>(&item)) {
mkdir_for(*d);
for (auto& c : d->items()) {
backup(c);
}
}
}
// 客户端③ 搜索:同理再抄一遍…… N 个操作 = N 份
这里暴露出三个问题:
- 分支散落
- 递归需要客户端实现
- 容易漏改
类图:

- N=3 个客户端操作:算总大小 / 备份 / 搜索;M=2 种节点,没有 Composite 就是 N×M 条边
- . 分支散落:同一个“是文件还是目录”的判断,在三个类中各写一遍
- 再加一种M,客户端必须要重新认识这个类型,所以变成了3✖3
改为Composite:

- 客户端的6条边全部收敛为3条,全部指向唯一抽象Node,判断消失
- 新增Symlink后同File一样新增一条继承边,三条客户端的使用不变
三个需求汇总到一起,形成了Composite最大的用途:
- 客户端统一对待叶子和容器;
- 容器要能递归嵌套任意深
- . 聚合语义(大小,文本,变换,tick状态)要沿着树层层汇总
什么是聚合?**一句话定义:聚合语义 = "整体的值如何由它的部分的值合并出来"的规则——它是"部分-整体"结构中"整体"一词的定义本身。**目录的"大小"是什么意思?“它包含的一切的总和”——这句话就是聚合语义。它不是实现细节,而是领域概念。
实现
类图

角色
| 角色 | 本例对应 | 职责 |
|---|---|---|
| 抽象组件 Component | Node(size() / print()) | 叶与容器的统一接口 |
| 叶子 Leaf | File | 实现操作,无子节点 |
| 复合 Composite | Directory | 持有子组件集合,操作=遍历/委托 |
| 客户 Client | report() | 只见 Node,不问叶与容器 |
类关系
| 类关系 | 类型 | C++ 落地 |
|---|---|---|
File → Node | 泛化(is-a) | 公有继承 |
Directory → Node | 泛化(is-a) | 公有继承 |
Directory → Node(子们) | 组合(has-a,独占) | vector<unique_ptr<Node>> |
标志性特征是那条递归边:Directory 对 Node 既是子类又是容器——既继承它、又持有它。这条"自相似"边让树可以任意深(目录套目录套文件),也让 Directory::size() 里那句 child->size() 成为一次虚调用,落到的可能是叶子、也可能是下一层复合——递归 × 虚派发= 树的全部动态性。对照 [[bridge]]:那是两条链一条边;这里是一条链一条自环边。
DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。
更多推荐


所有评论(0)