
1. 组合模式的经典困局从一个文件树说起做C开发的人几乎都遇到过这类需求有一个不规则嵌套的结构文件系统是文件夹里套文件再套子文件夹UI控件树是面板里放按钮再放子面板表达式树是数字加上括号里的加法SQL的抽象语法树更是层层递归。遇到这种场景GoF里的组合模式Composite Pattern几乎是教科书般的第一反应。它的目标很单纯让单个对象和组合对象拥有一致的使用接口客户端面对一个文件和一个文件夹时不需要分辨它们内部的差异。过去几年我在不同项目里写过好几版组合模式实现从最早的虚函数多态到后来C17之后的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::vectorstd::unique_ptrComponent 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_uniqueDirectory(); root-name root; root-children.push_back(std::make_uniqueFile(File{a.txt, 1.5})); auto sub std::make_uniqueDirectory(); sub-name sub; sub-children.push_back(std::make_uniqueFile(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虚函数调用是间接跳转编译器不好内联。做游戏引擎里的场景管理、或者高频遍历的配置树时几百万个节点的树跑一遍下来虚分发的开销就变得扎眼。当年我被这三个问题反复折磨之后开始接触C17的std::variant。它给组合模式带来的改变几乎可以用“换了个思路”来形容。3. std::variant路线把“整体-部分”建模成带类型的联合体3.1 递归variant怎么绕开无限布局std::variant是C17正式引入的“带类型联合体”定义时把可能出现的类型全部列出来运行时只保存其中一种。它和union最大的区别是variant永远知道当前存的是哪一种类型你可以安全地用访问器处理再也不需要自己维护类型标签。我的第一个想法是这样写struct Directory; using Item std::variantFile, Directory;然后让Directory里存一个std::vector 。如果你真的试过编译器会直接甩你一脸错误Directory类不完整、variant要求所有替代类型完整并且sizeof确定。原因也不难理解——Directory里放着vector Item又可能装一个完整的Directoryvariant的空间需求是内部所有类型大小的最大值这就成了无穷递归的布局问题。生活里类比一下一个抽屉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::variantFile, std::shared_ptrDirectory; struct Directory { std::string name; std::vectorItem 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_ptrDirectory 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_sharedDirectory(); sub-name sub; sub-children.emplace_back(File{b.cpp, 12.0}); auto root std::make_sharedDirectory(); 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_ptrDirectory 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时间长了会觉得有点重。C17结合折叠表达式可以写一个很短的overloaded辅助类让visitor直接用lambda表达式就地定义templatetypename... Fs struct overloaded : Fs... { using Fs::operator()...; }; templatetypename... Fs overloaded(Fs...) - overloadedFs...;有了它同样的打印逻辑可以紧凑地写auto printer overloaded{ [](const File f) { std::cout f.name \n; }, [](const std::shared_ptrDirectory 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的基础形态CRTPCuriously Recurring Template Pattern是一种看起来有点“自我指涉”的模板技巧基类把自己的派生类作为模板参数接收进来基类内部用static_cast把this转成派生类指针从而完成“编译期多态”。典型写法templatetypename Derived struct Base { void run() { static_castDerived*(this)-runImpl(); } }; struct MyClass : BaseMyClass { void runImpl() { // 真正的实现 } };它和虚函数最大的区别在于run的调用在编译期就确定了完全没有vptr、没有间接跳转编译器很容易内联。代价是这个“多态”没有类型擦除能力——你不能把所有Base 都放进同一个容器里除非借助variant、any或者shared_ptr配合具体类型。4.2 用CRTP做组合节点的公共骨架把CRTP用进组合模式一个很自然的设计是公共基类提供遍历入口派生类只负责实现自身渲染细节。以控件树为例templatetypename Derived struct WidgetBase { std::string name; void render(int depth) const { static_castconst Derived*(this)-renderImpl(depth); } double totalSize() const { return static_castconst Derived*(this)-sizeImpl(); } };叶子控件和容器控件分别继承这个骨架节点之间的包含关系继续用std::variant表达struct Label; struct Panel; using Widget std::variantstd::shared_ptrLabel, std::shared_ptrPanel; struct Label : WidgetBaseLabel { std::string text; void renderImpl(int depth) const { std::cout std::string(depth * 2, ) name : text \n; } double sizeImpl() const { return static_castdouble(text.size()); } }; struct Panel : WidgetBasePanel { std::vectorWidget 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或std::shared_ptr 。于是w-render调用的是WidgetBase具体类型的render里面static_cast到对应派生类再调用renderImpl整个调用链条没有一次虚函数跳转。树还是这棵树组合关系还是那些组合关系但运行时开销和继承体系已经是两码事。这是我实际在性能敏感组件项目中比较偏爱的一种组合模式变体它保留了继承的代码复用优势却把虚表开销清零同时拥有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想算树高再继续加。每次加一个操作就从基类到所有叶子节点全部修改一遍这个味道谁闻谁知道。换成variantvisitor之后表达式类型的定义是冻结的操作全部外置。5.2 完整示例表达式树的三个visitor先定义表达式树为了完整展示递归这次用加法二元节点#include memory #include string #include variant struct Literal { double value; }; struct Add; using Expr std::variantLiteral, std::shared_ptrAdd; struct Add { std::shared_ptrExpr left; std::shared_ptrExpr right; };然后一口气写三个visitor。求值、中缀打印、计算深度struct Evaluator { double operator()(const Literal lit) const { return lit.value; } double operator()(const std::shared_ptrAdd 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_ptrAdd 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_ptrAdd add) const { return 1 std::max(std::visit(TreeDepth{}, *add-left), std::visit(TreeDepth{}, *add-right)); } };构造一个(1 2) 3的表达式调用方式统一Expr e std::make_sharedAdd(); auto inner std::make_sharedAdd(); inner-left std::make_sharedExpr(Literal{1.0}); inner-right std::make_sharedExpr(Literal{2.0}); auto outer std::make_sharedAdd(); outer-left std::make_sharedExpr(std::move(*inner)); // 简单示例 outer-right std::make_sharedExpr(Literal{3.0}); std::getstd::shared_ptrAdd(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。我踩过最典型的坑是在一个配置解析模块里一开始上了虚函数组合模式节点类型确实只有两三种但操作加了十几套打印、校验、翻译成内部数据结构、统计、排序……每个操作都要往所有节点类里塞实现节点类膨胀得厉害。后来我花了半天时间改成variantvisitor代码量几乎减半而且每加一种操作只需要新建一个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_ptrDirectory的析构函数必须在Directory定义完整之后隐式实例化否则默认删除器会在不完整类型上展开非常容易踩。提示递归variant设计时先画一张“谁持有谁”的依赖图凡是从结构体内直接引用包含自身的类型都要换成指针。7.2 运行期getT抛出bad_variant_accessstd::variant可以这样取数据try { auto f std::getFile(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在编译期找不到renderImplvariant在编译期找不到对应的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逐步排查类型名称如果你自己试完这三种写法会发现它们最终解决的是同一个核心问题如何让调用者用统一方式操作单个对象与组合对象。C17之后组合模式在工程上的选择已经远远超出一本老设计模式书能覆盖的范围。我的体会是先别急着给方案贴标签先回答“节点会变多还是操作会变多”这个问题。答案一旦清晰选哪条路几乎是顺理成章的。至于编译性能、调试体验这类工程因素等你真的在大型项目里把三种都试过一遍自然会形成肌肉记忆。