☰
C++组合模式实战:统一递归处理树形结构
2026/10/15 7:07:32 网站建设 项目流程

年初给某内部管理系统做任务拆分模块时,我踩到一个特别典型的场景:一条任务要么是叶子任务(不能再拆),要么是一个包含若干子任务的任务组。报表要统计工时,页面要展示层级,搜索要递归找出所有未完成项——于是每个处理任务的地方都必须先判断“这到底是任务还是任务组”,再走不同的分支。那个 if-else 越写越多之后,我才认真去重构成了 C++ 里的组合模式。

组合模式(Composite Pattern)属于结构型设计模式,解决的问题非常聚焦:把“单个对象”和“对象的组合”统一成同一个抽象,让客户端拿到任何一个节点,都可以用同样的方式调用方法,至于它内部还有没有子节点,由对象自己递归处理。这篇文章不打算复述教科书定义,我会从一段真实代码讲起,把接口设计、内存所有权、递归遍历、环引用这些实际工程里绕不开的点掰开揉碎,最后再聊聊访问者模式和命中测试这类进阶玩法。

1. 从一处 if-else 泥潭说起:部分与整体为什么要共用接口

1.1 第一次遇到任务树

当时模块里有两个类:Task表示单个任务,TaskGroup表示任务分组。分组里面有个vector<Task>存直接子任务,而子任务又可能是分组,于是TaskGroup里还得再挂一层。报表模块统计总工时的第一版代码长这样:

void accumulate_hours(Item* item, int& total) { if (auto* group = dynamic_cast<TaskGroup*>(item)) { for (auto* sub : group->sub_items()) { accumulate_hours(sub, total); } } else if (auto* leaf = dynamic_cast<Task*>(item)) { total += leaf->hours(); } }

有个同事看到这段代码说,这不就是典型的“类型检查加递归”吗?问题在于,dynamic_cast出现一次还好,报表、导出、权限校验、页面渲染四处各写一遍,新增一种节点类型时,所有if-else站点都要跟着改。树结构的复杂度是递归的,而代码却在每个调用点手动展开递归,这是很别扭的。

1.2 组合模式的三个角色

组合模式把这种“部分-整体”的层级关系抽象成三个角色:

  • Component(抽象组件):声明所有节点共有的接口。在这个例子里就是“我是谁、我的工时是多少、怎么打印自己”。
  • Leaf(叶子节点):没有子节点的具体类,实现自己的行为,不关心容器逻辑。
  • Composite(容器节点):持有子节点列表,实现父接口时通常把工作转发给子树,再合并结果。

一句话:“整体”也被当成“部分”来处理。客户端只需要面向 Component 编程,调用同一个虚函数,具体是叶子还是容器由实现自己负责。这就好比文件系统里的du -sh,它根本不在乎某个路径底下是文件还是目录,反正size()这个接口对于目录来说会递归把所有后代文件加总。

1.3 什么时候不该用它

组合模式很强大,但滥用会很疼。我列一下自己判断的标准。

适用场景不适用的场景
数据天然是树形,且层次可变结构固定,两层封顶
需要对所有节点做统一操作每个类型的行为差异极大
树会运行期增长(用户建分组)只有叶子,没有真正的组合
希望新增节点类型时调用点零改动想用组合模式“消灭”所有 if 的场景

比如部门考勤表,部门下面只有员工,不会再有子部门,那直接两个类分开写更清爽。组合模式的价值在“递归”和“统一”同时成立时最大,少一个都不值得引入。

2. 接口设计的第一道选择题:add/remove 放基类还是只放容器类

2.1 “透明式”的便利与代价

GoF 的书里其实给了两种做法。第一种叫透明式,把add_child、remove_child也声明在抽象基类里。这样客户端写代码时完全不需要转型:

class Node { public: virtual void add_child(std::unique_ptr<Node> child) { throw std::runtime_error("leaf cannot add child"); } virtual void remove_child(const Node* child) { throw std::runtime_error("leaf cannot remove child"); } };

叶子节点的这个接口就是个空壳,调用即抛异常。好处是“接口透明”,坏处是违反了接口隔离原则——客户端可以调用一个注定失败的操作,而且这种错误发生在运行期而不是编译期。我做项目的时候很讨厌这种行为,因为报错的地方和写错的调用点往往隔了好几层。

2.2 “安全式”的严谨与麻烦

第二种叫安全式,组合操作只放在 Composite 类上,基类里完全没有。这样设计类的时候,叶子类根本不存在add_child这个方法,你在编译期就不可能对叶子做“添加子节点”的操作。

代价是构建树的时候需要知道具体容器类型。比如std::make_unique<Directory>("etc")之后才能调用add_child,不能把一个unique_ptr<Node>直接往下挂。对多数场景来说,这个“麻烦”其实不算麻烦——构建树的地方本来就要知道层级结构,真正需要统一处理的是后续的遍历和计算,而不是建树。

2.3 我习惯的折中方案

实际工程里我默认这么设计:修改型的操作只放容器类,读型的结构访问放基类。基类里除了业务方法,再提供一组只读的树访问接口:

class Node { public: virtual size_t child_count() const { return 0; } virtual Node* child_at(size_t index) { return nullptr; } virtual const Node* child_at(size_t index) const { return nullptr; } };

叶子默认没有孩子,容器覆写成children_.size()和children_[index].get()。这样既保住了“安全式”的编译期约束,又让遍历、查找、导出这类统一算法不用靠dynamic_cast判断类型。我在做任务树的时候,统一报表就是靠这套只读接口完成的,一篇accumulate函数没有任何类型分支。

2.4 虚析构不是建议是必须

这个坑我见过不止一次。基类不写virtual ~Node(),然后std::vector<std::unique_ptr<Node>>销毁时,unique_ptr会通过基类指针删除派生类对象——这是未定义行为。轻则派生类的成员没被释放,重则直接崩。

class Node { public: virtual ~Node() = default; // ... };

= default就够了,子类的析构默认自动调用。组合模式的树本来就是要靠容器类持有子节点的,析构链就是天然的递归释放,虚析构写漏了,前面所有设计都白搭。

3. 从零手写一个文件系统:150 行把这个模式跑起来

理论说太多容易飘,我直接给一个能编译能运行的完整例子。用文件系统当载体最直观:文件是叶子,目录是容器。

3.1 抽象基类 Node

#include <iostream> #include <memory> #include <string> #include <vector> class Node { public: explicit Node(std::string name) : name_(std::move(name)) {} virtual ~Node() = default; const std::string& name() const { return name_; } virtual size_t size() const = 0; virtual void print(int indent = 0) const = 0; protected: std::string name_; };

接口我刻意保持最小化,只有名字、大小、打印。size()和print()用纯虚函数,强制叶子与容器都实现。

3.2 叶子 File 与容器 Directory

class File final : public Node { public: File(std::string name, size_t bytes) : Node(std::move(name)), bytes_(bytes) {} size_t size() const override { return bytes_; } void print(int indent = 0) const override { std::cout << std::string(indent, ' ') << "- " << name() << " (" << bytes_ << " bytes)\n"; } private: size_t bytes_; }; class Directory final : public Node { public: using Node::Node; void add_child(std::unique_ptr<Node> child) { children_.push_back(std::move(child)); } size_t size() const override { size_t total = 0; for (const auto& child : children_) { total += child->size(); } return total; } void print(int indent = 0) const override { std::cout << std::string(indent, ' ') << "+ " << name() << "/\n"; for (const auto& child : children_) { child->print(indent + 2); } } private: std::vector<std::unique_ptr<Node>> children_; };

注意Directory::size()和Directory::print()都只是“递归地调用每个孩子的同名函数,然后汇总”。这就是组合模式的核心:容器自己不特殊,它把请求转交给孩子,孩子如果是容器又继续转交。你完全不用写“如果孩子是目录怎么办”这种分支。

3.3 构建一颗树并一键输出

int main() { auto root = std::make_unique<Directory>("root"); auto etc = std::make_unique<Directory>("etc"); etc->add_child(std::make_unique<File>("hosts", 4)); etc->add_child(std::make_unique<File>("passwd", 8)); root->add_child(std::move(etc)); root->add_child(std::make_unique<File>("readme.txt", 32)); root->print(); std::cout << "total = " << root->size() << " bytes\n"; return 0; }

输出是:

+ root/ + etc/ - hosts (4 bytes) - passwd (8 bytes) - readme.txt (32 bytes) total = 44 bytes

整个树的构建、统计、打印,加起来不到 120 行。后续要加一种Symlink节点,只需要继承Node实现三个方法,Directory一行都不用改。

3.4 这段代码里值得注意的两个点

第一,我在加法里传了std::move(etc),因为add_child接受的是unique_ptr<Node>。所有权转移是显式的,谁都不会误以为自己还能用etc。第二,File和Directory都加了final,这是我个人的习惯——组合模式的节点类型通常不需要再被继承,final还能给编译器提供虚调用去虚拟化的机会。

4. 递归的隐藏代价:深树、环引用与遍历顺序

4.1 递归让算法只写一遍

size()和print()之所以这么短,是因为递归隐藏了树的深度信息。调用者对深度一无所知,也无需知道。这是组合模式最舒服的地方:复杂度从调用方转移到了被调用方,而树结构本身就是递归定义,被调用方处理起来天经地义。

4.2 栈溢出不是危言耸听

递归不是免费的。每个递归帧都要占栈空间,默认栈大小在常见平台上是几 MB 到十几 MB。如果业务数据是从超深 JSON 或 XML 直接映射出来的,树的深度可能轻松超过几万层,递归print就有栈溢出的风险。

我遇到过一种特殊情况:配置树由脚本生成,脚本作者写了个深层嵌套循环,结果树深到了十万层。递归一进去就直接崩。这时候就得把递归改成显式栈的迭代遍历。

4.3 显式栈的迭代版本

要写迭代遍历,就得有统一的子节点访问接口。所以我在基类上加了一组只读访问方法(就是前面说的折中方案):

class Node { public: virtual size_t child_count() const { return 0; } virtual const Node* child_at(size_t index) const { return nullptr; } // ... }; class Directory final : public Node { public: size_t child_count() const override { return children_.size(); } const Node* child_at(size_t index) const override { return children_[index].get(); } // ... };

然后迭代前序遍历:

void print_iterative(const Node& root) { struct Frame { const Node* node; int depth; }; std::vector<Frame> stack; stack.push_back({ &root, 0 }); while (!stack.empty()) { auto frame = stack.back(); stack.pop_back(); frame.node->print(frame.depth); for (size_t i = frame.node->child_count(); i > 0; --i) { stack.push_back({ frame.node->child_at(i - 1), frame.depth + 1 }); } } }

这里print被我稍稍改成了“只打印自己这一行”,遍历顺序由调用方控制。递归版的遍历顺序藏在函数调用栈里,迭代版则明确砸在显式std::vector上——栈空间可控,层级再深也只是堆内存的增长。

4.4 环引用会让递归变成死循环

递归还有一个隐藏前提:树必须是树。一旦出现环引用,递归就会无限套娃。安全式 API 配合unique_ptr所有权,正常使用很难成环,但如果某个功能允许“借用”节点指针再挂到别处去,环就出来了。

加孩子前判一下目标节点是不是自己的后代,是个简单的防御:

bool is_descendant_of(const Node& node, const Node* candidate) { if (&node == candidate) return true; for (size_t i = 0; i < node.child_count(); ++i) { if (is_descendant_of(*node.child_at(i), candidate)) { return true; } } return false; }

在add_child里先检查is_descendant_of(*child, this),如果成立就拒绝。数据越界不可怕,可怕的是运行时才出现的环,所以这个防御我一般都留着。

5. 所有权与生命周期:谁删节点、什么时候删、删错会怎样

5.1 用 unique_ptr 表达所有权,用裸指针表达关联

组合模式最常见的隐患是节点生命周期。我见过有人用shared_ptr把所有节点全包起来,结果父子互相引用,对象永远释放不了。正确姿势是:

  • 父子关系用unique_ptr:父节点拥有子节点,树销毁时逐层析构,内存回收是确定性行为。
  • 父指针这类反向关联用裸指针:只表达“我知道我的父是谁”,不拥有,不参与释放。裸指针在这里是安全的,因为父节点一定比子节点活得久,树是自上而下析构的。

如果非要让外部也能持有树里的节点,再考虑shared_ptr,但那样父子之间一定不能再用shared_ptr形成环,父回溯指针要用weak_ptr。能不加智能指针复杂度就不加。

5.2 vector 扩容、移动与节点地址

Directory里是std::vector<std::unique_ptr<Node>>。有人担心vector扩容时会不会把孩子地址给换了,导致其他地方存的Node*失效。其实不会:vector移动的是unique_ptr对象本身,而不是它所指向的堆节点。哪怕vector的存储从 4 扩容到 16,堆上每个Node对象的地址都原封不动,之前取得的Node*依旧有效。

这是个很重要的小知识。我在做图形树的时候,外部拿了节点指针做缓存,后来容器扩容,我一度以为缓存全废了,排查半天发现根本和扩容无关。真正会导致指针失效的操作是erase或者clear——unique_ptr析构的那一瞬间,堆上的节点对象被销毁,所有指向它的裸指针都变成悬垂指针。

5.3 拷贝默认被删除后的深拷贝方案

组合模式树不能直接拷贝:std::vector<std::unique_ptr<Node>>的拷贝构造是删除的。但业务上经常要复制一颗树再改动(比如模板复制、分支版本)。这时候要么自己写深拷贝,要么提供一个clone虚函数,让每个节点负责复制自己:

class Node { public: virtual std::unique_ptr<Node> clone() const = 0; }; std::unique_ptr<Node> File::clone() const { return std::make_unique<File>(name_, bytes_); } std::unique_ptr<Node> Directory::clone() const { auto copy = std::make_unique<Directory>(name_); for (const auto& child : children_) { copy->add_child(child->clone()); } return copy; }

这个模式的优雅之处在于,每个类只负责复制自己这一层,子树的复制继续递归交给孩子。调用方拿到unique_ptr<Node>之后,不需要知道复制出来的具体是文件还是目录。移动则是另一回事,unique_ptr本身可移动,整颗树的移动构造基本可以直接用,省掉一次深拷贝。

6. 进阶:从打印到命中测试,再到访问者模式

6.1 别把所有算法塞进 Component

组合模式用久了容易犯一个毛病:什么方法都想往基类里加。今天加to_json,明天加export_csv,后天加count_uncompleted,结果Node变成一个臃肿的全能类。每次新增功能都要改基类和全部子类,组合模式带来的扩展性又还回去了。

这时候就该请出访问者(Visitor)模式。访问者把“对节点的操作”从节点类里抽出来,节点只需要提供一个accept方法:

class Visitor { public: virtual ~Visitor() = default; virtual void visit_file(File& f) = 0; virtual void visit_directory(Directory& d) = 0; };

File::accept调用v.visit_file(*this),Directory::accept先调用v.visit_directory(*this)再遍历孩子。于是每个新算法只需新增一个Visitor子类,节点类完全不动。组合模式负责“结构的递归”,访问者负责“操作的分派”,两者是天然搭档。

6.2 图形编辑器的命中测试

组合模式最有名的应用之一就是图形编辑器:Shape是叶子,Group是容器,两者都实现contains_point(x, y)和bounds()。画布上拖出一个框选,选中一组图形时——你可能根本不知道自己点中的到底是一个圆还是一个图形组,代码一样工作:

class Group final : public Shape { public: bool contains_point(int x, int y) const override { for (const auto& shape : shapes_) { if (shape->contains_point(x, y)) { return true; } } return false; } // bounds() 同理,取所有子形状包围盒的并集 };

这里有意思的是,命中测试这个操作从“单个形状”到“组合形状”语义完全一致。客户端的鼠标点击处理逻辑里,没有任何一处去判断dynamic_cast<Group>。新加一种TextShape节点,命中测试天然生效,这正是组合模式想达到的效果。

6.3 关于 std::variant 与纯数据树

如果你是 C++17 之后的新项目,节点种类固定、数量有限,其实还有一个更现代的选择:用std::variant表示节点类型,配合std::visit做类型安全的分派。

struct FileData { std::string name; size_t bytes; }; struct DirectoryData { std::string name; std::vector<std::unique_ptr<NodeValue>> children; }; using NodeValue = std::variant<FileData, DirectoryData>;

这样不需要类继承,编译器强制你处理每一种节点类型,漏一种就编译失败。代价是递归类型写起来别扭,而且运行时“新节点类型”无法动态加入,必须改类型定义。我的取舍标准是:类型绝对固定、性能敏感、想用编译期穷举,就上variant;需要开放扩展、面向接口编程、树结构动态成长,就用经典组合模式。

组合模式做的是“责任划分”这件事——叶子负责叶子的事,容器负责容器的事,调用方永远只看到同一个接口。我在几次重构里最深的体会是,模式本身不难,难的是别把那些本该留白的判断硬塞进代码里。写递归树的时候多想想栈深度和所有权,再加一个环引用防御,基本上就不会翻车。如果你手头正好有个树形需求,从最简版开始,先让size()和print()跑通,再谈优化和扩展,这条路走过一次,后面就顺了。

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

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

立即咨询