目录
组合模式的定义
组合模式(Composite)的 GoF 定义是:将对象组合成树形结构以表示”部分-整体”的层次结构。Composite 使得用户对单个对象和组合对象的使用具有一致性。
定义里的”部分”和”整体”,对应到具体结构就是两类节点:不能再往下拆的是叶子节点(比如一个文件),可以继续容纳其他节点的是容器节点(比如一个目录)。这两类节点要能被统一对待,前提是它们实现同一个接口。
这种层次的一个直观例子是文件系统。一个目录里可以放文件,也可以放子目录,子目录里还能继续嵌套。要算一个目录的总大小,朴素做法是先判断每个条目是文件还是文件夹再分别处理;组合模式把这层判断消化在节点内部:直接对每个条目调 getSize(),是文件就返回自身大小,是目录就递归往下算。
组合模式的实现思路是把”叶子”和”容器”统一成一个抽象:两者都实现 Component 接口,容器内部持有一组 Component 指针,操作时递归向下转发。客户端只对着 Component 接口编程,具体如何计算交给节点自身,调用方不需要在调用前关心节点的具体层级和类型。
组合模式用于解决什么问题
如果不用组合模式,处理树形结构时就得对每个节点做类型判断。
比如一个文件系统,有 File 和 Directory 两种节点。
要计算总大小,朴素写法是这样:
class File {
public:
int size;
int getSize() { return size; }
};
class Directory {
public:
std::vector<File*> files;
std::vector<Directory*> subdirs;
int getSize() {
int total = 0;
for (auto* f : files) total += f->getSize();
for (auto* d : subdirs) total += d->getSize();
return total;
}
};
这里 Directory 必须维护两个列表:一个放文件、一个放子目录。每加一种新的节点类型(比如快捷方式 SymbolicLink),Directory 里就得加一个新列表,getSize() 里也得加一段循环。
更严重的是,客户端拿到一个节点后,如果不知道它是 File 还是 Directory,就得先做类型判断再决定怎么调用。每出现一个新的节点类型,所有遍历树的地方都得改。
组合模式则把 File 和 Directory 统一成 Component 接口。Directory 只持有一个 vector<Component*> 列表,不管里面装的是文件还是子目录,遍历时统一调用 getSize()。新增节点类型只要实现 Component 接口,Directory 和客户端代码零改动。
两种实现方式的对比如下。
| 节点类型数 | 朴素写法 | 组合模式 |
|---|---|---|
| 2 | 2 个列表 + 类型判断 | Component 接口 + 1 个统一列表 |
| 3 | 3 个列表 + 新增类型判断 | 1 个统一列表,零改动 |
| N(动态组合) | 每加一种类型改所有遍历处 | 实现 Component 即可接入 |
节点类型越多、层级越深,组合模式的价值越大。
什么时候不该用组合模式
组合模式解决的是”部分-整体的树形结构”问题,前提是数据本身有这种层次。如果数据没有天然的层次、或者只是同构的扁平集合,上组合模式反而增加了一层间接,收益却不明显。
典型的反例是普通的一维集合:比如一组配置项、一批用户信息,天然就是扁平的列表,用 std::vector 加标准算法遍历处理就够了,没必要给每个元素套一个 Component 基类再塞进树里。
另一个点是子节点类型的约束。组合模式对”容器里能放什么”几乎没有限制,只要实现 Component 接口就能进 children 列表。如果你需要”这个目录只能放文件、不能放子目录”这类强约束,用组合模式会比较别扭,得在 add() 里手动做类型检查,而类型检查恰恰削弱了组合模式”统一对待”的优势。
简单说,数据呈层次、需要客户端统一操作、类型还可能扩展,这三点同时成立时,组合模式才比较合适。
组合模式 UML 类图
组合模式涉及三个角色:Component(统一接口)、Leaf(叶子节点)、Composite(容器节点)。
Composite 内部持有一个 Component 列表,列表里的元素既可以是 Leaf 也可以是另一个 Composite,这就构成了递归的树形结构。
flowchart BT
Component["Component<br/>---<br/><<interface>> Component<br/>---<br/>+getSize() : int<br/>+getName() : string<br/>+display(int) : void"]
File["File<br/>---<br/>Leaf<br/>-name : string<br/>-size : int<br/>---<br/>+getSize() : int<br/>+getName() : string<br/>+display(int) : void"]
Directory["Directory<br/>---<br/>Composite<br/>-name : string<br/>-children : vector<Component*><br/>---<br/>+getSize() : int<br/>+getName() : string<br/>+display(int) : void<br/>+add(Component*) : void<br/>+remove(string) : void"]
File -- 继承 --> Component
Directory -- 继承 --> Component
Directory -. 持有Component* .-> Component
style Component fill:#dbeafe,stroke:#1e40af,stroke-width:2px
style File fill:#fef3c7,stroke:#92400e
style Directory fill:#fef3c7,stroke:#92400e
UML 对象交互图:工作流程
以”客户端对根目录调用 getSize()“为例,看一次递归调用是怎么穿过树的。
sequenceDiagram
participant C as 客户端 Client
participant D1 as Directory(根目录)
participant F1 as File(readme.md)
participant D2 as Directory(子目录 src)
participant F2 as File(main.cpp)
C->>D1: getSize()
Note over D1: 遍历 children 列表
D1->>F1: getSize()
F1-->>D1: return 200
D1->>D2: getSize()
Note over D2: 遍历 children 列表
D2->>F2: getSize()
F2-->>D2: return 300
D2-->>D1: return 300
D1-->>C: return 500
从时序图可以看到,调用从客户端的 getSize() 进入根目录,之后根目录不关心每个子节点是什么类型,一律遍历 children 往下递归。叶子节点把自身大小直接返回,容器节点继续向下展开,直到触到叶子。整个过程中客户端只发起了那一次调用。时序图是简化示意,只画了两条分支,实际 main 算出的是 700 bytes,见下文伪代码。
伪代码:将 UML 类转变为实现
接下来把图里的 Component、File、Directory 三个角色依次落地。
以下代码为说明原理的示意性伪代码,省略头文件与 include guard 等内容。
先定义 Component 接口,Leaf 和 Composite 都实现它:
class Component {
public:
virtual ~Component() = default;
virtual int getSize() const = 0;
virtual std::string getName() const = 0;
virtual void display(int depth = 0) const = 0;
};
File 是 Leaf,没有子节点,直接返回自己的数据:
class File : public Component {
std::string name;
int size;
public:
File(std::string n, int s) : name(std::move(n)), size(s) {}
int getSize() const override {
return size; // 叶子节点直接返回
}
std::string getName() const override { return name; }
void display(int depth = 0) const override {
std::string indent(static_cast<size_t>(depth) * 2, ' ');
std::cout << indent << "[F] " << name
<< " (" << size << " bytes)\n";
}
};
Directory 是 Composite,持有子节点列表,操作时递归向下转发:
class Directory : public Component {
std::string name;
std::vector<std::unique_ptr<Component>> children;
public:
explicit Directory(std::string n) : name(std::move(n)) {}
void add(std::unique_ptr<Component> c) {
children.push_back(std::move(c));
}
void remove(const std::string& childName) {
// erase-remove idiom
children.erase(
std::remove_if(children.begin(), children.end(),
[&](const auto& c) { return c->getName() == childName; }),
children.end()
);
}
int getSize() const override {
int total = 0;
for (const auto& c : children) {
total += c->getSize(); // 递归:Leaf 直接返回,Composite 继续往下
}
return total;
}
std::string getName() const override { return name; }
void display(int depth = 0) const override {
std::string indent(static_cast<size_t>(depth) * 2, ' ');
std::cout << indent << "[D] " << name << "/\n";
for (const auto& c : children) {
c->display(depth + 1); // 每个子节点缩进加一层
}
}
};
Client 端组装树并调用:
int main() {
auto root = std::make_unique<Directory>("root");
root->add(std::make_unique<File>("readme.md", 200));
root->add(std::make_unique<File>("config.yaml", 50));
auto src = std::make_unique<Directory>("src");
src->add(std::make_unique<File>("main.cpp", 300));
src->add(std::make_unique<File>("utils.cpp", 150));
root->add(std::move(src));
// 客户端只调一次,整棵树递归完成
std::cout << "Total: " << root->getSize() << " bytes\n";
// 输出: Total: 700 bytes
root->display();
// [D] root/
// [F] readme.md (200 bytes)
// [F] config.yaml (50 bytes)
// [D] src/
// [F] main.cpp (300 bytes)
// [F] utils.cpp (150 bytes)
}
这段代码里,递归的衔接发生在 File 和 Directory 之间:叶子直接返回、容器继续向下,调用方写一行 getSize() 就拿到了整棵树的合计,连 for 循环都不用自己写。
用 std::variant 取代继承层次
如果节点类型是编译期就能确定的封闭集合,C++17 的 std::variant 配合 std::visit 可以替代整套 Component 继承体系。思路是把”文件或目录”这个二选一关系直接表达成一个和类型,而不是一个基类加两个派生类。
// C++17:用 std::variant 表示一个封闭节点集合
struct File {
std::string name;
int size;
};
struct Directory; // 前置声明
using Node = std::variant<File, std::unique_ptr<Directory>>;
struct Directory {
std::string name;
std::vector<Node> children;
};
int getSize(const Node& node) {
return std::visit([](const auto& x) -> int {
using T = std::decay_t<decltype(x)>;
if constexpr (std::is_same_v<T, File>) {
return x.size; // 叶子直接返回
} else {
int total = 0;
for (const auto& c : x->children) // 容器,递归往下
total += getSize(c);
return total;
}
}, node);
}
variant 里放 unique_ptr
这套写法有几个好处。一是省掉了虚函数派发的运行时开销:没有 Component 基类,File 直接以值类型存在,只有 Directory 用指针串起子节点。二是 std::visit 要求访问器能处理 variant 里的每一个 alternative,漏掉一种编译器直接报错,比在 Leaf 里忘了重写某个虚函数更早暴露问题。三是节点的持有方式由你自己定,想要值语义就平铺、想要共享就换 shared_ptr,不必被一个抽象基类绑死。
代价是节点类型变成了封闭集合,编译期就固定了。要加一种新节点(比如符号链接),得改 Node 的定义,再把所有 std::visit 的访问器补上对应分支,涉及的文件都要重新编译。经典 Composite 靠继承开放扩展,运行时加一个新子类不用动旧代码,这是 variant 做不到的。
两种写法怎么选,看你的场景:节点类型稳定、追求省去虚函数开销和更紧凑的内存布局,用 std::variant;节点类型需要运行时动态扩展、或者来自外部插件,经典 Composite 仍然更合适。另外,一旦用 variant 表达树,前面讨论的”add / remove 放在 Component 还是 Composite”之争也就不存在了,因为根本没有 Component 接口,容器节点直接操作自己的 children 向量即可。
透明式 vs 安全式:接口放哪里
组合模式有一个经典的设计分歧:add / remove 这些管理子节点的方法,到底放在 Component 接口里,还是只放在 Composite 里?
透明式 把这些方法提升到 Component 接口:
class Component {
public:
virtual ~Component() = default;
virtual int getSize() const = 0;
virtual void add(std::unique_ptr<Component>) {} // 叶子节点空实现
virtual void remove(const std::string&) {}
};
好处是客户端拿到 Component* 就能直接调 add(),不用先确认手里是 Leaf 还是 Composite。代价是 Leaf 被迫继承一个它用不到的方法,通常实现为空操作或抛异常。这违反了”不要给子类它不需要的方法”的原则,运行时如果对 Leaf 调 add() 不是编译期错误而是运行时静默失败或异常。
安全式 把这些方法只留在 Composite 里:
class Component {
public:
virtual ~Component() = default;
virtual int getSize() const = 0;
virtual void display(int) const = 0;
// 不放 add / remove
};
class Directory : public Component {
public:
void add(std::unique_ptr<Component>);
void remove(const std::string&);
// ...
};
好处是类型安全:编译期就不可能对 Leaf 调 add()。代价是客户端如果只有 Component*,想加子节点就得先 dynamic_cast<Directory*>,使用上多了一步。
GoF 原书倾向透明式,追求”统一接口”的纯粹性。实际 C++ 工程更倾向安全式,因为运行时异常比编译期多一次 cast 更麻烦。选哪种取决于你的树有多深、客户端多久需要动态增删子节点。如果树结构基本静态、只在构建期组装,安全式更合适;如果运行时频繁增删且客户端拿到的是泛化引用,透明式更省事。
认清几个类似模式的边界
组合模式容易和几个结构相似的模式搞混,分清楚边界能避免用错地方。
头号易混点是装饰器模式(Decorator)。两者都要求具体类共享同一个接口,也都在内部持有同类型的引用,但目的不同。组合模式是一对多地组织对象、搭出一棵”部分-整体”的树,关心的是结构;装饰器模式是一对一地包裹单个对象、在外面叠一层职责,关心的是行为。装饰器甚至可以把一个组合节点整个包起来,给那条分支上的所有节点统一加上某种行为。
组合模式也常和其他模式搭档。在树形结构上要做多种互不相干的操作(比如既要算大小、又要序列化、还要做校验),又不想给每个节点类都塞一堆方法时,可以用 访问者模式(Visitor) 把操作外提;宏命令本质上是命令模式(Command) 的组合,用组合结构把多个命令串成一个可整体执行的命令;当叶子节点大量重复、内存吃紧,可以用享元模式(Flyweight) 让多个父节点共享同一个叶子实例。
一张小表区分组合与装饰器:
| 维度 | 组合模式 | 装饰器模式 |
|---|---|---|
| 引用关系 | 一对多,容器持有多个子节点 | 一对一,包裹单个对象 |
| 关注点 | 部分-整体的结构 | 单个对象的行为增强 |
| 形状 | 树形 | 链式(也可能多层包裹) |
| 典型操作 | 递归遍历整棵子树 | 在外层拦截并增强原方法 |
落到工程里要注意的几点
所有权要讲清楚。 本文伪代码用 unique_ptr 让 Composite 独占子节点的所有权,子节点的生命周期跟着容器走,容器析构时子节点自动释放。如果多个 Composite 需要共享同一个子节点,就得用 shared_ptr,但要小心循环引用。另一种选择是 Composite 只存裸指针,由外部管理生命周期,但这样就得保证子节点不会比容器先死。C++ 没有垃圾回收,所有权模型必须在设计阶段定下来。
Component 的析构函数必须声明为虚函数。 否则通过 Component 指针删除 Composite 对象时,Composite 的析构不会被调用,children 列表里的 unique_ptr 不会释放,直接内存泄漏。基类写 virtual ~Component() = default; 就够了。
递归调用栈深度是隐患。 getSize() 和 display() 都是递归的,树有多深调用栈就有多深。大多数文件系统的深度不会成问题,但如果你在处理用户构造的树(比如解析 XML/JSON),恶意输入可以构造出极深的嵌套导致栈溢出。对此可以把递归改成显式栈加循环,牺牲可读性换安全性。
遍历时增删子节点会导致迭代器失效。 如果在遍历 children 的过程中 add 或 remove 了某个子节点,vector 的迭代器就废了,继续遍历是未定义行为。解决办法是遍历前拷贝一份 children 列表,或者遍历时只标记、遍历后统一增删。
小心子树自引用和循环引用。 如果 add() 把关心的节点本身、或者它的某个祖先加进了自己的 children,递归遍历会陷入无限循环,直到栈溢出。构建树时应当保证子节点既不是自身也不是祖先,必要时在 add() 里做自检。另一个隐患是内存环:本文示例用 unique_ptr 让 Composite 独占子节点,关系是单向的,天然不会成环;一旦改用 shared_ptr,若子节点又用 shared_ptr 指回父节点,引用计数就归不了零,两个节点都析构不掉。规避办法是让子节点用 weak_ptr 持有父节点的引用,而不是 shared_ptr,把强引用环断开。
display() 不是组合模式的核心。 本文把 display() 放进 Component 接口是为了演示方便,但组合模式的本质是”统一接口加递归转发”,不局限于展示功能。实际工程中 getSize()、search()、serialize()、validate() 这类需要递归遍历整棵树的操作才是组合模式真正发挥作用的地方。