☰
C++组合模式实战:统一接口优雅处理递归树结构
2026/10/7 4:40:59 网站建设 项目流程

先说个我最近半年的实际感受:但凡你在C++里做编辑器工具、引擎框架,或者哪怕只是写一个配置管理模块,“树形嵌套”的结构几乎是无处不在的。文件夹套文件夹、控件套控件、语法树里的嵌套表达式、组织架构里的部门套小组……这种“部分-整体”的递归关系,如果每次处理都靠if (isDirectory) ... else ...去判断节点类型,代码很快就会被发散的逻辑撕碎。

组合模式(Composite Pattern)就是专门为这类结构准备的。它把单个对象和组合对象统一成同一个抽象接口,让客户代码像操作一个叶子节点一样去操作一棵完整的树。这篇文章我用文件系统和一个表达式求值的例子,把组合模式从设计取舍、完整实现到坑点排错完整过一遍,希望能帮那些已经掌握C++基础、开始涉足设计模式和应用框架的同学少走弯路。

1. 为什么递归树结构的代码越写越丑:组合模式要解决的痛点

1.1 先看一个绕不开的场景:目录与文件

假设你要实现一个简单的文件管理模块,目录里既能装文件,也能装子目录。最直白的做法是先定义两个类:

class File { public: std::string name; size_t size; void display(int indentLevel); }; class Directory { public: std::string name; std::vector<File*> files; std::vector<Directory*> subDirectories; void display(int indentLevel); };

问题很快会出现:在Directory::display里,你得分别遍历files和subDirectories,然后对文件调用文件的显示逻辑,对目录调用目录的显示逻辑。等需求变成“统计总大小”“按名称搜索”“导出树形JSON”时,每一处遍历代码都要写两套分支。再往后,如果加一个“快捷方式”节点,那所有遍历的地方都要跟着改。

这还只是两层的结构。真实项目里的树往往不止两类节点,而且节点之间的行为差异很大,比如有的节点需要加密、有的节点是挂载点、有的节点只是占位符。这种情况下,if-else分支会呈爆炸式增长。我见过最夸张的一段代码,一个遍历函数里有七八层判断,改一个节点类型要牵连十几个文件。

1.2 组合模式的核心思路:让叶子也像树一样说话

组合模式做的事情其实很朴素:抽象出一个Component基类,所有具体节点都继承它。叶子节点(Leaf)和容器节点(Composite)都实现同一个接口,容器内部维护一个子节点列表。客户代码只需要面对Component*,完全不用关心它到底是个文件还是个目录:

void displayAll(Component* node, int level) { node->display(level); }

对调用方来说,树、树枝、树叶都一样能“显示”,一样能“算大小”,一样能“搜索”。节点的内部结构被封装在节点自己的实现里,外部代码变得极度简单。这种“单个对象和组合对象的一致性”正是组合模式的灵魂。

1.3 什么信号出现时,该想起这个模式

我自己判断是否使用组合模式,基本看三个信号:

  • 存在递归的“部分-整体”结构:目录套目录、控件套控件、语法树里的表达式套表达式。
  • 客户代码对叶子节点和容器节点希望一视同仁:比如“渲染场景里的所有物体”,我不关心物体是单独的模型还是一整个模型组。
  • 结构层级可能在运行期动态变化:比如用户运行时创建、删除节点,而不是编译期固定的嵌套关系。

满足其中两条,组合模式基本就是对的方向;一条都不满足,那我建议先别急着上模式,后面第六节我会专门聊什么时候不该硬套。

2. 核心抽象的设计取舍:安全模式与透明模式怎么选

组合模式最容易被新手搞糊涂的地方,不是怎么建树,而是add、remove这类管理子节点的操作到底放在哪个类里。这里有两套经典方案,GoF原书把它们叫“安全模式”和“透明模式”,实际工程里怎么选是个真问题。

2.1 组件基类的接口边界

无论哪种模式,都需要先定义一个Component基类。接口设计上有几个点必须想清楚:

第一,纯虚函数还是默认实现。如果把display、getSize这类操作全部设为纯虚,每个叶子必须逐一实现,这在初期很干净,但一旦中间层增加一个公共行为(比如rename),所有节点类都要跟着改。折中方案是基类提供默认空实现或默认抛异常,子类只覆盖自己关心的接口。我比较倾向后者,尤其是节点类型比较多的时候,能让新加节点的工作量小很多。

第二,虚析构是必须的。这一点我会在第四节的坑里细说,这里先记住结论:只要存在基类指针删除派生对象的可能,基类析构函数就必须是virtual。组合模式里几乎所有操作都是通过Component*进行的,析构不虚等于给自己埋内存泄漏的雷。

第三,子节点列表用什么容器和管理方式。这里牵扯到C++所有权语义,std::vector<std::unique_ptr<Component>>通常是最稳的默认选择,原因我在第三节代码里展开。

2.2 安全模式:管理操作只在容器节点

安全模式的做法是把add、remove、getChild这类节点管理方法放在Composite类里,基类完全不声明它们。好处是类型安全:你在File上根本调不了add,编译器直接拦住了非法操作。代价是客户代码如果拿到了基类指针,又想向容器里添加节点,必须做向下转型,比如dynamic_cast<Directory*>(node)。

class Component { public: virtual ~Component() = default; virtual void display(int depth) const = 0; virtual size_t getSize() const = 0; // 注意:没有 add / remove }; class Directory : public Component { public: void add(std::unique_ptr<Component> child); void remove(const std::string& name); // ... };

这种模式下,遍历和操作逻辑必须主动判断“这是不是容器”,所以客户代码里会出现dynamic_cast或typeid。偶尔用一次还行,用多了代码就变脏。

2.3 透明模式:让基类统一暴露全部操作

透明模式则把add、remove、getChild都搬进基类,叶子节点要么不实现,要么直接抛出异常。客户代码完全不需要向下转型,一个Component*就能完成整棵树的构建和遍历,调用体验极度丝滑。

但代价也很明显:File这种叶子类在语义上“可以add”,如果你忘了对异常做处理,运行期就会莫名其妙崩一下。换句话说,编译器帮不了你,只能靠运行时兜底。

对比维度安全模式透明模式
管理操作位置只放在容器节点放在基类,叶子也继承
类型安全性编译期阻止非法调用运行期靠异常/忽略
客户代码复杂度需要向下转型完全统一,最简洁
推荐场景节点类型明确、层级稳定树结构使用频繁、追求统一接口

2.4 我在实际项目里的选择

我的习惯是:以安全模式为骨架,用访问者或独立的遍历器补偿类型分发的麻烦。也就是说,基类不暴露add/remove,但对需要“统一处理所有节点”的场景,我会额外写一组遍历函数,内部用dynamic_cast或虚函数分派。这样既保住类型安全,又没有让客户代码被转型污染。

如果只是写一个临时的小工具、树结构不复杂,我也用过透明模式,确实快,但前提是叶子节点的add/remove要写成带诊断信息的抛错,别用空实现默默吞掉错误。空实现是透明模式最大的陷阱——你调了add以为成功了,结果节点压根没加进去,排查起来极其痛苦。

3. 文件系统的完整组合实现:代码走读与关键细节

前面讲的是设计层面的决策,下面我用文件系统例子把完整代码走一遍。这个例子可以在你本地的VS Code或CLion里直接编译运行,我会把每一步为什么这么写说清楚。

3.1 基类与叶子节点:Component和File

先定义基类。我把getSize和display作为纯虚接口,因为文件和目录对这两个操作都有明确的不同实现:

#include <iostream> #include <memory> #include <string> #include <vector> class Component { public: virtual ~Component() = default; virtual std::string getName() const = 0; virtual size_t getSize() const = 0; virtual void display(int depth) const = 0; }; class File : public Component { public: explicit File(std::string name, size_t size) : name_(std::move(name)), size_(size) {} std::string getName() const override { return name_; } size_t getSize() const override { return size_; } void display(int depth) const override { std::cout << std::string(depth * 2, ' ') << name_ << " (" << size_ << " bytes)\n"; } private: std::string name_; size_t size_; };

display(int depth)的参数用缩进展示层级深度,这是组合模式最常见的演示方式。叶子节点的display实现简单直接:打印名字和大小。

3.2 容器节点:Directory的所有权设计

接下来是重头戏Directory。它维护一个子节点数组,子节点的类型既可能是File,也可能是另一个Directory,这就是递归结构的核心:

class Directory : public Component { public: explicit Directory(std::string name) : name_(std::move(name)) {} std::string getName() const override { return name_; } size_t getSize() const override { size_t total = 0; for (const auto& child : children_) { total += child->getSize(); } return total; } void display(int depth) const override { std::cout << std::string(depth * 2, ' ') << name_ << "/\n"; for (const auto& child : children_) { child->display(depth + 1); } } void add(std::unique_ptr<Component> child) { children_.push_back(std::move(child)); } // 按名称移除子节点,返回是否找到 bool remove(const std::string& name) { for (auto it = children_.begin(); it != children_.end(); ++it) { if ((*it)->getName() == name) { children_.erase(it); return true; } } return false; } private: std::string name_; std::vector<std::unique_ptr<Component>> children_; };

几个关键设计理由:

为什么用unique_ptr而不是裸指针?组合树的父子关系天然就是独占所有权:一个子节点只有一个父节点。unique_ptr在语义上精确表达这一点,同时还解决了异常安全的问题——如果中途抛出异常,unique_ptr会自动释放已持有的节点,不会泄露内存。

为什么不用shared_ptr?大部分场景下组合树的节点不需要共享所有权,如果节点被多个父节点引用,树就变成了图,display、getSize这些递归操作很容易出现循环递归,最后爆栈。用shared_ptr等于把“树一定是树”这个约束放开了,得不偿失。

getSize为什么可以写这么简单?关键在递归调用child->getSize()时发生了多态分派:如果child实际是File,走文件的大小逻辑;如果是Directory,走目录的汇总逻辑。这就是组合模式“透明遍历”的精髓。

3.3 使用示例:构建一棵两层的目录树

int main() { auto root = std::make_unique<Directory>("root"); auto docs = std::make_unique<Directory>("docs"); docs->add(std::make_unique<File>("readme.md", 2048)); docs->add(std::make_unique<File>("design.txt", 4096)); auto src = std::make_unique<Directory>("src"); src->add(std::make_unique<Directory>("core")); src->add(std::make_unique<File>("main.cpp", 10240)); root->add(std::move(docs)); root->add(std::move(src)); std::cout << "Total size: " << root->getSize() << " bytes\n"; root->display(0); return 0; }

编译运行后会输出:

Total size: 16384 bytes root/ docs/ readme.md (2048 bytes) design.txt (4096 bytes) src/ core/ main.cpp (10240 bytes)

注意src下嵌套了一个空目录core,它没有子节点但依然正常工作,getSize返回0,display只打印一行。这就是组合模式说“容器可以和叶子同等对待”的最好例证。

3.4 在不改类的前提下扩展功能:独立遍历函数

组合模式最大的好处在于扩展能力。如果我要做一个“按扩展名过滤”或“统计指定类型大小”的功能,理论上可以通过改Component基类加虚函数实现,但每加一个需求就改基类,会让基类越来越臃肿。替代方案是写独立的递归函数,配合dynamic_cast做类型判断:

void collectCppFiles(const Component* node, std::vector<std::string>& out) { if (auto file = dynamic_cast<const File*>(node)) { if (file->getName().size() > 4 && file->getName().substr(file->getName().size() - 4) == ".cpp") { out.push_back(file->getName()); } return; } if (auto dir = dynamic_cast<const Directory*>(node)) { // 需要遍历子节点,但 children_ 是私有的... // 可以在 Directory 里加一个 forEach 回调 } }

这里遇到一个现实问题:Directory的children_是私有的,外部函数没法遍历它子节点。传统组合模式库里通常会给Component增加一个for_each_child(visitor)之类的辅助方法,让外部遍历逻辑能触达子节点,同时不破坏封装。我在生产代码里一般是给基类加一个默认空实现、容器节点覆盖它:

class Component { public: // ... virtual void forEachChild(const std::function<void(Component*)>& fn) { // 叶子节点没有子节点,默认什么也不做 } }; void Directory::forEachChild(const std::function<void(Component*)>& fn) override { for (auto& child : children_) { fn(child.get()); } }

有了forEachChild,上面的过滤函数就可以写成统一的递归:

void collectCppFiles(const Component* node, std::vector<std::string>& out) { if (auto file = dynamic_cast<const File*>(node)) { if (endsWith(file->getName(), ".cpp")) out.push_back(file->getName()); } const_cast<Component*>(node)->forEachChild([&](Component* child) { collectCppFiles(child, out); }); }

这个设计的好处是:新功能以函数形式存在,不需要改动节点类的继承结构。缺点也有——dynamic_cast用起来有点啰嗦,而且性能上有运行期类型检查开销。所以如果树特别大、遍历特别频繁,我通常会在Component内部直接缓存一两个关键类型标记,用整数枚举判断来代替dynamic_cast,性能会明显好一些。

3.5 遍历时的性能与栈深度经验

实测过程中,组合树的递归遍历有几个经验数值可以参考:

  • 单次遍历只做轻量操作(比如取个值、打印),几千到几万个节点完全没问题,递归开销可以忽略。
  • 到了十万级以上,递归函数本身带来的栈空间和函数调用开销开始变得明显。这时候可以考虑把递归改成显式栈的迭代遍历。

显式栈遍历(深度优先)的经典写法是:

void iterativeDisplay(const Component* root) { struct Frame { const Component* node; int depth; }; std::vector<Frame> stack{Frame{root, 0}}; while (!stack.empty()) { auto [node, depth] = stack.back(); stack.pop_back(); node->display(depth); node->forEachChild([&](Component* child) { stack.push_back(Frame{child, depth + 1}); }); } }

注意forEachChild的回调里,子节点的入栈顺序会决定访问顺序。上面这段代码会把子节点逆序压栈,实际输出顺序会倒过来。如果要保持和递归一样的顺序,需要先收集再倒序入栈,或者换成队列做广度优先。这是我踩过的顺序坑,写出来提醒一下。

4. 踩过的坑:所有权、无效引用与虚析构

组合模式的代码看起来简单,真正放到工程里,最容易翻车的反而不是模式本身,而是C++特有的内存管理细节。我把这几个坑按出现频率排个序,每个都附上解决方案。

4.1 坑一:父容器持有子节点,外部另行维护裸指针导致悬空

这是组合模式里最典型的“野指针”来源。比如你要在UI树中记录“当前选中的节点”,于是在别处保存了一个Component* selected。如果用户在界面上删除了那个节点,Directory::remove会把它的unique_ptr销毁,而selected仍然指向一块已释放的内存。下次访问selected必然是未定义行为。

解决方案:删除操作必须走统一的协议。我一般会在remove之前先通知所有持有者“这个节点即将销毁”,或者干脆不保存裸指针,改成保存“从根到该节点的路径数组”或者字符串ID,每次使用时重新查找。后者的代价是查找变慢,但安全性高很多,而且逻辑更清晰。

4.2 坑二:remove时迭代器失效

再看一遍上面Directory::remove的实现:

for (auto it = children_.begin(); it != children_.end(); ++it) { if ((*it)->getName() == name) { children_.erase(it); return true; } }

这里有个隐含前提:调用(*it)->getName()和erase(it)之间,绝对不能在容器里触发任何“重新分配”或者“删除其他元素”的操作。如果有人在getName()里偷偷修改了children_,迭代器立刻失效。真实工程里,文件名可能由某个回调动态计算,这种坑特别隐蔽。

保险做法:先找到目标下标,退出循环后再统一erase。或者使用std::find_if配合谓词。总之,不要在遍历中做结构性修改,这是STL容器的铁律。

4.3 坑三:基类析构函数没有virtual

这个坑每个C++程序员迟早会遇到。组合模式里你几乎总是通过Component*来删除对象:

std::unique_ptr<Component> p = std::make_unique<File>("a", 1); // p 销毁时,调用的是 Component::~Component 还是 File::~File?

如果Component的析构函数不是虚函数,delete p就只会调用Component的析构逻辑,File的成员(比如std::string name_)永远不会被析构,于是发生内存泄漏。对于含有std::string、std::vector这种RAII成员的类,后果尤其明显——资源全都不释放。

解决方案:基类析构函数写成virtual ~Component() = default;。如果你用的还是C++98/03老标准,至少要写virtual ~Component() {}。这个习惯值得从第一天就养成。

4.4 坑四:默认拷贝导致浅拷贝与双重释放

如果组合树里的某个类不小心暴露了拷贝构造,默认的浅拷贝会把两个对象的children_共享成同一个底层数组。两个对象析构时会对同一批unique_ptr调用delete,这是双释放错误。

解决方案:显式删除拷贝操作,或者实现真正的深拷贝(递归克隆)。

class Directory : public Component { public: Directory(const Directory&) = delete; Directory& operator=(const Directory&) = delete; Directory(Directory&&) noexcept = default; Directory& operator=(Directory&&) noexcept = default; // ... };

这里我连移动构造都标记了noexcept,原因是vector在扩容时可能会用到移动语义,如果移动构造可能抛异常,容器会退回到拷贝操作,而拷贝已经被删除,就会编译失败。把移动构造标记为noexcept是让它在vector里更高效也更安全的关键一步。

4.5 调试技巧:把树打出来看

组合树出问题时最直接的诊断方法就是打印整棵树。我给组合模式类的调试建议是:在基类里实现一个debugPrint(),把父节点、子节点数量、自身地址、子节点地址全部输出。排查循环结构时,这个信息比断点好用得多。我自己甚至写过一个小工具,用字符串拼出带括号的树形描述,比如(root (docs (readme.md design.txt) src (core main.cpp))),直接打印在日志里,一眼就能看出结构对不对。

5. 表达式求值:组合模式在算法与语法场景的实战

文件系统是组合模式最经典的教材案例,但实际工作中,我觉得真正让这个模式发光的是“表达式树”。编译器、计算器、规则引擎、评分系统……几乎凡是需要把表达式解析成树、再递归求值的地方,组合模式都提供了最自然的结构。

5.1 问题背景:算术表达式怎么表示

假设要做一个支持加减乘除的四则运算计算器,输入字符串(3 + 5) * 2,需要解析并求值。求值过程中我们发现:一个表达式,本质上就是一棵树,叶子是数字,内部节点是运算符,运算符的左右操作数既可能是数字,也可能是另一个运算表达式。这天然满足组合模式的所有特征。

5.2 节点设计:Literal与BinaryOp

按照组合模式的分工,抽象基类是表达式节点,叶子是数字,容器是运算符:

class Expr { public: virtual ~Expr() = default; virtual int evaluate() const = 0; virtual void print() const = 0; }; class Literal : public Expr { public: explicit Literal(int value) : value_(value) {} int evaluate() const override { return value_; } void print() const override { std::cout << value_; } private: int value_; }; class BinaryOp : public Expr { public: BinaryOp(char op, std::unique_ptr<Expr> left, std::unique_ptr<Expr> right) : op_(op), left_(std::move(left)), right_(std::move(right)) {} int evaluate() const override { int l = left_->evaluate(); int r = right_->evaluate(); switch (op_) { case '+': return l + r; case '-': return l - r; case '*': return l * r; case '/': return r == 0 ? 0 : l / r; // 简化处理,生产环境要抛异常 } return 0; } void print() const override { std::cout << "("; left_->print(); std::cout << " " << op_ << " "; right_->print(); std::cout << ")"; } private: char op_; std::unique_ptr<Expr> left_; std::unique_ptr<Expr> right_; };

如果你在第3节已经理解了File和Directory的递归关系,这里的BinaryOp几乎就是完全对称的:叶子实现具体值,容器通过递归调用子节点完成任务。

5.3 递归求值的执行过程

构造表达式(3 + 5) * 2的代码大概长这样:

auto tree = std::make_unique<BinaryOp>( '*', std::make_unique<BinaryOp>('+', std::make_unique<Literal>(3), std::make_unique<Literal>(5)), std::make_unique<Literal>(2) ); tree->print(); // 输出 ( ( 3 + 5 ) * 2 ) std::cout << " = " << tree->evaluate() << "\n"; // 输出 16

evaluate()的执行过程是:外层乘法先去问左子树“你是多少”,左子树发现自己是BinaryOp,又去问它的左叶子3和右叶子5,拿到结果8后返回给外层,外层再用2去乘。整个递归过程就是一次多态分派驱动的深度优先遍历。

这个例子让我觉得,理解组合模式的关键不是“树”这个名词,而是**“递归结构上的统一操作”**。同样的思想可以迁移到所有语法结构里:XML/DOM节点、JSON对象、C++的AST,甚至游戏里的行为树、技能树。

5.4 与访问者模式的配合:新增操作而不改节点

表达式树也有它的“扩展痛点”:如果我想给表达式增加一个“统计有多少个运算符”的功能,按照组合模式的基本写法,要么在Expr里加虚函数,要么写外部递归函数。加虚函数会让基类越来越胖,外部递归又受限于成员可见性。

这时可以和访问者模式(Visitor Pattern)结合。给每个节点加一个accept(Visitor&)接口,具体操作全放在Visitor类里。这样,加一个“统计运算符数量”的操作,只需要新写一个统计Visitor,完全不需要修改已有的Literal和BinaryOp类。这个组合是设计模式里非常经典的一对搭档,尤其适合表达式语法树这种“节点类型相对稳定、但操作经常增加”的场景。不过访问者模式本身又是一大块内容,这次不展开细说,有兴趣的可以单独再挖。

5.5 我发现这个模式特别适用的场景

除了教科书里的文件系统和算术表达式,我在真实项目里见过并把组合模式用在过这些地方:

  • 技能树系统:技能节点可以是一个具体技能(叶子),也可以是一个技能组(容器),技能组的激活条件是前提技能全部激活,这正好是递归聚合逻辑。
  • 商品配置/套餐系统:单商品是叶子,套餐内可以包含多个商品也可以嵌套其他套餐,计算总价时递归汇总。
  • 资源合并和部署包管理:配置文件嵌套引用,一个配置项可以引用另一个配置文件,引用关系构成树。

这些场景的共同点是:它们都要对“单个对象”和“一组对象的聚合”执行相同的操作,比如算价格、算激活、算资源总量。组合模式在最底层把这些场景收敛成了一组递归虚函数调用,逼着你把真正复杂的业务逻辑留在节点内部,而不是散落在调用方的一堆if-else里。

6. 组合模式的边界与过度设计判断

写了这么多好处,也得泼一盆冷水。组合模式不是万能药,实际项目里我很注意避免无脑套模式。下面这几个场景是我明确说过“别用”或者“用了你后面会哭”的情况。

6.1 节点类型极少、结构扁平时,别硬套

如果业务里的层级最多只有两层,而且节点类型只有一种两种,if-else写一遍可能十行代码就结束了,硬套组合模式反而要写一堆抽象类和虚函数。有一个经验法则:当你在模式带来的抽象收益低于它产生的样板代码成本时,就应该退回去写简单代码。我一般会先用最简单的方式实现,等到确实发现调用方代码开始重复、分支开始变多,再回头重构进组合模式,“先让代码跑起来,再让它变优雅”在实战里远比一句“一定要用设计模式”靠谱。

6.2 递归遍历的性能边界

组合模式的优雅高度依赖递归调用。如果用递归实现对千万级节点的树做高频遍历,每次getSize()都走一遍完整递归,性能消耗是很可观的。实测经验是,递归本身的开销主要体现在函数调用栈上,几万次虚函数调用在现代CPU上通常在一毫秒到几毫秒级别,看起来不痛不痒,但如果这个操作在每帧都执行、或者在网络请求热路径里执行,就会变成性能隐患。解决方案前面提过:改成显式栈的迭代遍历;或者对稳定的树做缓存,在Directory里缓存totalSize_,子节点增删时标记脏位,下一次getSize只更新变化路径。

6.3 破坏性操作和循环依赖

组合模式默认这棵树是无环的。如果某个业务允许子节点反向引用父节点,或者允许多个父节点共享同一子节点,树就变成了图。图上的递归算法会死循环。我在实际项目里遇到过,一个菜单系统误把“返回上级”的表单对象加成了子节点,等于给自己安了一个环,结果整个递归展示直接爆栈。

约束方案:在add方法里向上回溯检查“是否已经存在该节点的路径上有本节点”,有环就拒绝添加。同时最好在文档里明确,组合树不允许共享子节点、不允许反向引用。和数据库设计一样,规则越早约定,后期坑越少。

6.4 多态动态类型与静态类型的权衡

最后说一种情况:如果你整个项目的核心需求是“针对不同类型节点做完全不同的处理”,而节点类型之间几乎没有任何公共操作,那组合模式“统一接口”的出发点就不成立——你得写大量dynamic_cast和类型判断分派,最后发现代码比不用模式还啰嗦。这种场景更适合用访问者模式,把“类型差异”集中在Visitor里,而不是硬塞进组合模式的统一接口里。组合模式适合的是:叶子节点和容器节点在对外行为上高度一致,比如都能显示、都能求值、都能渲染;如果有若干个类型的叶子,它们的对外行为却完全不同,就要谨慎了。

写到最后,说点干货之外的感受

组合模式是我个人认为C++项目里性价比最高的结构型设计模式之一。它不像观察者模式那样有调度时机问题,也不像状态模式那样容易牵一发动全身。它只是在递归结构上给了你一个天然的统一抽象,而C++的多态、RAII、智能指针又恰好把这种抽象落地得很舒服。文件系统、表达式树、UI控件树、配置嵌套、技能树,全是我在真实项目里花过钱踩过坑、最后又用这套思路收拾干净的场景。

代码不复杂,但建议你自己动手敲一遍,不要只是看我贴的代码。敲完之后试着扩展一下:给文件系统加一个“搜索包含关键字的文件”的递归查询,或者给表达式树加一个“按括号字符串导回原式”的功能。组合模式这个东西,读十遍不如自己建一棵树再拆掉它一遍。

如果你也想在自己的项目里试试,可以运行一次项目里的单元测试,把树打出来看一眼结构是否符合预期。做工程的乐趣有很大一部分在于:把一个看着混乱的递归结构,收束成一段每个节点都只需操心自己的递归虚函数,然后你发现整棵树的逻辑一夜之间变清晰了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询