☰
C++组合模式三大变体:虚函数、std::variant与CRTP对比
2026/10/1 12:16:28 网站建设 项目流程

1. 组合模式的经典困局:从一个文件树说起

做C++开发的人,几乎都遇到过这类需求:有一个不规则嵌套的结构,文件系统是文件夹里套文件再套子文件夹,UI控件树是面板里放按钮再放子面板,表达式树是数字加上括号里的加法,SQL的抽象语法树更是层层递归。遇到这种场景,GoF里的组合模式(Composite Pattern)几乎是教科书般的第一反应。它的目标很单纯:让单个对象和组合对象拥有一致的使用接口,客户端面对一个文件和一个文件夹时,不需要分辨它们内部的差异。

过去几年我在不同项目里写过好几版组合模式实现,从最早的虚函数多态,到后来C++17之后的std::variant方案,再到配合CRTP做静态多态的组合结构,可以说踩了不少坑,也养成了自己的一套判断标准。标题里的“组合模式变体”其实不只是一个概念,而是现代C++给这个经典模式带来的好几条分岔路:经典继承多态是一条路,基于std::variant的封闭类型集合是一条路,CRTP的静态多态又是一条路。它们解决同一个问题,但代价、写法、可扩展性完全不同。

这篇文章就用一个文件树/控件树玩具项目作为贯穿案例,把这三种路线完整写出来,对比各自的原理和坑。代码都能直接编译跑,适合对C++有一定基础、开始研究设计模式和现代C++特性的朋友。看完之后,你至少能在下一次遇到“整体-部分”结构时,明确知道自己该选哪条路。

2. 经典多态实现:虚函数与继承那一套

2.1 用纯虚基类搭出树结构

先从最标准、最容易理解的版本出发。抽象基类Component定义统一接口,File作为叶子节点,Directory作为组合节点,内部持有子节点列表。代码如下:

#include <iostream> #include <memory> #include <string> #include <vector> struct Component { virtual ~Component() = default; virtual void render(int depth) const = 0; virtual double getSize() const = 0; }; struct File : Component { std::string name; double size = 0.0; void render(int depth) const override { std::cout << std::string(depth * 2, ' ') << name << " (" << size << " KB)\n"; } double getSize() const override { return size; } }; struct Directory : Component { std::string name; std::vector<std::unique_ptr<Component>> children; void render(int depth) const override { std::cout << std::string(depth * 2, ' ') << name << "/\n"; for (const auto& child : children) { child->render(depth + 1); } } double getSize() const override { double total = 0.0; for (const auto& child : children) { total += child->getSize(); } return total; } }; int main() { auto root = std::make_unique<Directory>(); root->name = "root"; root->children.push_back(std::make_unique<File>(File{"a.txt", 1.5})); auto sub = std::make_unique<Directory>(); sub->name = "sub"; sub->children.push_back(std::make_unique<File>(File{"b.cpp", 12.0})); root->children.push_back(std::move(sub)); root->render(0); std::cout << "total: " << root->getSize() << " KB\n"; return 0; }

这段代码的优点一眼可见:客户端拿到的永远是Component这个抽象类型,调用render和getSize时,多态自动决定是文件还是文件夹,目录自己负责递归。对于调用者来说,文件和文件夹的差别被彻底抹掉,这正是组合模式要的“透明性”。

2.2 这条路线真正难受的地方

经典方案表面看很完美,我实际使用中却频繁遇到三个问题。

第一个是新加一种节点非常痛苦。假设文件系统里要加一个“压缩包”节点,它外层看起来是个文件,双击进去又是一个虚拟目录。放到这套继承体系里,你得让CompressedArchive同时具备File和Directory两种行为,要么被迫多继承,要么在类里塞一堆空实现。更麻烦的是,如果哪天要在基类Component里加一个getPermission纯虚函数,所有子类都要跟着改,这就是著名的脆弱基类问题。加一个操作,整棵继承树都要动,开闭原则被按在地上摩擦。

第二个问题是遍历逻辑被拆散到了每个类里。Directory::render里写children的遍历,File::render里写文件的打印。想要增加一种“按扩展名统计文件大小”的遍历方式,没有虚函数版本就只能往下加一个统计函数,然后继续在每个类里塞实现。代码量一大,每颗节点都背着越来越多的职责,类膨胀得很厉害。

第三个问题很实际——性能。每个对象要趴一个vptr,虚函数调用是间接跳转,编译器不好内联。做游戏引擎里的场景管理、或者高频遍历的配置树时,几百万个节点的树跑一遍下来,虚分发的开销就变得扎眼。

当年我被这三个问题反复折磨之后,开始接触C++17的std::variant。它给组合模式带来的改变,几乎可以用“换了个思路”来形容。

3. std::variant路线:把“整体-部分”建模成带类型的联合体

3.1 递归variant怎么绕开无限布局

std::variant是C++17正式引入的“带类型联合体”,定义时把可能出现的类型全部列出来,运行时只保存其中一种。它和union最大的区别是:variant永远知道当前存的是哪一种类型,你可以安全地用访问器处理,再也不需要自己维护类型标签。

我的第一个想法是这样写:

struct Directory; using Item = std::variant<File, Directory>;

然后让Directory里存一个std::vector 。如果你真的试过,编译器会直接甩你一脸错误:Directory类不完整、variant要求所有替代类型完整并且sizeof确定。原因也不难理解——Directory里放着vector ,Item又可能装一个完整的Directory,variant的空间需求是内部所有类型大小的最大值,这就成了无穷递归的布局问题。生活里类比一下:一个抽屉(variant)想装下一个完整的房间(Directory),房间里面又有抽屉,谁也说不清抽屉到底该多大。

解决办法是打破“直接包含”,改成间接持有。用shared_ptr作为组合节点的引子,树还是树,但对象布局不再无限嵌套:

#include <iostream> #include <memory> #include <string> #include <vector> #include <variant> struct File { std::string name; double size = 0.0; }; struct Directory; using Item = std::variant<File, std::shared_ptr<Directory>>; struct Directory { std::string name; std::vector<Item> children; };

现在Item的价值是:File作为叶子直接存值,Directory作为组合节点只存一个shared_ptr。树的递归关系通过指针表达,布局无限递归的问题彻底消除。这段结构非常像原来Component继承体系里的树,但已经没有了任何虚函数。

3.2 用std::visit统一处理:分类讨论的究极形态

节点类型定义完了,遍历怎么实现?std::visit是专门用来“看variant里现在装的是什么”的工具,给它一个visitor,它根据当前存储的类型自动调用对应的重载函数。文件树的打印可以写成这样:

struct TreePrinter { int depth = 0; void operator()(const File& f) const { std::cout << std::string(depth * 2, ' ') << f.name << " (" << f.size << " KB)\n"; } void operator()(const std::shared_ptr<Directory>& d) const { std::cout << std::string(depth * 2, ' ') << d->name << "/\n"; TreePrinter childPrinter{depth + 1}; for (const auto& child : d->children) { std::visit(childPrinter, child); } } }; int main() { auto sub = std::make_shared<Directory>(); sub->name = "sub"; sub->children.emplace_back(File{"b.cpp", 12.0}); auto root = std::make_shared<Directory>(); root->name = "root"; root->children.emplace_back(File{"a.txt", 1.5}); root->children.emplace_back(sub); std::visit(TreePrinter{0}, Item{root}); return 0; }

如果还需要统计大小,完全不需要动File和Directory的定义,再写一个visitor就行:

struct SizeCounter { double operator()(const File& f) const { return f.size; } double operator()(const std::shared_ptr<Directory>& d) const { double total = 0.0; for (const auto& child : d->children) { total += std::visit(SizeCounter{}, child); } return total; } }; double total = std::visit(SizeCounter{}, Item{root});

这个方案最大的变化是:节点的数据描述和操作逻辑彻底分开了。File和Directory只是纯数据容器,所有遍历、统计、序列化的行为全部作为独立visitor存在。想加一种新操作?写一个新visitor;想加一种新节点?往variant里加类型,然后编译器会逼你把所有visit的重载补齐。这恰好和虚函数方案形成镜面对称:虚函数方案扩展节点容易,但加操作痛苦;variant方案加操作容易,加节点时会引发所有visitor的修改。

3.3 overloaded模式:让visitor就地成团

每次写树打印都要声明一个struct,时间长了会觉得有点重。C++17结合折叠表达式,可以写一个很短的overloaded辅助类,让visitor直接用lambda表达式就地定义:

template<typename... Fs> struct overloaded : Fs... { using Fs::operator()...; }; template<typename... Fs> overloaded(Fs...) -> overloaded<Fs...>;

有了它,同样的打印逻辑可以紧凑地写:

auto printer = overloaded{ [](const File& f) { std::cout << f.name << "\n"; }, [](const std::shared_ptr<Directory>& d) { std::cout << d->name << "/\n"; for (const auto& child : d->children) { std::visit(printer, child); // 注意递归需要先声明变量 } } };

lambda递归有一点绕:visitor对象在lambda体里引用自己,得先把变量声明放在前面,或者用一个引用包装。实际项目中我通常选择为复杂组合树写具名struct visitor,只有在分支逻辑足够简单时才用overloaded。overloaded的价值更多在于把一组互不相关的分支逻辑就地拼成一个访问器,尤其在表达式求值这种场景里特别顺手。

4. CRTP变体:继承还在,运行时分发没了

4.1 CRTP的基础形态

CRTP(Curiously Recurring Template Pattern)是一种看起来有点“自我指涉”的模板技巧:基类把自己的派生类作为模板参数接收进来,基类内部用static_cast把this转成派生类指针,从而完成“编译期多态”。典型写法:

template<typename Derived> struct Base { void run() { static_cast<Derived*>(this)->runImpl(); } }; struct MyClass : Base<MyClass> { void runImpl() { // 真正的实现 } };

它和虚函数最大的区别在于:run的调用在编译期就确定了,完全没有vptr、没有间接跳转,编译器很容易内联。代价是,这个“多态”没有类型擦除能力——你不能把所有Base 都放进同一个容器里,除非借助variant、any或者shared_ptr配合具体类型。

4.2 用CRTP做组合节点的公共骨架

把CRTP用进组合模式,一个很自然的设计是:公共基类提供遍历入口,派生类只负责实现自身渲染细节。以控件树为例:

template<typename Derived> struct WidgetBase { std::string name; void render(int depth) const { static_cast<const Derived*>(this)->renderImpl(depth); } double totalSize() const { return static_cast<const Derived*>(this)->sizeImpl(); } };

叶子控件和容器控件分别继承这个骨架,节点之间的包含关系继续用std::variant表达:

struct Label; struct Panel; using Widget = std::variant<std::shared_ptr<Label>, std::shared_ptr<Panel>>; struct Label : WidgetBase<Label> { std::string text; void renderImpl(int depth) const { std::cout << std::string(depth * 2, ' ') << name << ": " << text << "\n"; } double sizeImpl() const { return static_cast<double>(text.size()); } }; struct Panel : WidgetBase<Panel> { std::vector<Widget> children; void renderImpl(int depth) const { std::cout << std::string(depth * 2, ' ') << name << " [panel]\n"; for (const auto& child : children) { std::visit([depth](const auto& w) { w->render(depth + 1); }, child); } } double sizeImpl() const { double total = 0.0; for (const auto& child : children) { std::visit([&total](const auto& w) { total += w->totalSize(); }, child); } return total; } };

注意std::visit里的lambda接受auto参数,w的具体类型在编译期就是std::shared_ptr

这是我实际在性能敏感组件项目中比较偏爱的一种组合模式变体:它保留了继承的代码复用优势,却把虚表开销清零,同时拥有variant方案的行为集中好处。缺点同样明显——类型名称巨长,IDE调试时每层模板都让人头大;并且Label和Panel之间没有公共基类,想写函数统一接收“任意控件”时,只能通过std::variant或模板函数实现,抽象层多了一层。

4.3 CRTP方案要付出的心智代价

说句实话,CRTP变体并不适合所有人。如果只是业务代码里处理一个三层以内的配置树,用CRTP属于过度设计。它的模板语法对新手不算友好,报错信息更要命——static_cast调用不存在的函数时,错误信息会从模板实例化的最深处蹦出来,夹杂一堆由复杂类型名组成的乱码。我第一次写这种结构时,光是搞清楚“为什么renderImpl找不到”就花了大半个小时,后来才明白是派生类忘记写这个函数。

但反过来,它的优势也是虚函数方案给不了的:新增一个操作时,在基类WidgetBase里加一个模板方法,派生类无需全部修改;而漏实现某个具体行为时,编译器会直接报错,而不是静默地调用基类里的默认实现。对“忘记实现”这种事,静态多态的容错率几乎为零,但这反而是一种保护。

5. 访问者与组合的深度融合:一次搞定求值、打印、序列化

5.1 数据结构稳定、操作多变时,该选访问者

把std::variant和组合模式放在一起,实际上已经隐含着访问者模式的影子。std::visit本身就是“语言级访问者”:variant是被访问者,visitor是实际操作,二者通过编译器生成的索引跳转完成对应。组合模式的真正价值在操作复杂时展现得淋漓尽致。

以表达式树为例,节点只有两种:数字Literal和加法Add。如果用虚函数方案算值,要在基类里加eval,再在子类里各自实现;如果想打印中缀表达式,继续在基类里加toString;想算树高,再继续加。每次加一个操作,就从基类到所有叶子节点全部修改一遍,这个味道谁闻谁知道。换成variant+visitor之后,表达式类型的定义是冻结的,操作全部外置。

5.2 完整示例:表达式树的三个visitor

先定义表达式树,为了完整展示递归,这次用加法二元节点:

#include <memory> #include <string> #include <variant> struct Literal { double value; }; struct Add; using Expr = std::variant<Literal, std::shared_ptr<Add>>; struct Add { std::shared_ptr<Expr> left; std::shared_ptr<Expr> right; };

然后一口气写三个visitor。求值、中缀打印、计算深度:

struct Evaluator { double operator()(const Literal& lit) const { return lit.value; } double operator()(const std::shared_ptr<Add>& add) const { return std::visit(Evaluator{}, *add->left) + std::visit(Evaluator{}, *add->right); } }; struct Printer { std::string operator()(const Literal& lit) const { return std::to_string(lit.value); } std::string operator()(const std::shared_ptr<Add>& add) const { return "(" + std::visit(Printer{}, *add->left) + " + " + std::visit(Printer{}, *add->right) + ")"; } }; struct TreeDepth { int operator()(const Literal&) const { return 1; } int operator()(const std::shared_ptr<Add>& add) const { return 1 + std::max(std::visit(TreeDepth{}, *add->left), std::visit(TreeDepth{}, *add->right)); } };

构造一个(1 + 2) + 3的表达式,调用方式统一:

Expr e = std::make_shared<Add>(); auto inner = std::make_shared<Add>(); inner->left = std::make_shared<Expr>(Literal{1.0}); inner->right = std::make_shared<Expr>(Literal{2.0}); auto outer = std::make_shared<Add>(); outer->left = std::make_shared<Expr>(std::move(*inner)); // 简单示例 outer->right = std::make_shared<Expr>(Literal{3.0}); std::get<std::shared_ptr<Add>>(e) = outer; double val = std::visit(Evaluator{}, e); std::string str = std::visit(Printer{}, e); int depth = std::visit(TreeDepth{}, e);

这三个visitor不是虚构的装饰,而是项目里天天在用的模板需求。求值对应真实解释器,打印对应反编译输出,深度对应静态分析或树平衡计算。更现实一点,可以把打印visitor换成JSON序列化函数,把求值visitor换成类型检查器。每加一个需求,只需要新增一个visitor,表达式节点本身一行不动。这种“数据结构冻结,操作集中扩展”的体验,和经典继承方案完全是两个世界。

5.3 新增节点类型时,编译器逼你把所有visitor补齐

访问者路线有一面是非常双刃的:当你往variant里加一种新节点,比如Mul乘法节点,改写所有visitor是必须的。std::visit在编译期检查visitor是否对所有alternative可调用,漏掉任何一个,编译器直接给你报一个长到离谱的模板错误。初看是麻烦,但我后来发现这其实是好事,它把“忘记处理新节点”从运行期Bug变成了编译期错误。经典虚函数方案里,基类添加一个纯虚函数,编译器同样会逼你补齐实现;但如果新增一个类,而某操作忘了在这个类里实现,则可能静默继承基类默认行为,跑起来才发现结果不对。两者相比,variant方案的显式检查让我在改节点的路上安心很多。

当然,如果你设计的系统里节点类型非常开放,比如插件的控件系统,随时可能有外部团队注册新的节点类型,那variant就是一个错误选择——封闭类型集合根本不接受运行时扩展。那种场景老老实实用虚函数继承,或配合类型注册表才是对的。方案没有绝对的优劣,场景先于架构。

6. 三种方案怎么选:对照表与实际决策建议

6.1 五个维度的横向对比

这里把三种方案放到一张表里,方便翻阅:

维度虚函数多态std::variantCRTP结合variant
运行时开销虚表指针+间接调用一个index标记+编译期跳转近似零间接调用,可内联
新增节点类型容易,继承后实现接口即可改variant定义,并且所有visitor要补齐改variant定义,且CRTP实现要补齐
新增操作需要改基类和所有节点类新增一个visitor即可新增一个visitor或基类模板方法
调试直观性gdb能看到具体类型和虚表variant带index,需要看alternative编号模板类型名长到怀疑人生
适用场景公共SDK、插件系统、开放继承封闭业务、表达式树、配置解析性能敏感、类型集合有限的内部模块

一句话总结:虚函数方案赚的是类型扩展的灵活,亏的是操作扩展的繁琐;variant方案赚的是操作扩展的干净,亏的是类型扩展的联动;CRTP方案赚的是运行期性能,亏的是模板复杂度和调试体验。

6.2 我的选型习惯与经验

实际项目里,我现在的判断逻辑已经固定成三个问题。第一,节点类型集合是否会持续膨胀?如果答案是不会,比如文件系统就文件、目录这么几种,那直接用std::variant。第二,操作是否经常增加?如果增加得多,继续用variant;如果操作基本稳定,虚函数方案也不差。第三,遍历是否在热路径上,节点数量是否大?如果百万级别且频繁遍历,虚函数方案会被排除,直接看CRTP还是variant。

我踩过最典型的坑是在一个配置解析模块里,一开始上了虚函数组合模式,节点类型确实只有两三种,但操作加了十几套:打印、校验、翻译成内部数据结构、统计、排序……每个操作都要往所有节点类里塞实现,节点类膨胀得厉害。后来我花了半天时间改成variant+visitor,代码量几乎减半,而且每加一种操作只需要新建一个visitor文件,再也不用打断原有节点的逻辑。那次重构之后,我对“数据结构稳定、操作多变”这个组合模式选型信号特别敏感。

相反,在另一个偏插件化的编辑器框架里,控件的类型由各个业务团队自行注册,谁也不知道下个季度会冒出来什么控件。这种场景下我老老实实退回虚函数继承,因为编译期封闭的variant根本无法承载这种开放式扩展。C++组合模式的厉害之处就在于:它不是一个答案,而是给你一串选项,关键看你是想让类型扩展自由,还是让操作扩展自由。

7. 常见问题与排查实录

下面这些问题全是我自己或身边同事在实现组合模式变体时真实遇到过的,每一个都对应一段痛苦的调试经历。

7.1 编译期:variant递归导致的incomplete type

报错关键字通常是incomplete type,位置指向std::variant内部的某个静态断言。根本原因早在第3节讲过:variant直接包含Directory,而Directory又持有variant,对象布局无法确定。解法只有一个认准的方向——用shared_ptr、unique_ptr这类指针把递归链打破。我建议直接用shared_ptr,因为递归结构里同一节点可能被多个父节点引用,unique_ptr会让所有权管理变得复杂。另外注意,如果用unique_ptr,Directory的析构函数必须在Directory定义完整之后隐式实例化,否则默认删除器会在不完整类型上展开,非常容易踩。

提示:递归variant设计时先画一张“谁持有谁”的依赖图,凡是从结构体内直接引用包含自身的类型,都要换成指针。

7.2 运行期:get<T>抛出bad_variant_access

std::variant可以这样取数据:

try { auto& f = std::get<File>(item); } catch (const std::bad_variant_access& e) { // 类型不匹配 }

但很多初写variant代码的人不习惯用try,而是直接get,一旦节点实际存的是shared_ptr ,就会抛异常。如果你在组合树里频繁遍历,get<>这种“赌类型”的写法几乎必然踩雷。正确姿势是先判断holds_alternative ,或者干脆用std::visit统一分发。我个人几乎不用get,除非我真的提前知道variant里存的必是某种类型。

7.3 设计期:虚函数默认实现掩盖遗漏

回到经典虚函数方案,有一个特别隐蔽的问题:基类提供了默认行为,派生类忘记覆盖时不会报错,只会默默执行那个默认逻辑。比如Component基类里写了一个空的render默认实现,文件类忘了override,渲染结果就少一块,而且没有任何编译期提示。这种问题在大型继承树里排查起来非常费时。相比之下,CRTP和variant方案都会把“缺失实现”暴露出来:CRTP在编译期找不到renderImpl,variant在编译期找不到对应的visitor重载。这也是为什么我现在越来越不喜欢用带默认动作的虚基类做组合模式,宁可让编译器多吼我几声。

7.4 生命周期:shared_ptr循环引用

用shared_ptr表达树节点时,如果父节点和子节点互相持有shared_ptr,就会形成循环引用,节点永远不会析构。组合模式里的树本应该是有向无环的,但业务代码里偶尔会因为“拿到子节点反查父节点”的需求给子节点加上向父节点的shared_ptr。一旦这么干,父子结构就出现环,析构函数永远等不到引用计数归零。我的做法是:从父到子用shared_ptr,从子到父用weak_ptr。在节点内部保存一个std::weak_ptr 指向父节点,既保证能反查,又不破坏析构。

7.5 实践速查表

症状可能原因解决办法
编译报incomplete typevariant直接递归包含自身用shared_ptr/unique_ptr打破递归
std::visit漏掉某个分支的编译错误新增了variant类型,visitor没更新补齐对应重载,或使用overloaded模式
get<>抛bad_variant_access类型判断靠猜导致改用visit或get_if
树节点析构不了父子shared_ptr互相引用反向引用改weak_ptr
忘记实现某个操作,行为缺失虚函数默认实现被继承把默认实现改成纯虚,或改用CRTP/variant
模板报错信息难以阅读CRTP或variant实例化链太长抽small test case逐步排查类型名称

如果你自己试完这三种写法,会发现它们最终解决的是同一个核心问题:如何让调用者用统一方式操作单个对象与组合对象。C++17之后,组合模式在工程上的选择已经远远超出一本老设计模式书能覆盖的范围。我的体会是,先别急着给方案贴标签,先回答“节点会变多还是操作会变多”这个问题。答案一旦清晰,选哪条路几乎是顺理成章的。至于编译性能、调试体验这类工程因素,等你真的在大型项目里把三种都试过一遍,自然会形成肌肉记忆。

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

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

立即咨询