☰
C++组合模式实战:五种工程变体解决树形结构落地痛点
2026/10/14 6:08:09 网站建设 项目流程

设计模式书里讲组合模式,几乎都是同一套模板:Component、Leaf、Composite 三个类,一张继承关系图,然后一个递归遍历的示例。看起来清爽,但真要在 C++ 项目里落地,你大概率会发现教科书那版代码根本撑不住。我在几个具体项目里被这棵树的特性反复折腾过:树太深递归栈爆掉、叶子节点类型收敛不了、遍历到一半想提前剪枝却不知道怎么传信号、加一种新节点要改动无数处 if-else。

这篇文章不打算复述标准组合模式,而是记录我在 C++ 里尝试过的几种组合模式变体。它们不是对原模式的否定,而是针对不同场景做了取舍:有的用 std::variant 做密封层级,有的用 CRTP 让节点共享静态多态能力,有的把递归遍历改成显式栈迭代器,有的是把遍历返回值变成控制信号。每个变体我都会说清楚它解决的痛点、核心写法和代价。

1. 教科书组合模式在 C++ 项目里经常不能直接落地

1.1 安全接口与透明接口的两难:叶子节点上凭空多出来的 add

标准的组合模式把 add、remove、getChild 这些容器操作放在 Component 接口里,这让客户端可以用一致的方式对待叶子节点和组合节点,也就是所谓的“透明接口”。但代价是叶子节点也必须实现这些方法,而叶子根本没有子节点,所以只能在实现里抛异常或者写空操作。

class Component { public: virtual ~Component() = default; virtual void execute() = 0; // 透明接口:叶子节点也要被迫提供 virtual void add(std::shared_ptr<Component> child) { throw std::logic_error("leaf node cannot add child"); } virtual void remove(std::shared_ptr<Component> child) { throw std::logic_error("leaf node cannot remove child"); } };

把 add 操作放到 Component 里,运行时一旦传错对象,就是异常或者无效操作。另一条路是“安全接口”,add 只定义在 Composite 上,但这样一来客户端遍历时拿到的是 Component,想要 add 就必须向下转换成 Composite,dynamic_pointer_cast 写得满屏幕都是。两种接口各有各的别扭,这是组合模式在 C++ 里的第一个老问题。

我实际碰到的场景比这个更麻烦。某个内部配置系统里,节点只有两种:键值对叶子节点和分组节点。理论上应该很干净,但因为有透明接口这一层,代码里到处都是 try-catch,防的是一个永远不会发生的 add。真正常见的操作——遍历查找某个叶子节点、判断节点类型、打印树结构——反而被淹没在这些防御性代码里。

1.2 虚函数分发的开销被海量叶子节点放大

如果树的规模不大,虚函数那点开销可以忽略。但等你处理的是几万个叶子节点、每秒要被遍历多次的树,情况就不一样了。每一次 execute() 调用都是一次间接跳转,分支预测还容易失败,再加上每个对象里都带一个 vptr,叶子节点本身又没有太多数据,vptr 的内存占比会变得相当刺眼。

我还遇到过一个更直观的问题:调试器里看一堆叶子节点的内容,只看见 vptr 占掉了首 8 字节,后面的数据反而要绕一下。对象池、缓存、序列化都不太容易处理这种带虚表的对象。不是说虚函数有多慢,而是在“叶子对象极其轻量、数量极其巨大”的特定场景里,它的代价会被放大。

1.3 层级不封闭:谁都能继承 Component,组合树失去约束

标准的组合模式在“类型集合”上是完全开放的,任何类都可以继承 Component,然后被塞进父节点的 children 列表。这在插件化架构里是优点,但在业务规则明确的系统里反而是麻烦。

比如我的配置系统里定义了两种节点,结果同事临时加了一个继承自 Component 的“特殊节点”塞进去,遍历代码里的 if-else 就要跟着膨胀。虽然可以靠模式匹配去收敛,但语言层面从来没有强制过“节点类型只有这两种”。你只能靠代码评审和文档去维护这个约束,属于典型的自律型设计。

2. 密封组合:用 std::variant 替掉递归虚表分发

2.1 先把递归类型定义这件事说清楚

std::variant 可以看作一种类型安全的 union,它能在编译期限定“树里可能有哪几种节点”。这在组合模式里天然就是密封层级:不允许新增子类,所有节点类型在编译期就被列出来。但 C++ 里用 std::variant 实现递归树有个小坑,必须提前知道。

直接写using Node = std::variant<Leaf, Group>,然后让 Group 里放std::vector<Node>是不行的,因为实例化 variant 的时候要求所有备选类型都是完整类型,而 Group 当时还没定义完。我的绕法是这样:先前置声明 Node,再定义 Leaf 和 Group,Group 的孩子用 shared_ptr 来放,最后再定义 variant 和 Node 结构。

struct Node; struct Leaf { int value = 0; void execute() const { // 叶子节点的具体逻辑 } }; struct Group { std::vector<std::shared_ptr<Node>> children; void execute() const; }; using VariantNode = std::variant<Leaf, Group>; struct Node { VariantNode data; }; void Group::execute() const { for (const auto& child : children) { std::visit([](const auto& node) { node.execute(); }, child->data); } }

这个写法的核心是用 shared_ptr (这里其实是 shared_ptr )打断递归依赖。shared_ptr 允许指向不完整类型,所以编译器不会强制要求 Node 在定义 Group 时就完整。代价是这里用了共享所有权,如果你希望组合树是独占所有权,可以换成 unique_ptr,但要小心析构函数和声明顺序。为了省事,我的配置系统里直接用 shared_ptr,树结构不追求极端性能时没什么问题。

2.2 用 std::visit 代替 virtual execute

有了 VariantNode,遍历分发就不再走虚函数表,而是交给 std::visit。对于一个密封的、类型集合在编译期已知的组合树,这是很自然的选择。

void execute(const Node& node) { std::visit( [](const auto& concrete) { concrete.execute(); }, node.data ); }

这个自动 Lambda 会在编译期被展开成针对 Leaf 和 Group 两个类型的调用,效率上经常比虚函数更好,因为编译器能看到完整调用目标,可以直接内联。更重要的是,如果有人往 VariantNode 里塞一个不实现 execute() 的新类型,编译期就会报错。密封带来的收益是“穷尽检查”:新增节点类型时,所有 visit 分支都强制要求补全,而不是等到运行时才发现漏了一个。

2.3 密封层级换来的是编译器替你穷尽检查

密封组合不是没有代价。当业务真的要在运行期动态注册一种全新节点类型时,std::variant 这套方案就卡住了,因为类型集合在编译期就固定了。你只能提前把所有可能类型列进去,或者回退到虚函数方案。

我的建议是:如果你能明确说出“这棵树里最多只有三到五种节点”,std::variant 密封组合是首选,它让代码更像数据描述而不是多态对象关系网。如果需求里明确要求“运行期能加载外部模块的新节点类型”,那老实的虚函数组合模式仍然更合适。

3. 静态多态混搭:CRTP 在组合模式里的真实价值

3.1 一个叶子反查兄弟节点的需求逼出来的设计

有次做一个可视化流程编辑器,流程里的每个节点都要能知道自己父节点的上下文,还要能访问同级兄弟节点的状态。标准组合模式里,叶子节点通过父节点向下访问子节点很容易,但从叶子向上反查父节点,接口里完全没提,得额外塞一个 parent 指针。

为了让不同节点对象都能调用“获取父节点”“枚举兄弟节点”这类公共操作,我一开始想用继承把公共逻辑都放到基类里。但每个节点类型的差异太大,基类如果塞满所有公共能力,很快就变成一个巨型工具类。后来改用 CRTP 写了一个节点基类模板,把“基于自身类型执行的树操作”用 static_cast 派发到我自己的类里。

template <typename Derived> class NodeBase { public: Node* parent() const { return parent_; } void setParent(Node* p) { parent_ = p; } template <typename Fn> void forEachSibling(Fn&& fn) const { if (!parent_) return; auto& siblings = parent_->children(); for (auto& candidate : siblings) { if (candidate.get() != static_cast<const Derived*>(this)) { std::forward<Fn>(fn)(candidate); } } } private: Node* parent_ = nullptr; }; class FlowNode : public NodeBase<FlowNode> { // 流程节点自己的逻辑 }; class GroupNode : public NodeBase<GroupNode> { // 组合节点自己的逻辑 };

这里的关键是 NodeBase 里能拿到 Derived,在 forEachSibling 里通过 static_cast 把 this 转成当前派生类型去比较,避免每个派生类重复写一遍兄弟枚举的样板代码。

3.2 CRTP 提供公共行为模板,容器仍需要统一接口

但这里有个现实问题,也是 CRTP 组合变体绕不过去的一道坎:FlowNode 和 GroupNode 是不同的模板实例,它们之间没有公共基类,所以不能直接放进统一的 children 容器里。CRTP 在这里替代的是“公共遍历行为的样板”,而不是“容器元素类型统一”。

我最后的做法是双轨制:容器元素继续用传统的 Component 抽象接口,这样才能在运行时持有不同类型节点;而叶子节点和组合节点各自的公共树操作,通过 CRTP 基类模板注入。也就是说,这个变体不是用静态多态完全替代运行时多态,而是用静态多态卸掉重复样板代码。

3.3 这个变体到底解决什么问题

所以要说清楚:CRTP 组合变体并不是为了消灭虚函数。它解决的是组合模式里最常见的一个尴尬——叶子节点需要拥有父指针、兄弟枚举、路径查询这些树操作能力,而这些能力本身跟具体业务无关。如果放在基类里,业务方法会混入大量树结构操作;如果每个类自己写一遍,又极度重复。

CRTP 相当于把“我是组合树的一员,我应该能执行这些树操作”这层能力,以编译期注入的方式交给了各个具体节点类。它牺牲了一定的类型抽象,换来的是公共逻辑只写一次,同时保留每个派生类自己的完整类型信息。想要彻底替代虚表,还得靠 std::variant 那套密封方案互相配合。

4. 显式栈遍历:把递归调用改成可暂停的迭代器

4.1 四万层 JSON 结构把递归调用栈打穿之后

教科书式遍历写起来都是递归:execute() 里遍历 children,每个 child 又调用 execute()。树深度几百层时没事,但某次我从外部系统同步了一份很深的配置数据,嵌套层级达到三万多层,递归遍历直接爆栈。排查时发现栈回溯里有几千层同样的调用帧,没有任何优化能救回来。

于是我把递归改成显式栈。组合树本质是树形结构,任何递归遍历都可以等价地改写成用一个栈来维护“当前走到哪了”。改造后深度不再依赖调用栈大小,因为每个深度层次只是栈里的一条记录,压多少层都不涉及系统调用栈的增长。

4.2 显式栈迭代器的核心结构

我先定义了一个非常简单的 Frame 结构,记录当前节点和当前访问到的子节点下标,然后用一个 vector 当栈用。

struct Frame { const Node* node = nullptr; size_t nextChildIndex = 0; }; class TreeIterator { public: explicit TreeIterator(const Node* root) { if (root) stack_.push_back({root, 0}); } const Node* next() { while (!stack_.empty()) { auto& frame = stack_.back(); if (frame.nextChildIndex == 0) { // 前序遍历:第一次进入节点就返回 frame.nextChildIndex++; return frame.node; } // 需要根据实际节点类型获取子节点 auto child = getChild(frame.node, frame.nextChildIndex - 1); if (child) { frame.nextChildIndex++; stack_.push_back({child, 0}); } else { stack_.pop_back(); } } return nullptr; } private: std::vector<Frame> stack_; };

getChild 在标准组合模式里就是 Component 的 getChild 方法,在密封 variant 方案里,可以靠 std::visit 分别处理 Leaf 和 Group,叶子返回 nullptr,组合节点从 children 里取。这样迭代器的遍历顺序是前序,每调用一次 next() 就往前走一步。

改成显式栈之后,同时获得了一个附加能力:遍历随时可以暂停。迭代器对象本身保存了栈状态,你可以遍历一部分、处理点别的事、再回来继续访问,不需要递归调用那么多层栈帧一直挂着。这对那种“边加载边建树”的数据流特别有用。

4.3 惰性遍历与中途停止派生出来的能力

显式栈迭代器让组合模式可以从“一次遍历到底”变成“按需取下一个节点”。比如我要统计树里有多少个叶子节点,传统递归写法要一次性遍历完,显式栈迭代器却能遍历 1000 个就停下来,返回统计增量,再继续下一次。

另外,前面 4.1 那种超深树,除了显式栈,还有一个 C++20 新选项:协程。协程可以在堆上分配帧,递归协程让深层树遍历写起来像递归,但不会爆调用栈。

cppcoro::generator<const Node&> walk(const Node& node) { co_yield node; if (auto* group = dynamic_cast<const Group*>(&node)) { for (const auto& child : group->children()) { co_await walk(*child); } } }

协程本质上也是一种可暂停迭代器的实现。如果你的编译环境已经支持 C++20,写出这种递归风格又能随时停下来的遍历,是最贴近人的直觉的。但协程帧的开销比显式栈大,对于追求极致性能的核心路径,显式栈仍然是更可控的方案。

5. 短路组合:给遍历返回状态码,组合和责任链杂交

5.1 为什么 void 返回值在事件分发场景不够用

标准组合模式的 execute() 往往是 void,组合节点依次遍历子节点就完事。但真实世界的树遍历经常需要在某个分支结束后停下来。比如界面事件分发:鼠标点击一个容器,它要把事件分发给子控件,某个控件消费了这个事件,剩下兄弟控件就不该再收到。再比如规则引擎:规则节点层层嵌套,只要下层规则命中,上层就不必继续处理后续分支。

如果坚持用 void 返回值,就只能靠异常来打断遍历。异常做控制流不是不可以,但跳过的位置太多,性能开销大,代码可读性也差,更别提调试时异常栈一堆噪声。

5.2 用枚举状态码传递“继续/停止”信号

我的做法是让操作返回一个枚举状态码,组合节点在遍历子节点时检查这个状态码,一旦某个子节点返回“停止”,整个遍历立刻剪枝结束。

enum class VisitStatus { Continue, Stop }; class Component { public: virtual ~Component() = default; virtual VisitStatus handle(const Event& event) = 0; }; class Leaf : public Component { public: VisitStatus handle(const Event& event) override { if (hit(event)) { consume(event); return VisitStatus::Stop; } return VisitStatus::Continue; } }; class Group : public Component { public: VisitStatus handle(const Event& event) override { for (const auto& child : children_) { if (child->handle(event) == VisitStatus::Stop) { return VisitStatus::Stop; } } return VisitStatus::Continue; } };

这样 handle() 在 Leaf 上出现“停止”,会一层一层向上传递,所有祖先组合节点看到 Stop 后都立即返回。继续遍历的成本极低,就是一个枚举比较,而且责任非常清楚:每个节点只决定自己这一层的去留。

更进一步,可以把 VisitStatus 扩展成三态,比如:

enum class VisitStatus { Continue, Stop, Fail };

Fail 表示这个分支不只是想截断遍历,还想向上报告一个明确的失败原因。组合模式这时候就跟责任链模式杂交了,每个节点既是树的一部分,又是一条判定链上的环节。我曾在权限管理组件里用过这个变体,整个组织部门呈树形,权限检查沿着部门向下走,某一层明确拒绝后,子部门完全不需要再检查。

5.3 与访问者模式叠加:把操作从节点里拆出去

一旦遍历带上“提前停止”的控制语义,就会引出另一个问题:每次新需求都要改 Component 接口加一个虚方法吗?这时候组合模式和访问者模式的杂交变体就来了。节点只提供 accept(visitor),具体逻辑全部放在访问者里,访问者返回值同样可以携带控制信号。

template <typename Context> struct IVisitor { virtual VisitStatus visit(Leaf* node, Context& ctx) = 0; virtual VisitStatus visit(Group* node, Context& ctx) = 0; };

这个变体的好处是,树结构只维护节点关系,具体“遍历时干什么”完全由访问者注入。遍历框架复用,业务逻辑继续膨胀时不用改树的接口,只加新的访问者。我在调试树形配置时,一个为了打印的结构体树访问者,一个为了统计存活节点的访问者,各干各的,互不干扰。

6. 表达式树里的组合模式变体:求值、化简、求导

6.1 计算器 AST 是最标准的组合模式

组合模式还有一个经典应用场景是表达式引擎,或者更宽泛地说抽象语法树。一个表达式1 + 2 * 3会被解析成一棵二叉树,数字是叶子节点,运算符是组合节点。求值操作天然适合放在节点自己的方法里。

class Expr { public: virtual ~Expr() = default; virtual double evaluate() const = 0; }; class NumberExpr : public Expr { public: explicit NumberExpr(double v) : value_(v) {} double evaluate() const override { return value_; } private: double value_; }; class BinaryExpr : public Expr { public: BinaryExpr(char op, std::shared_ptr<Expr> lhs, std::shared_ptr<Expr> rhs) : op_(op), lhs_(std::move(lhs)), rhs_(std::move(rhs)) {} double evaluate() const override { double l = lhs_->evaluate(); double r = rhs_->evaluate(); switch (op_) { case '+': return l + r; case '-': return l - r; case '*': return l * r; case '/': return l / r; default: throw std::runtime_error("unknown operator"); } } private: char op_; std::shared_ptr<Expr> lhs_; std::shared_ptr<Expr> rhs_; };

这是教科书组合模式在表达式树里的原样落地,也是我见过最标准的一个变体——节点持有子节点,操作由自身完成,外部调用者统一面对 Expr 指针。

6.2 把“节点自带求值”重构为“外部算法访问节点”

表达式引擎里很快会遇到一个新需求:要对 AST 做求导、打印、符号化简。如果每个操作都往 Expr 里加一个虚函数,那 Expr 会越来越臃肿,而且每加一种操作都要改所有节点。我在一个内部计算模块里把节点里的操作都拆了出去,节点只剩下数据结构和子节点,具体算法全部交给访问者。

访问者求值的核心是:节点不再自己 execute,而是 accept 一个操作对象,让操作对象在编译期或者运行期决定怎么处理。再配合之前第 5 章的短路语义,访问者还能实现“树中第一个可匹配规则的查找”这种操作,遍历提前结束,性能也省了。

6.3 怎么选:先想清楚树的规模、扩展方式、遍历形态

这篇文章跨度比较大,最后直接说说我在实务里的选择判断。先回答三个问题,答案基本能把变体定下来。

如果节点类型是编译期固定、最多三五类,优先用 std::variant 密封组合,让编译器穷尽检查所有分支。如果必须支持运行期加载外部模块的新节点,回退到经典虚函数接口模式,容器接受 Component 抽象指针。

如果树深度可能超过数千层,或者遍历必须支持暂停和续跑,用显式栈迭代器,或者上协程,别跟系统调用栈硬碰硬。如果遍历动作是带条件的,要提前跳过子树,返回值就使用状态码而不是 void,让组合节点在循环里检查收敛信号。

核心代码写出来都不复杂,复杂的是定义一个清晰的节点边界。很多组合模式走样,问题不在模式本身,而是节点类型职责不清:叶子节点塞了一堆组合节点的责任,组合节点又做了太多本应放进访问者的业务操作。边界清楚之后,再选什么变体心里就很笃定了。

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

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

立即咨询