前言组合模式Composite Pattern解决的问题是如何让单个对象和由若干对象组成的容器对外表现出一致的接口使客户端不必写if (是容器) ... else ...。典型场景是文件系统、界面控件树、表达式语法树、组织架构——它们的共同点是部分与整体具有相同的操作语义一个文件有大小一个目录的大小等于所有子项之和一个按钮能绘制一个面板也能绘制。常见的误解有两个。一是把它和继承一个基类划等号只是抽个基类、把子类塞进vector还不叫组合模式关键在于递归结构容器本身也实现同一接口并且容器可以包含容器。二是以为容器必须提供add/remove于是把这两个操作塞进基类结果叶子节点被迫实现一个没有意义的方法。这正是透明组合与安全组合的取舍点。本文用文件系统作为例子说明两种组织方式的差异、所有权怎么选unique_ptr还是shared_ptr、递归析构的深度风险以及遍历时修改子节点这类边界。一、结构一个接口两种节点组合模式有三个角色Component抽象构件声明所有节点共有的操作例如size()、print()。Leaf叶子构件没有子节点直接实现操作。Composite容器构件持有Component的集合实现操作时递归转发给每个子节点。关键点是Composite自己也继承Component所以它可以被放进另一个Composite的子节点集合里形成任意深度的树。客户端只持有Component或Component*对整棵树和单个叶子调用同样的方法。// 适用于 C17#include iostream#include memory#include string#include vectorclass Component {public:virtual ~Component() default; // 多态基类析构必须为虚virtual void print(int indent) const 0;virtual double size() const 0;};class File : public Component {public:File(std::string name, double bytes): name_(std::move(name)), bytes_(bytes) {}void print(int indent) const override {std::cout std::string(static_caststd::size_t(indent), ) name_ ( bytes_ )\n;}double size() const override { return bytes_; }private:std::string name_;double bytes_;};class Directory : public Component {public:explicit Directory(std::string name) : name_(std::move(name)) {}void add(std::unique_ptrComponent child) {children_.push_back(std::move(child));}void print(int indent) const override {std::cout std::string(static_caststd::size_t(indent), ) name_ /\n;for (const auto child : children_) {child-print(indent 2);}}double size() const override {double total 0.0;for (const auto child : children_) total child-size();return total;}private:std::string name_;std::vectorstd::unique_ptrComponent children_;};std::unique_ptrComponent表示目录独占子节点子节点的生命周期不超过父节点父节点析构时自动递归释放。这避免了裸指针下的泄漏与重复释放也让一个节点只能有一个父节点这一约束由类型系统表达出来。二、透明组合与安全组合add/remove放在哪一层是组合模式最常被讨论的设计点。透明组合把add/remove也声明在Component里客户端对任何Component都能调用。好处是完全一致坏处是File必须实现一个无意义的add——要么抛异常要么静默忽略两种都不理想。// 适用于 C17透明组合的写法class Component {public:virtual ~Component() default;virtual void add(std::unique_ptrComponent) {throw std::logic_error(leaf cannot have children);}virtual double size() const 0;};安全组合只在Directory上声明add编译期就禁止对叶子调用。代价是客户端手里只有Component时无法添加子节点必须先dynamic_castDirectory*或从一开始就用Directory。维度透明组合安全组合add位置Component基类仅Composite叶子是否有多余接口有无错误发现时机运行期抛异常或忽略编译期客户端写法统一无需转换需要具体类型或dynamic_cast适用场景客户端确实不区分节点类型构建阶段与使用阶段分离工程上更常见的是折中Component只放查询类操作size、print、accept构建类操作add、remove只放在容器上构建完成后客户端统一以Component使用。这样既不污染叶子接口也不需要在遍历时做类型判断。三、递归、所有权与析构深度组合模式的遍历天然是递归的。递归写法简洁但有两个现实约束。第一是析构深度。Directory的children_是vectorunique_ptrComponentDirectory析构时依次销毁每个unique_ptr如果子节点还是Directory就会继续递归。树的深度等于调用栈深度极端深例如链式的几千层时有栈溢出的风险。普通目录树不会这么深但如果数据来自外部且不限制深度解析阶段就应该设上限。第二是共享与循环。如果改用std::shared_ptrComponent父子互指会形成循环引用引用计数永远不归零内存泄漏需要用std::weak_ptr指向父节点来打破环。用unique_ptr时反向指针只能是非拥有裸指针或引用且必须保证父节点生命周期覆盖子节点。第三是遍历时修改。在print()或size()里遍历children_如果某个重写里又调用了addvector可能重新分配正在使用的迭代器与引用全部失效属于未定义行为。约定遍历期间不修改结构或者先复制一份指针列表再遍历。四、一个完整的使用示例// 适用于 C17#include memory#include utilityint main() {auto root std::make_uniqueDirectory(root);auto src std::make_uniqueDirectory(src);src-add(std::make_uniqueFile(main.cpp, 1200.0));src-add(std::make_uniqueFile(util.cpp, 800.5));auto docs std::make_uniqueDirectory(docs);docs-add(std::make_uniqueFile(readme.md, 300.0));root-add(std::move(src));root-add(std::move(docs));root-add(std::make_uniqueFile(LICENSE, 100.0));root-print(0); // 递归打印整棵树double total root-size(); // 递归求和Component c *root; // 以统一接口使用double again c.size();return (total again) ? 0 : 1;}这段代码里没有任何如果是目录则……否则……的分支客户端只调用size()。新增一种节点类型比如符号链接时只要实现Component接口遍历代码一行都不用改这就是组合模式带来的扩展点。需要更复杂的遍历时例如按层遍历、过滤、提前终止可以在Component上加一个accept(Visitor)把遍历与操作分离也可以提供一个迭代器适配器把树展开成前序序列。这两种做法都不改变节点结构只是在接口层扩展。常见坑点1. 让叶子实现无意义的add// ❌ 叶子被迫实现只能抛异常客户端防不胜防void File::add(std::unique_ptrComponent) override {throw std::logic_error(not supported);}// ✅ 构建接口只放在容器上基类只保留查询操作class Directory { void add(std::unique_ptrComponent c); };2. 用裸指针管理子节点// ❌ 谁来 delete漏删泄漏删两次是未定义行为std::vectorComponent* children_;// ✅ 独占所有权交给智能指针std::vectorstd::unique_ptrComponent children_;3. 父子互指用shared_ptr// ❌ 循环引用引用计数永不归零std::shared_ptrComponent parent;// ✅ 反向指针用 weak_ptr或裸指针/引用并保证生命周期std::weak_ptrComponent parent;4. 在遍历过程中增删子节点// ❌ 可能触发 vector 重新分配迭代器失效for (const auto c : children_) { if (c-size() 0) children_.push_back(...); }// ✅ 先收集遍历结束后再修改std::vectorstd::unique_ptrComponent pending;for (const auto c : children_) { if (c-size() 0) pending.push_back(...); }for (auto p : pending) children_.push_back(std::move(p));5. 基类析构不是虚函数// ❌ 通过 Component* 删除派生对象是未定义行为class Component { ~Component() default; };// ✅ 虚析构class Component { virtual ~Component() default; };6. 忘记把容器自身也继承自 Component// ❌ 目录不能放进目录树退化成两层class Directory { std::vectorstd::unique_ptrFile files_; };// ✅ 容器也是构件class Directory : public Component { std::vectorstd::unique_ptrComponent children_; };7. 递归没有深度上限// ❌ 外部数据构造出极深的链递归析构/遍历可能耗尽栈void build(int depth) { /* 无限制递归 */ }// ✅ 解析时限制深度超出即拒绝if (depth kMaxDepth) throw std::runtime_error(tree too deep);总结要点做法统一接口叶子与容器共同继承Component递归结构容器持有vectorunique_ptrComponent构建接口只放在容器上安全组合避免污染叶子所有权独占用unique_ptr共享才用shared_ptr反向引用weak_ptr或非拥有指针防循环遍历修改先收集再改避免迭代器失效组合模式的本质是用树表达部分—整体并让递归发生在结构内部而不是客户端代码里。判断是否该用它看两点就够了对象是否天然成树客户端是否希望对单个对象和整棵树写同一段代码。两点都成立时它能消掉大量类型分支只要有一点不成立硬套只会增加一层没有必要的抽象。