ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

C++组合模式实战指南:从经典实现到std::variant变体

C++组合模式实战指南:从经典实现到std::variant变体 搞C的谁没被嵌套结构恶心过窗口套面板、面板套按钮目录套文件场景节点套子节点。这套“部分-整体”的树形关系语法上次次手写递归跑起来又到处是if判断类型。后来我用组合模式Composite Pattern把这一坨理顺了再后来我在项目里越用越觉得教科书那套模板不够用开始折腾各种变体。这篇就聊聊我在C里对组合模式及其变体的实战经验从经典双流派到防环、共享、模板化、std::variant改造一次性讲透。组合模式这个名字你可能不陌生但说实话很多人的理解停留在“树形结构叶子节点”这个层面上。实际落地到C项目里要处理的问题远不止画一张类图那么简单内存谁管、递归多深、叶子调Add怎么处理、节点能不能被多个父亲引用、要不要缓存统计数据。这些问题教科书基本不提但项目里天天见。我写这篇文章就是想把这些变体和坑一次性理清楚给正在设计场景树、菜单系统、表达式计算器或者任何“部分-整体”结构的读者一份能直接抄作业的参考。1. 组合模式到底解决了什么问题为什么C项目里绕不开1.1 从一棵菜单树说起组合模式的本质先看一个最常见的场景。你在做一个编辑器的菜单系统菜单里有“文件”文件下面有“打开”“保存”“最近文件”最近文件下面又有一串文件名。对上层代码来说“文件”是一个可以点击的条目“最近文件”也是一个可以点击的条目但它们内部的结构完全不同。如果不做任何抽象你的渲染函数会长这样void render(const MenuItem item) { if (item.type Type::LEAF) { draw_button(item.name); } else if (item.type Type::SUBMENU) { draw_menu(item.name); for (auto child : item.children) render(child); } }这写法一看就有问题每加一种节点类型render、find、count这些函数全要跟着加一个else if而且这些函数散落在多个文件里。今天加个“分隔线”类型明天加个“复选框”类型所有遍历逻辑全部沦陷。组合模式的核心思路就是把这个树形结构抽象成统一的接口。不管叶子还是树枝对外都是同一个类型客户端调用接口时根本不关心自己握着的到底是一个单节点还是一整棵子树。class Component { public: virtual ~Component() default; virtual void render() const 0; virtual const std::string name() const noexcept 0; }; class Leaf : public Component { /* ... */ }; class Composite : public Component { std::vectorstd::unique_ptrComponent children_; public: void add(std::unique_ptrComponent child); void render() const override; };这样做的收益很直接第一客户端代码比如寻找某个菜单项、统计菜单深度、渲染整棵树只需要依赖Component一个类型第二新增节点类型只需要新增一个派生类不需要改动任何遍历逻辑。用一句话概括组合模式把“单对象”和“对象集合”在接口层拉平了让客户端可以用同一种方式对待它们。1.2 什么时候该用什么时候硬套是给自己挖坑组合模式好归好不是所有树形结构都值得套。我见过有人为了一个只有两种节点、深度不超过3的小配置树硬是抽象出三层虚函数结构结果代码量翻倍性能还更差。这个模式的核心适用场景有三个硬指标你确实有“部分-整体”的层级关系整体可以像部分一样被操作。客户端希望无视整体与部分的区别统一处理所有节点。节点类型未来有明显的扩展预期或者类型数量已经多到if-else崩溃。反过来如果你的树很浅、节点类型很少且几乎不变直接用struct Node { int type; vectorNode* children; }加switch可能是更务实的选择。组合模式的价值在于屏蔽类型差异如果差异本身只有两三种屏蔽的收益撑不起抽象的成本。C项目还有一个Java那套组合模式不太遇到的特殊问题内存管理。Java里new出来的节点有GC兜底C里你得明确每个子节点的所有权。这直接导致C组合模式在实现层面和教科书示例有巨大差异后面我会单独讲这个问题。2. 教科书之外的两种经典变体透明式与安全式2.1 透明式接口统一但埋着雷翻开任意一本设计模式书组合模式的类图都是把add()、remove()、getChild()放在基类Component里子类Leaf对这些方法给个空实现或者直接抛异常。这种叫透明式因为对外接口完全一致客户端不需要做任何向下转型。C代码写出来大概是这个感觉class Component { public: virtual ~Component() default; virtual void render() const 0; virtual void add(std::unique_ptrComponent child) { throw std::logic_error(leaf cannot add child); } virtual std::size_t childrenCount() const noexcept { return 0; } }; class Leaf : public Component { std::string name_; public: explicit Leaf(std::string name) : name_(std::move(name)) {} void render() const override { std::cout name_ \n; } }; class Composite : public Component { std::vectorstd::unique_ptrComponent children_; public: void add(std::unique_ptrComponent child) override { children_.push_back(std::move(child)); } std::size_t childrenCount() const noexcept override { return children_.size(); } void render() const override { for (auto child : children_) child-render(); } };好处很明显写遍历的时候不用判断类型一个Component*走天下。但坑也很明显——你手里攥着一个Leaf调用add()编译期一片祥和运行期直接抛异常。这种“编译期合法、运行期爆炸”的接口设计说白了就是用异常来兜底一个本不该存在的方法。逻辑上是合理但实际开发里团队新人很容易踩拿到一个Component指针就往上挂子节点挂了才发现是个叶子线上日志只留下一句logic_error。透明式还有个C特有的编译期问题unique_ptr在基类没有虚析构的时候会造成未定义行为。上面代码里写了虚析构但如果漏了unique_ptrComponent析构时就会切片删除。这不是组合模式独有的问题但是组合模式大量使用基类指针放大了这个风险。2.2 安全式把add/remove从基类里踢出去安全式就是把add()、remove()这些孩子管理方法从基类里移除只放在Composite里。基类只保留“节点本身”该有的行为比如渲染、命名、获取属性。class Component { public: virtual ~Component() default; virtual void render() const 0; }; class Leaf : public Component { std::string name_; public: explicit Leaf(std::string name) : name_(std::move(name)) {} void render() const override { std::cout name_ \n; } }; class Composite : public Component { std::vectorstd::unique_ptrComponent children_; public: void render() const override { for (auto child : children_) child-render(); } void add(std::unique_ptrComponent child) { children_.push_back(std::move(child)); } };安全式的最大优点是类型安全。你手里是Composite*才能调add()编译器在源头就拦住了叶子挂孩子这种操作。但代价是客户端写遍历时代码变丑了void renderAll(const Component node) { node.render(); if (auto* composite dynamic_castconst Composite*(node)) { for (auto child : composite-children_) renderAll(*child); } }每次遍历都要dynamic_cast而dynamic_cast是有运行时开销的。如果一棵树在游戏循环里每帧遍历一次这个开销会让你肉疼。而且dynamic_cast依赖RTTI有些嵌入式C环境是关掉RTTI的那条路直接堵死。所以怎么选完全看场景如果你代码里不走遍历、或者遍历次数少安全式的类型安全收益是实打实的如果你需要高频遍历且不想背RTTI开销就还得是透明式加文档约束。我个人的折中方案是默认透明式但在基类里把add()做成[[nodiscard]]返回bool的版本叶子返回false并写日志而不是抛异常。这样既保留了统一遍历的能力又不会让应用在运行期爆炸。这个可以算是第三种变体我管它叫“容错式”后面实操那一节会给出完整实现。2.3 C的特殊性自己管内存绕不开的拷贝与析构在C里实现组合模式最绕不开的就是内存管理。Java和C#那种“new一个节点丢进列表GC自动清理”的写法在C里直接抄就是内存泄漏。C有几种主流做法我一个个说清楚。第一种是unique_ptr持有子节点推荐默认使用。父子关系明确树析构时自动递归释放所有子节点不需要自己写复杂度为O(n)的递归析构。但unique_ptr意味着组合模式里的“组合”具有独占性同一节点不能被两个父亲引用。大部分场景——菜单、场景树、文件系统——恰好都是独占的所以这是默认选择。第二种是shared_ptr持有子节点用在需要共享节点的场景。比如一个“素材”同时被“素材库”和“当前使用列表”两棵树引用时就得共享。但共享树有个连带麻烦如果子节点反过来持有父节点的shared_ptr就会形成循环引用析构函数永远不会执行。这个问题我在第3章会详细讲。第三种是裸指针加外部生命周期管理。某些性能敏感的中间件会从内存池里分配节点树的析构不负责释放内存统一由内存池回收。这种方式性能最好但使用边界要非常清晰否则就是悬空指针和use-after-free的温床。拷贝方面组合模式的树通常禁止拷贝或者只做深拷贝。unique_ptr直接让拷贝构造函数被删除这是好事防止你无意中做出浅拷贝然后两个树共享子节点。真需要拷贝树比如做原型模式时手动实现一个clone()虚函数逐层深拷贝比试图定义拷贝构造函数干净得多。3. 实战中真正改变模式形态的三个变体3.1 防环组合引用计数防递归爆炸很多人在实现组合树遍历时都默认这是一棵树——树天然无环。但项目一旦复杂起来你或同事可能会写出把一个父节点挂进自己后代的代码。这在图结构里是合法的但在树结构里意味着遍历永远无法结束最终爆栈。最常见的成环路径是什么共享节点。你用shared_ptr把节点A挂在B下面然后又把B挂在A下面两边互相引用不仅遍历死循环内存还不释放。我在一个资源管理模块里就遇过这种事故美术资源节点被挂到资源组下资源组又被挂到根节点下有人图省事直接代码构造时把根节点指针塞进资源组内部整个编辑器一打开场景树就卡死。解决办法有两种。第一种是深度优先遍历时带一个“访问标记集合”遍历前先查节点是否已访问过这是通用防环方案任何树形结构都能用但开销在hash查找上。第二种是把防环放在结构维护阶段每次add()子节点时从新节点往父方向走一遍如果走到了当前节点自己就拒绝添加。这个检查虽然发生在插入时但只朝父方向走深度通常不大性能开销可以接受。实现时比较讲究你需要在add()之前先试着自己维护父指针然后在add()内部完成合法性校验。这里我想提醒一点永远不要在组合树已经成环之后再去“修复”——修的过程本身可能触发遍历而且很难定位是谁挂了谁。最好的策略是把这条规则做进add()里让任何合法的插入路径天然无环。3.2 共享节点当同一节点被多个父节点引用组合模式教科书默认树是独占的但现实中“同一个对象出现在多个地方”太常见了。比如一个UI系统里“确认”按钮同时出现在对话框和工具栏里一个游戏引擎里一个模型同时被多个场景引用。这种场景用unique_ptr直接无解必须上shared_ptr。using ComponentPtr std::shared_ptrComponent; class Composite : public Component { std::vectorComponentPtr children_; public: void add(ComponentPtr child) { // 注意需要检查child是否已经是自己的祖先 children_.push_back(std::move(child)); } };共享节点带来的第一个问题是生命周期变得不直观。shared_ptr的引用计数决定了节点析构时机如果树A和树B都引用同一个节点就必须等A和B都销毁节点才释放。这是合理的但调试时你可能会发现“明明把节点从树里移除了内存却没释放”——大概率是另一个地方还持有shared_ptr。第二个问题是“修改一处处处生效”。共享节点意味着同一个对象在逻辑上出现在多个父节点下对它的子结构修改会同时影响所有引用它的地方。这在一些场景下正是我们想要的纽件调整一次所有地方同步更新但如果没意识到这个语义改了一处以为只有一处变排查现场会让你极度痛苦。第三个问题是第3.1节说的环引用。两个shared_ptr组合互指引用计数永远不为零内存泄漏。这时要引入weak_ptr来打破环子节点回指父亲的指针设为weak_ptr只观察不持有。也就是说整棵树从上往下全是shared_ptr从下往上全是weak_ptr这样既满足共享需求又不至于循环泄漏。这个写在代码注释里后来维护的人会感谢你。3.3 模板化组合用泛型消灭重复代码如果你要构建的不止一个组件树而是“菜单树”“资源树”“场景节点树”各来一套就会发现每个版本的结构代码长得一模一样只是节点Type不同。这时候可以把组合结构本身抽成一个模板。template typename NodeData class TreeNode { NodeData data; std::vectorstd::unique_ptrTreeNode children; public: void add(std::unique_ptrTreeNode child); template typename F void preorder(F visit); }; class MenuItem { std::string text; bool visible; }; class SceneNode { glm::mat4 transform; Mesh* mesh; };模板化组合带的收益很直接树的遍历、深度统计、序列化这些基础设施只写一遍所有业务树复用。C模板在编译期生成代码运行时没有任何额外开销比运行时抽象更高效。但模板化不是没代价。第一NodeData必须满足统一的操作约束比如都要有name()不然遍历逻辑没法统一。这时需要给NodeData定义统一概念要么用C20的concept显式约束要么用SFINAE隐式约束不约束就是一堆天书报错。第二如果你需要把不同类型的树放在同一个容器里模板化就帮不上忙了要么加一层类型擦除要么退回虚函数。模板适合“多棵结构相同、数据类型不同”的场景不适合“一棵树里混合N种类型”的场景这个要自己判断。4. 现代C视角std::variant与类型擦除对组合模式的改造4.1 用std::variant构造扁平组合树传统组合模式的所有复杂度都来自“基类指针虚函数”。如果想避开动态多态现代C提供了一个非常漂亮的替代品std::variant。思路是这样的不再抽象一个Component基类而是把节点能有的所有“形状”做成一个联合体树结构用std::vectorNode表达Node可以递归引用自己通过std::vector做一层间接。struct Node; using NodeList std::vectorNode; struct LeafData { std::string text; // 叶子特有字段 }; struct CompositeData { std::string title; NodeList children; }; struct Node { std::string name; std::variantLeafData, CompositeData data; };递归定义的结构Node里有个NodeList而NodeList又是Node的vector这在C17里直接这么写会编译失败因为NodeList要求知道Node的完整定义。解决办法是用std::vector自带的“允许不完整类型”特性或者把NodeList改成std::vectorstd::unique_ptrNode。前者需要C17支持不完整类型的vector后者更传统一些两种都行。这个方案的精髓在于把“哪些类型是节点”从运行期的虚函数表挪到了编译期的variant备选列表里。你的代码里不再有dynamic_cast而是用std::visit来精确处理每一种节点。void render(const Node node) { std::visit(overloaded{ [](const LeafData d) { std::cout d.text \n; }, [](const CompositeData d) { std::cout d.title \n; for (auto child : d.children) render(child); } }, node.data); }overloaded是借用C17的using声明技巧把这几个lambda合体成单个可调用对象这个惯用法在CppReference上叫“overload pattern”。std::visit和自己实现一个switch(type_index)最大的区别是编译器会强制检查你是否处理了所有类型。你每加一种新节点所有visit的地方都会编译报错提醒你补case这就是静态分发的安全红利。4.2 无虚函数的组合与性能变化std::variant方案在性能上的优势非常显著。虚函数调用从汇编层面看是两次间接跳转查vptr、查函数指针而且无法内联std::visit虽然内部有一个小的type index跳转但后续访问的都是具体类型编译器可以大胆内联。现代编译器甚至能把一系列连续的类型判断优化成跳转表比虚函数少一层缓存未命中。更重要的性能差异在内存布局上。虚函数方式的节点是“指针串起来的碎片每个子节点都是new出来的对象散落在堆的各处”遍历时缓存不命中爆炸尤其在游戏/图形引擎这类场景下一帧要遍历几千个场景节点cache miss带来的延迟可能比计算本身还高。variant方案可以做到子节点紧挨着父节点存放在一个std::vector里整个子树的内存是连续的遍历顺序天然符合cache预取。实测在渲染场景树场景下variant版本比虚函数版本能快20%50%节点越密集差距越大。但这个性能优势不是白给的。std::variantNode, LeafData, CompositeData的大小是其中最大成员的大小如果LeafData和CompositeData差异很大比如CompositeData里放一个std::map那每个Node对象都会占用跟map一样大的内存即使叶子根本不需要map。这时候就要权衡是牺牲一些内存换取统一存储、连续布局还是回到指针方案换取按需分配。我的经验是节点类型简单、数量多、高频遍历用variant节点类型丰富、单节点内部字段多而杂、树深度大用虚函数unique_ptr。4.3 C23与递归Lambda传统组合模式的新写法C23给了一个很实用的小改进deducing this让递归lambda写起来自然很多。以前想在lambda里递归遍历组合树得用std::function包一层或者用Y组合子又丑又低效。现在可以这样写auto render [](this auto self, const Node node) - void { std::visit(overloaded{ [](const LeafData d) { std::cout d.text \n; }, [](const CompositeData d) { std::cout d.title \n; for (auto child : d.children) self(child); } }, node.data); };self就是lambda自身递归调用不经过std::function的类型擦除内联友好。这个写法在C23下编译是写基于variant组合树遍历的利器。组合模式本身是结构型模式但C的实现层面一直随着语言演进而变化。从最原始的虚函数向下转型到模板化、到variant静态多态、到C23递归lambda实现细节在进化模式的本质一直没变让客户端以统一接口对待单个对象和对象集合。我觉得这才是设计模式正确的学习姿势——模式提供骨架语言特性填充肉身。5. 实操复盘一个场景树统计系统的完整演进5.1 需求与第一版设计光讲理论容易飘我拿一个真实做过的小项目完整走一遍一个轻量级场景树系统节点有两种类型——空节点Group和实体节点Entity需要支持统计总实体数、累计包围盒、遍历渲染。第一版我用了最朴素的安全式透明混合写法。当时想着用透明式方便遍历但又不希望叶子挂孩子于是让Leaf的add()直接返回false。这个版本的类结构长这样class Node { public: virtual ~Node() default; virtual void render() const 0; virtual size_t entityCount() const noexcept { return 0; } virtual bool add(unique_ptrNode child) noexcept { return false; } const std::vectorNode* children() const noexcept { return children_; } protected: std::vectorNode* children_; }; class GroupNode : public Node { public: void render() const override { for (auto* c : children_) c-render(); } size_t entityCount() const noexcept override { return std::accumulate(children_.begin(), children_.end(), size_t{0}, [](size_t acc, Node* c) { return acc c-entityCount(); }); } bool add(unique_ptrNode child) noexcept override { children_.push_back(child.get()); owned_.push_back(std::move(child)); return true; } private: std::vectorunique_ptrNode owned_; }; class EntityNode : public Node { public: explicit EntityNode(Mesh* mesh) : mesh_(mesh) {} void render() const override { renderer.submit(mesh_); } size_t entityCount() const noexcept override { return 1; } private: Mesh* mesh_; };这个版本的问题非常明显children_是Node*裸指针vectorowned_是unique_ptrvector两边都得维护同步。add()时先push裸指针再push unique_ptr写的时候觉得没什么但代码一多就很容易出现“owned_里删了children_里还留着悬空指针”这种事故。这个设计属实是自己给自己挖坑后来我重构时才意识到这种双数组同步管理是反模式。5.2 加入缓存与脏标记让统计不再递归到底entityCount()这个接口每调用一次就递归整棵子树如果上层每帧都在统计性能就崩了。解决思路是给GroupNode加一个缓存字段第一次统计时算好存下来后续直接返回。关键点在于缓存什么时候失效。如果每次都从根节点全量重建其实和递归一次复杂度一样收益有限。所以需要引入“脏标记”任何一个修改树结构的事故add、remove、修改子节点都把当前节点及其所有祖先标记为dirty。统计时如果节点不dirty直接返回缓存如果dirty递归重新计算并从当前节点向上清标记。这个缓存方案复杂度并不高核心是给Node加一个bool dirty_给GroupNode加一个size_t cachedCount_。markDirty()沿父链向上传播。上面那个版本没有父指针所以我还得给Node加一个Node* parent_add()时设置新节点的parent为当前节点。加了缓存之后单次统计从O(整棵子树节点数)降到O(1)树结构不变化时几乎零开销。代价呢每个节点多了8字节的缓存和一个布尔位对于几万个节点的树来说可以接受。但要注意缓存一致性完全靠代码纪律任何修改操作漏了markDirty统计结果就是错的。所以我的建议是把markDirty收敛到极少几个入口add、remove、clear并加上调试模式下的自检函数断言缓存值与全量递归值一致。5.3 加入环检测与共享节点最终形态如何收尾这个场景树系统后面还遇到一个需求同一个模型实体要被多个Group引用。比如一个“路灯”模型想放在“街道”组里也想出现在“整个城市”的总场景里。这就是共享节点场景我做了两个改动。第一存储改成shared_ptr子节点列表从unique_ptr改成shared_ptrNode* parent_改成weak_ptrNode防止循环引用。第二add()里做祖先链检查。每次插入前从新节点沿着parent_往上走只要遇到当前节点自身就拒绝插入返回false并记录日志。这个检查是O(h)h是树高对于通常100以内的深度没问题。最终形态看起来是这样class Node : public std::enable_shared_from_thisNode { public: virtual ~Node() default; std::weak_ptrNode parent; std::vectorstd::shared_ptrNode children; bool add(std::shared_ptrNode child) noexcept { if (!child) return false; // 环检测从child的父链往上找遇到this就拒绝 for (auto cur child-parent.lock(); cur; cur cur-parent.lock()) { if (cur.get() this) return false; } child-parent weak_from_this(); children.push_back(std::move(child)); markDirty(); return true; } void markDirty() noexcept { dirty_ true; if (auto p parent.lock()) p-markDirty(); } virtual size_t entityCount() const noexcept { return 0; } private: bool dirty_ true; };有人可能注意到这个最终版本的children是std::shared_ptrNode的vector而遍历时用shared_ptr访问子节点会有引用计数的原子增减开销。如果子节点数量很多每次遍历都要原子操作性能上不划算。一个缓解办法是遍历前s.reserve()后用const Node引用捕获不让shared_ptr在循环内析构。或者干脆在遍历接口里返回std::spanNode*自己做视图跳开计数开销。C的SEXY就在这里同样的一个模式性能敏感与否写法能差出一个量级。6. 常见问题速查表与避坑实录6.1 高频坑叶子调用add导致崩溃透明式组合模式最常见的坑在叶子节点上调add()。教科书让你抛异常但我更推荐返回bool或者断言。如果项目里异常被禁用很多游戏引擎就是这么干的那add()返回bool就是最稳的接口设计。任何调用方拿到false要么记录日志要么弹出提示不会把整个进程带崩。一个是软错误一个是硬崩溃团队协作时后者能少很多半夜的报警。6.2 高频坑递归栈溢出组合树深度一深递归遍历容易爆栈。普通项目树深几十层没事但如果你做了一个像文件系统一样深嵌套的数据结构达到几千层任何递归实现都会踩爆默认栈空间。有两个解法第一遍历改成显式栈的迭代器void renderNonRecursive(const Node root) { std::vectorconst Node* stack{root}; while (!stack.empty()) { auto* n stack.back(); stack.pop_back(); n-renderSelf(); for (auto child : n-children) stack.push_back(child.get()); } }第二把树转成扁平数组后再遍历。显式栈方案简单易改但深度极大时vector的扩容也要注意先预留一个估计深度上限的容量避免中途rehash浪费。如果你能预估最大深度直接在栈上分配一个固定大小数组也可以。6.3 高频坑生命周期与拷贝切割组合树析构时如果根节点是unique_ptr整个树会递归析构。但如果你在某处把unique_ptrNode赋给unique_ptrComposite或者反过来就会切片。C的unique_ptr支持基类到派生类的转换但那是你显式static_pointer_cast之后的事。在组合模式里我强烈建议所有保存节点的容器统一用unique_ptrNode不要出现一处在用unique_ptrLeaf另一处在用unique_ptrNode否则一半节点切片一半节点完好查起来极其酸爽。6.4 高频坑遍历顺序与迭代器失效组合树默认的遍历顺序是深度优先先父后子preorder。如果你要实现搜索功能注意搜索时是否要跳过整棵子树的剪枝。这其实不算坑坑在如果你在遍历过程中修改了节点列表比如删除了某个子节点基于索引的循环直接越界基于迭代器的循环直接失效。C里这个比Java更隐蔽因为vector的erase会导致后续迭代器全部失效而崩溃未必马上发生可能过了几十次循环才炸。解决办法是处理删除时用“标记延迟删除”模式遍历时先收集要删除的节点指针遍历结束后统一从父节点remove。这个思路叫two-pass看似多了一步实际上避免了一堆迭代器失效的未定义行为。6.5 避坑心得最后分享几条我这些年做C组合模式项目的私人心得默认用透明式但add()明确返回bool不要用异常控制正常流程。unique_ptr优先除非明确需要共享再用shared_ptrweak_ptr回指。树结构产生的拷贝能禁则禁深拷贝通过clone()完成别依赖默认拷贝构造函数。加缓存统计时脏标记必须沿着父链向上传播且必须在add/remove内部统一触发别让调用方自己mark。任何遍历逻辑优先用迭代器或模板回调别把“遍历这棵树”的逻辑写死在业务代码里。如果你不确定节点会不会成环趁早在add()里做祖先链检查这行检查可能是你最值钱的防御代码。我曾在生产环境遇到过一次坑某个统计缓存忘记markDirty结果编辑器里的包围盒一直显示上一次的值没人发现直到美术反馈“我移动了模型为什么包围盒没变”排查了一整天才定位到是某个add分支漏了标记。从那以后我把“任何结构修改必须落入add/remove/clear三个入口”写进了代码评审checklist。组合模式本身不难难的是在C这种资源自管理的语言里把每个细节都打磨到不出事。希望这篇文章能帮你少踩几个我踩过的坑。
返回列表