
⭐️在这个怀疑的年代,我们依然需要信仰。
个人主页 :YYYing.
⭐️设计模式系列专栏:设计模式系列
系列上期内容:【设计模式系列 (七) 】桥接模式
系列下期内容:暂无
目录
[1. 概述](#1. 概述)
[2. 结构](#2. 结构)
[3. 仓库实现:拼句子(composite/)](#3. 仓库实现:拼句子(composite/))
[3.1 场景](#3.1 场景)
[3.2 Component:抽象构件](#3.2 Component:抽象构件)
[3.3 Leaf:叶子构件](#3.3 Leaf:叶子构件)
[3.4 Composite:两个容器构件](#3.4 Composite:两个容器构件)
[3.5 Client:统一对待](#3.5 Client:统一对待)
[3.6 C++ 实现要点:递归析构](#3.6 C++ 实现要点:递归析构)
[4. 两种实现方式:透明 vs 安全](#4. 两种实现方式:透明 vs 安全)
[4.1 透明组合(Transparent)](#4.1 透明组合(Transparent))
[4.2 安全组合(Safe)](#4.2 安全组合(Safe))
[4.3 选择](#4.3 选择)
[5. 优缺点与适用环境](#5. 优缺点与适用环境)
[6. 面试专题](#6. 面试专题)
[6.1 开场题:说说组合模式吧](#6.1 开场题:说说组合模式吧)
[6.2 必问:透明组合 vs 安全组合](#6.2 必问:透明组合 vs 安全组合)
[6.3 必问:组合 vs 装饰器](#6.3 必问:组合 vs 装饰器)
[6.4 高频追问清单](#6.4 高频追问清单)
文件夹里可以有文件,也可以有子文件夹,子文件夹里还能再放文件......但你右键"删除"时,从来没想过眼前这个图标到底是文件还是文件夹------系统的处理方式是一样的。
组合模式(Composite Pattern)就是把这种"树形结构 + 统一对待"用面向对象的方式表达出来。别名很直白:部分-整体模式(Part-Whole)。
本文代码取自 design-pattern-cpp 仓库的 design-pattern/composite/,使用 C++11。为阅读方便,片段省略了头文件保护宏。
1. 概述
Wikipedia :The composite pattern describes that a group of objects is to be treated in the same way as a single instance of an object. The intent of a composite is to "compose" objects into tree structures to represent part-whole hierarchies. Implementing the composite pattern lets clients treat individual objects and compositions uniformly.
组合模式描述了在对待一组对象实例的时候,使用以单个对象实例相同的方式对待。 组合的目的是将对象"组合"成树形结构,以表示部分-整体层次结构。 通过实现组合模式,客户端可以统一对待各个对象与组合。
GoF:将对象组合成树形结构以表示"部分-整体"的层次结构,使得客户端对单个对象和组合对象的使用具有一致性。
两个关键词:
① 树形结构
组合模式解决的场景,本质上都是树:文件系统的目录、UI 的控件树、公司的组织架构、菜单、公文流转、表达式语法树。凡是"整体里能装部分、部分里还能再装部分"的地方,都是它的地盘。
② 统一对待(一致性)
这是组合模式真正的价值。 客户端拿着抽象构件就能工作,不必写 if (是容器) {...} else {...},也不必递归地写类型判断。递归的复杂度被封装在抽象构件自己内部。
组合模式属于结构型模式。
2. 结构
| 角色 | 职责 |
|---|---|
| Component(抽象构件) | 为叶子和容器声明共同的接口,通常也包含管理子构件的方法(增加、删除、获取)和公共行为的默认实现 |
| Leaf(叶子构件) | 没有子节点,实现抽象构件中定义的行为;对管理子构件的方法,一般给空实现或抛异常 |
| Composite(容器构件) | 含子节点,用一个集合存储子节点 (子节点可以是叶子也可以是容器),业务方法里递归调用子节点的业务方法 |

关键点:Composite 与 Component 之间是聚合关系,且 Composite 的子节点类型也是 Component------这条自引用把递归封闭起来了,树想长多深就长多深。
3. 仓库实现:拼句子(composite/)
3.1 场景
每个句子由单词组成,单词由字母组成;每个对象都是"可打印的",而且打印时前后要加点东西------句子以句号/换行结尾,单词前面总是有个空格。
翻译成角色:Letter 是叶子,Words 和 Sentence 都是容器(容器的子节点既可以是字母,也可以是单词),LetterComposite 是抽象构件。

3.2 Component:抽象构件
cpp
namespace comp
{
class LetterComposite
{
protected:
std::vector<LetterComposite*> children; // ★ 自引用集合
virtual void printBefore() {} // 钩子,默认空
virtual void printAfter() {} // 钩子,默认空
public:
void addChild(LetterComposite* comp) {
children.push_back(comp);
}
size_t count() {
return children.size();
}
void print() { // ★ 模板方法
printBefore();
for (auto child : children) {
child->print(); // ★ 递归
}
printAfter();
}
virtual ~LetterComposite() {
for (auto child : children) {
delete child; // ★ 递归析构
}
children.clear();
}
};
}
这里有三处值得留意:
-
print()是模板方法 :流程(前 → 递归子节点 → 后)在抽象构件里固定,差异点通过printBefore/printAfter两个钩子交给子类。叶子覆盖printBefore打印自己,单词覆盖printBefore打印空格,句子覆盖printAfter打印换行。 -
children和addChild定义在抽象构件上:写死的是"透明组合",见 4.1。 -
析构函数是虚的且递归删除子节点:这是 C++ 里组合模式的内存关键,见 3.6。
3.3 Leaf:叶子构件
cpp
class Letter : public LetterComposite
{
private:
char character;
public:
Letter(char c) { this->character = c; }
protected:
void printBefore() override
{
cout << character; // 叶子把"打印自己"挂到钩子上
}
};
Letter 没有子节点,children 永远是空的------它的递归 print() 自然就退化成"打印一个字符"。
3.4 Composite:两个容器构件
cpp
class Words : public LetterComposite
{
public:
template<typename ... Rest>
Words(Rest ... c) {
// 展开可变参
std::initializer_list<int>{([&] {
auto letter = new Letter(c);
this->addChild(letter);
}(), 0)...};
}
protected:
void printBefore() override
{
cout << " "; // 单词前面总是有一个空格
}
};
class Sentence : public LetterComposite
{
public:
template<typename ... Rest>
Sentence(Rest ... words) {
initializer_list<int>{(this->addChild(words), 0)...};
}
protected:
void printAfter() override
{
cout << endl; // 句子以换行结尾
}
};
两个容器都用可变参数模板 收参,再用初始化列表展开技巧 把每个参数变成子节点。initializer_list<int>{ f(x)..., 0 } 配合逗号表达式,是 C++11 里做参数包展开的常用写法(C++17 起可以直接用折叠表达式 (f(x), ...))。
Sentence 收到的是 Words*,Words 收到的是 char------但两者都把结果 addChild 进自己的 children,因为**LetterComposite* 这个抽象类型把差异抹平了**。
3.5 Client:统一对待
cpp
int main()
{
Messenger msg;
auto ddg = msg.messageFromDdg(); // 是个 Sentence*
auto awei = msg.messageFromAwei();
// ★ 往"句子"里直接塞一个单词、再塞两个字母
ddg->addChild(new Words('h', 'e', 'l', 'l', 'o'));
ddg->addChild(new Letter('c'));
ddg->addChild(new Letter('c'));
ddg->print(); // 客户端只管调 print,不关心对象是叶子还是容器
awei->print();
delete ddg;
delete awei;
return 0;
}
注意 main 里那三行 addChild :往一个句子(容器)里塞字母(叶子)和单词(容器)的语法完全一样,编译器也不区分。这就是"一致性"落到代码上的样子。
Messenger 里的句子是这么拼出来的:
cpp
LetterComposite* messageFromAwei()
{
// What's the hurry ? I'm not up yet .
return new Sentence(
new Words('W', 'h', 'a', 't'),
new Words('i', 's'),
// ...
new Words('.')
);
}
一个 Sentence 挂着若干 Words,每个 Words 挂着若干 Letter------树就这么长出来了。
3.6 C++ 实现要点:递归析构
组合模式在 C++ 里最容易踩的坑是内存泄漏 :容器的 children 里存的是裸指针,delete sentence 只析构 Sentence 自己,子节点全漏。
仓库的解法是在抽象构件的析构里递归删:
cpp
virtual ~LetterComposite() {
for (auto child : children) {
delete child; // 叶子也没关系,children 是空的
}
children.clear();
}
三个要点:
-
虚析构必须有 ,否则
delete一个LetterComposite*指向的Sentence是未定义行为; -
递归链自洽 :
delete child又会触发 child 的析构,一路删到叶子为止; -
所有权唯一 :子节点由父节点独占并负责释放,所以 3.5 的
main里只需要delete ddg和delete awei,子节点不能 再单独 delete,也不能有第二个指针指向它------否则就是双重释放。现代 C++ 改写成std::vector<std::shared_ptr<LetterComposite>>或unique_ptr能免掉这段手写代码,但要小心父子互相引用导致谁都释放不掉 的循环引用问题(用weak_ptr破环)。
4. 两种实现方式:透明 vs 安全
这是组合模式最常考的一个点,仓库用的是前者。
4.1 透明组合(Transparent)
管理子构件的方法(addChild / remove / getChild)声明在抽象构件 Component 里,叶子和容器拥有完全一样的接口。
-
✅ 优点 :客户端完全透明 ,拿到
Component*就能无差别调用,不用做任何类型判断; -
❌ 缺点 :不够安全 ------
Letter这种叶子也能调addChild,编译期拦不住,要么给空实现(调用被静默忽略,出 bug 难查),要么在运行时抛异常。
仓库里 LetterComposite 把 addChild 放在基类,Letter 继承后就带着一个语义上无意义的 addChild------这正是透明组合的典型代价。3.5 中 main 敢直接往 Sentence 上加节点,靠的也是这个统一接口。
4.2 安全组合(Safe)
管理子构件的方法只声明在 Composite 里,Component 和 Leaf 都没有。
-
✅ 优点 :安全 ,叶子根本调不到
addChild,编译期就能查错; -
❌ 缺点 :不透明------客户端想加子节点,就得知道手里的对象是容器还是叶子,必须做类型判断/强制转换,直接违背了"统一对待"的初衷。
4.3 选择
| 透明组合 | 安全组合 | |
|---|---|---|
| 管理子构件的接口在哪 | Component(抽象构件) | Composite(仅容器) |
| 客户端是否需要类型判断 | 不需要 | 需要 |
编译期能否拦住叶子的 addChild |
不能 | 能 |
| 代价 | 叶子带无用方法 | 破坏一致性,客户端依赖具体类型 |
结论:优先透明组合。 组合模式的卖点就是"统一对待",安全组合把这个卖点丢了,换来的那点类型安全在多数场景下不划算。
5. 优缺点与适用环境
主要优点
-
清楚地定义分层次的复杂对象,让客户端忽略层次差异,方便对整个层次结构进行控制;
-
客户端一致地使用组合结构或单个对象,不必关心处理的是单个对象还是整个组合结构,简化了客户端代码;
-
增加新的容器构件和叶子构件都很方便,无须修改现有类库,符合开闭原则;
-
为树形结构提供了灵活的解决方案:递归组合能造出任意复杂的树,而对树的控制极其简单。
主要缺点
-
增加新构件时很难对容器中的构件类型进行限制 。比如希望某个文件夹里只能放文本文件,由于叶子和容器来自同一个抽象层,类型系统拦不住,只能在运行时做类型检查,实现复杂且容易漏;
-
抽象构件的接口如果太泛(例如把所有子类的方法都往基类堆),会让设计过于抽象、违背接口隔离;透明组合的"叶子有无用方法"就是它的一种表现;
-
在 C++ 里需要自己处理递归析构与所有权,裸指针方案容易出内存问题。
适用环境
-
需要实现树状对象结构------组合模式提供了"简单叶节点"和"复杂容器"两种共享公共接口的元素类型,容器中可嵌套叶节点和其他容器,从而构建树状嵌套的递归结构;
-
希望客户端代码以相同方式处理简单和复杂元素------所有元素共用同一接口,客户端不必在意具体类。
6. 面试专题
6.1 开场题:说说组合模式吧
概念 :组合模式将对象组合成树形结构 以表示"部分-整体"的层次结构,使客户端对单个对象和组合对象的使用具有一致性。别名部分-整体模式,属于结构型模式。
角色 :抽象构件 Component 为叶子和容器声明共同接口;叶子构件 Leaf 无子节点;容器构件 Composite 用集合存储子节点,在业务方法里递归调用子节点的方法。
关键 :容器与抽象构件之间是聚合关系,且子节点类型也是抽象构件------这条自引用让树可以无限延伸。
适用场景:需要表示对象的"部分-整体"层次;希望客户端忽略层次差异、统一处理单个对象和组合对象。
可扩展:接上"透明组合 vs 安全组合"(见 6.2)。
6.2 必问:透明组合 vs 安全组合
见第 4 节。浓缩成三句话:
接口位置不同 :透明组合把
add/remove放在抽象构件里,安全组合只放在容器构件里。代价不同 :透明组合牺牲类型安全(叶子也能调
add,但调用没意义),换来客户端无需判断类型;安全组合牺牲一致性(客户端必须知道手里是容器还是叶子),换来编译期安全。实践中首选透明组合,因为"统一对待"正是这个模式存在的理由。
6.3 必问:组合 vs 装饰器
两个模式都靠递归组合 + 共享同一抽象接口 ,新手很容易混。区别在子节点的数量和目的:
| 组合 Composite | 装饰器 Decorator | |
|---|---|---|
| 目的 | 表示部分-整体的树形层次 | 动态给对象添加职责 |
| 子节点个数 | 多个(一个容器挂 N 个子节点) | 通常一个(层层包裹,像洋葱) |
| 结构形状 | 树 | 链 |
| 是否改变行为 | 只在聚合层面组织,叶子/容器的行为各自独立 | 在转发前后增加行为 |
| 客户端视角 | 看到的是"一棵树" | 看到的是一个"功能叠加后的对象" |
一句话记忆:组合是"一对多、搭树",装饰器是"一对一、套娃"。
6.4 高频追问清单
| 追问 | 答法 |
|---|---|
| 组合模式属于哪一类? | 结构型模式,别名部分-整体(Part-Whole)模式 |
| 组合模式的关键是什么? | 定义了一个抽象构件类,它既能代表叶子又能代表容器,客户端针对它编程,无须知道具体是哪种 |
| 容器里的子节点能是什么? | 叶子和容器都行------正因如此才能递归形成树 |
| 容器与抽象构件是什么关系? | 聚合关系(aggregation / "has-a"),且是自引用,这是递归的基础 |
| 递归调用在哪里? | 在容器构件 的业务方法里,遍历 children 逐个调用子节点的同名方法 |
| 组合模式的缺点? | 难以对容器中的子构件类型做编译期约束,只能运行时判断;透明组合下叶子会带上无意义的方法;C++ 里要自行处理递归析构 |
| 什么时候不该用? | 层次结构不是树、或者客户端本来就需要区别对待叶子与容器时,硬套只会多一层无用的抽象 |
| 组合和继承怎么选? | 继承表达"is-a"且是静态的;组合表达"part-whole"且能在运行时动态增删子节点。树形结构天然适合组合 |
| 树很深会有什么问题? | 递归调用会消耗栈空间,深度过大可能栈溢出;另外反复的虚函数分派有性能开销。极端深度场景要考虑改成显式栈的迭代实现 |
| 框架里哪里见过组合? | 文件系统、GUI 控件树、DOM 节点、组织架构与公文流转、菜单、表达式/语法树解析、java.awt.Container、前端组件树 |
结语
我是YYYing,后面还有更精彩的内容,希望各位能多多关注支持一下主包。
无限进步,我们下次再见!