C++组合模式实战:从文件目录树到访问者模式

📅 发布时间:2026/10/9 11:31:59
C++组合模式实战:从文件目录树到访问者模式
搞过几年C的人多半会碰到一种情况你拿到手的数据结构像一棵树但写代码的时候却得时刻区分“树干”和“树叶”两种角色。文件系统是最典型的例子一个目录里能塞文件也能塞子目录你在计算总大小时得先判断类型再决定是否递归。这种if/else一多维护起来就让人头疼。组合模式就是专门解决这个问题的——它让单个对象和组合对象用同一个接口被访问把“部分-整体”的层次结构封装成一种统一表达。这篇文章我会用一个完整的C文件目录树案例从接口设计、内存管理到访问者模式扩展把组合模式的实战写法讲透。适合刚学完C基础、想了解设计模式实际用法的读者也适合正在做界面控件树、配置文件解析这类工作的朋友。1. 组合模式到底在解决什么问题1.1 用管理后台的菜单树理解组合模式如果你做过后台管理系统肯定不陌生菜单可以嵌套子菜单也可以是一个直接的页面。用户点一个一级菜单可能展开子菜单也可能直接跳页面。从代码角度说一级菜单是容器页面是叶子但它们对外暴露的行为都是“被点击后展示”。组合模式的核心就在这里把“单个对象”和“组合对象”抽象成同一个类型让调用方不用区分当前处理的是叶子还是容器。订单里的“套餐”和“单品”也是这个意思服务员不需要在点餐流程里区分套餐和汉堡反正最后都是“点一份餐”。组合模式的定义是“将对象组合成树形结构以表示部分-整体的层次结构使得用户对单个对象和组合对象的使用具有一致性”。这个定义看着拗口翻译成白话就是文件、文件夹都能被同一个函数处理你不需要写“if (isDirectory) ... else ...”这样的判断。这种一致性带来的直接收益是调用方的代码大幅简化新增一种叶子类型时已有遍历逻辑多半不用改。1.2 常见的适合用组合模式的三类场景从项目实战看适合使用组合模式的主要有三类场景。第一类是文件系统、目录树、ZIP包结构这类天然的层级存储结构第二类是图形界面里的控件树比如Qt的QWidget树、一个窗口里有布局、布局里有按钮和文本框绘制和事件分发都要递归遍历第三类是业务领域中的数据树比如组织架构、权限树、商品分类树你要做部门人数汇总、权限查找时统一接口能省掉大量重复判断。此外编译器领域里语法树、表达式树也是组合模式的典型应用当一个运算表达式可以嵌套另一个表达式时组合模式让“计算整个表达式”这个动作变成对树根的递归调用。说白了只要你的数据具备“层层嵌套”的特征组合模式基本就是最自然的建模方法。1.3 在C中实现组合模式和Java、Python有什么不同很多设计模式书用Java写示例换到C以后最明显的变化就是内存和类型系统。Java里所有对象都是引用语义有GC兜底你new出一个子节点放进父节点不需要再操心析构。C里则必须考虑清楚子节点由谁持有、何时释放、释放顺序如何。默认情况下我会用独占所有权语义也就是unique_ptr把子节点的生命周期绑定在父节点上。另一个差异是虚析构函数在C中只要父类可能被指针删除析构函数就必须是虚的否则会出现“只析构基类部分、子类资源泄漏”的未定义行为。很多初学者在这个地方翻车。还有一个更实际的区别C里有值语义和拷贝语义对象既可以存在栈上也可以存在堆上还可以被复制。组合模式如果用了unique_ptr复制就被禁用了如果业务需要复制整棵对象树要么实现clone接口要么改用shared_ptr并接受额外的引用计数开销。这些都是写C组合模式时绕不开的设计点后面我会逐一展开。2. 动手前先想清楚的三件事接口、所有权与遍历2.1 抽象基类接口要窄析构要虚先定义一个抽象节点类。接口怎么定直接决定后面所有代码的体验。经验是第一版接口不要太宽只放所有节点都必然具备的能力。对于文件目录树来说获取名字、计算大小、打印结构这三个能力是通用的先放这三个#include iostream #include memory #include string #include vector class Node { public: virtual ~Node() default; virtual std::string name() const 0; virtual double size() const 0; virtual void print(std::ostream out, int indent 0) const 0; };有人会问为什么不在基类里直接写add、remove方法这样调用方连类型都不用判断了不是更“一致”吗设计模式里确实有一种“透明式”写法把所有操作都放到基类叶子节点的add就是空实现或者抛异常。但我在C里一般不建议这么做原因有两点。一是在C里叶子节点的空实现会把一个真正的错误往文件里塞子目录变成“看似成功但实际上什么也没发生”的静默失败。二是在基类里加太多方法会让所有继承类都变胖以后每次改动都可能牵连叶子。C生态里更流行的是“安全式”写法容器特有的方法只写在Directory里调用方需要时再通过类型判断或访问者模式去操作。接口窄后续的扩展成本才低。2.2 子节点容器到底用 unique_ptr、shared_ptr 还是裸指针这个问题几乎每次写组合模式都会遇到我给一个比较通用的选型结论。默认用std::unique_ptr整个树是一个严格的所有权树父子关系就是所有权关系父节点析构时子节点自动析构内存安全且无额外开销。shared_ptr适合出现“同一个子节点被多个父节点引用”的需求比如DAG图或共享资源节点但一旦出现反向引用就很容易造成循环引用必须配weak_ptr。裸指针不是不能用而是要求有一个更高层的对象池统一管理所有节点树的析构顺序必须在外部明确控制否则一旦外部先释放了对象池里的对象树上就会留下悬垂指针。从我的经验看绝大多数文件系统、菜单、权限树都属于严格所有权树unique_ptr够用还能通过移动语义清晰地表达“把一个节点挂到另一个节点下面”的动作。shared_ptr看上去方便但引用计数会让树的析构顺序变得难以预期排查泄漏时也更费劲。还有一点unique_ptr在C11之后就支持配合make_uniqueC14非常顺手我用的是C17风格下面的代码都以此为准。2.3 节点类需要支持哪些操作如果采用安全式写法Directory主要负责管理子节点我通常会给Directory提供以下接口void add(std::unique_ptrNode child)把节点挂到当前目录下参数用右值引用强制调用方std::move明确表达所有权的转移。children()提供一个只读访问器用来遍历子节点。find(const std::string name)在目录内按名字查找返回裸指针表示“只查看不拥有”。add的参数不能是const std::unique_ptrNode因为这样调用方会以为可以传一个左值并且之后还能继续使用语义是错的。传值加std::move是最能体现C所有权语义的写法。在Directory内部add只需要children_.push_back(std::move(child));非常简洁。2.4 遍历方式递归是天然选择但要控制深度组合模式本身就是一个递归结构所以遍历时递归是天然选择。打印目录结构、计算总大小、查找姓名都可以用递归写得很直观。我唯一担心的不是递归本身而是递归深度。如果树的深度超过几千层比如从一个配置文件中解析出一个极端嵌套的对象递归就有栈溢出风险。这种情况下要么用显式栈要么用迭代式深度优先遍历。一般业务树的深度都在几十层以内递归完全够用而且代码可读性最好只有当你的树由用户恶意构造、深度不可控时才需要考虑迭代写法。3. 实战实现一个可用的文件目录树3.1 实战目标能做统计、打印、查找的目录树接下来用一个具体例子把上面这些设计落到代码里。需求是从一个根目录出发我们可以任意添加文件或子目录然后做到三件事打印整棵目录树的层级结构统计某个目录的总大小查找某个文件。为了让案例贴近真实开发我会让File支持记录文件大小Directory的大小等于所有子节点大小之和。为了不把代码堆在一处我先给出File的完整定义。它很简单就是一个叶子节点class File : public Node { std::string name_; double size_; public: File(std::string name, double size) : name_(std::move(name)), size_(size) {} std::string name() const override { return name_; } double size() const override { return size_; } void print(std::ostream out, int indent) const override { out std::string(indent, ) name_ ( size_ bytes)\n; } };注意File的name成员和基类接口都在析构不用自己写虚析构已经保证File的成员会被正确析构。3.2 组合节点Directory的实现细节Directory实现class Directory : public Node { std::string name_; std::vectorstd::unique_ptrNode children_; public: explicit Directory(std::string name) : name_(std::move(name)) {} void add(std::unique_ptrNode child) { children_.push_back(std::move(child)); } std::string name() const override { return name_; } double size() const override { double total 0.0; for (const auto child : children_) { total child-size(); } return total; } void print(std::ostream out, int indent) const override { out std::string(indent, ) name_ /\n; for (const auto child : children_) { child-print(out, indent 2); } } const std::vectorstd::unique_ptrNode children() const { return children_; } };这里的size()和print()体现的递归思想是一致的Directory只是把自己的行为委托给每个子节点具体子节点是File还是Directory它完全不关心。这就是组合模式带来的好处——Directory不需要知道子节点类型。我试过把Directory里的循环遍历提取成模板用回调函数处理每个子节点后来发现得不偿失因为C里虚函数的递归已经把这个逻辑封装得足够干净再加一层模板纯属给代码增加阅读负担。除非一个遍历逻辑要用在多个不同的业务中才值得抽象成一个walk()工具函数。3.3 递归查找怎么安全地找到指定节点查找名字时需要一个既能返回结果、又不破坏“所有权”语义的方案。我用裸指针作为返回值表示“我只借用不拥有”。代码可以这样写Node* findByName(Node* node, const std::string name) { if (node-name() name) { return node; } if (auto* dir dynamic_castDirectory*(node)) { for (const auto child : dir-children()) { if (auto* result findByName(child.get(), name)) { return result; } } } return nullptr; }这里用到了dynamic_cast很多人会觉得在组合模式中做类型判断是不纯正的做法。确实理想的组合模式希望完全用多态解决问题但实际业务里查找“目录下的子节点”这个动作天然只有容器才有不通过类型判断就得把children()放到基类里并让File返回空容器或者用访问者模式。动态类型判断用一次两次还好如果到处都是就要考虑用访问者模式替换后面我会讲。在调用方使用返回的裸指针时要小心树的结构不能被修改。比如先找到叶子节点然后把它的父目录清空了这个指针就成了悬垂指针。比较稳妥的做法是把查找和业务处理放在同一个作用域内不要在别处持有返回指针。3.4 打印完整路径把“路径栈”传给递归函数打印结构是一重需求打印完整路径又是另一重需求。完整路径其实是递归过程中的“路径栈”。可以专门做一个递归函数每进入一层Directory就在路径末尾追加一个目录名void printPath(Node* node, const std::string parentPath) { if (!node) return; std::string currentPath parentPath.empty() ? node-name() : parentPath / node-name(); std::cout currentPath \n; if (auto* dir dynamic_castDirectory*(node)) { for (const auto child : dir-children()) { printPath(child.get(), currentPath); } } }注意在这里parentPath每次递归都需要创建新字符串如果树非常大字符串拷贝开销不可忽略。面对超大目录树时可以用std::vectorstd::string做栈只在一头push/pop只在真正打印时拼接一次这样能省下大量中间字符串创建。做性能优化之前先动脑子这是我的习惯。3.5 完整测试一下这棵树写一个简单的main函数来验证效果int main() { auto root std::make_uniqueDirectory(workspace); auto docs std::make_uniqueDirectory(docs); docs-add(std::make_uniqueFile(readme.md, 1024)); docs-add(std::make_uniqueFile(plan.doc, 2048)); auto src std::make_uniqueDirectory(src); src-add(std::make_uniqueFile(main.cpp, 4096)); src-add(std::make_uniqueFile(util.cpp, 2048)); root-add(std::move(docs)); root-add(std::move(src)); root-print(std::cout); std::cout total size: root-size() bytes\n; Node* found findByName(root.get(), main.cpp); if (found) { std::cout found: found-name() ( found-size() bytes)\n; } }运行结果很直观workspace是根节点下面有docs和src两个目录每个目录里挂着文件。计算总大小时把四个文件的大小加起来就是9220。值得留意的是docs在root-add(std::move(docs));之后就不能再用了因为它的所有权已经转移给root。如果你在这之后访问docs就是典型的悬垂访问。4. 再进一步用访问者模式组合出更灵活的扩展4.1 为什么组合模式走到后面往往会想引入访问者假设上面的目录树已经上线了现在新增需求要统计文件总数、按扩展名分组、生成JSON格式的树、找出最大的文件。最直接的做法是在Node里不断增加虚函数countFiles()、toJson()、largestFile()。每个虚函数都要在File和Directory各写一份实现而且它们都是“遍历整棵树”这个逻辑的变体。再加五六个需求基类和所有子类都会膨胀得厉害每次新增功能都要修改所有节点类。这违背了开放封闭原则。访问者模式专门解决这个问题把“处理某个节点”的行为从节点类中搬出来放到一个独立的Visitor对象里。节点类只需要提供一个accept(Visitor)入口剩下的事情交给Visitor。组合模式管树的组装和递归遍历访问者管具体业务逻辑两者配合很经典文件导出、语法分析、报表统计这类场景里非常常见。4.2 给节点增加accept入口先定义Visitor抽象的接口再给File和Directory分别实现acceptclass Directory; class File; struct NodeVisitor { virtual ~NodeVisitor() default; virtual void visit(File file) 0; virtual void visit(Directory dir) 0; };在基类Node中增加virtual void accept(NodeVisitor visitor) 0;File的实现很简单把调用交还给Visitorvoid File::accept(NodeVisitor visitor) override { visitor.visit(*this); }Directory的实现则多了遍历孩子的动作void Directory::accept(NodeVisitor visitor) override { visitor.visit(*this); for (const auto child : children_) { child-accept(visitor); } }为什么Directory要在这里主动遍历孩子而不是把遍历逻辑全部放在Visitor里因为树的遍历顺序属于树结构本身的知识Directory清楚谁是谁的孩子由它来决定“访问完自己之后访问谁”实现最自然。Visitor则只需要关心“访问到一个节点时做什么”。4.3 用Visitor写一个统计大文件数量的需求写一个Visitor用来统计文件总数和大小超过阈值的文件数class StatsVisitor : public NodeVisitor { public: void visit(File file) override { fileCount_; if (file.size() threshold_) { bigFileCount_; } } void visit(Directory dir) override { // 不做额外处理子节点交给Directory::accept遍历 } int fileCount() const { return fileCount_; } int bigFileCount() const { return bigFileCount_; } private: int fileCount_ 0; int bigFileCount_ 0; double threshold_ 1024; };调用方式StatsVisitor visitor; root-accept(visitor); std::cout visitor.fileCount() files, visitor.bigFileCount() big files\n;这里Directory的visit可以是空实现它只用于占位。如果你要统计目录个数可以把目录统计也写进去。访问者模式在C里有个绕不开的话题就是double dispatch——本来accept是虚函数会根据实际类型分派到File或Directory在子类的accept里再调用visitor.visit(*this)这时候*this的静态类型是File或Directory所以能精确匹配到对应的visit重载。这种“先虚分派再重载分派”的手法第一次看可能懵画个调用链就能理解。4.4 std::variant 和 std::visit替代继承的一种现代C思路如果你用的是C17还有另一条路可以走。当节点类型固定、不需要运行时扩展时用std::variant保存节点类型配合std::visit访问比虚函数和Visitor都更高效。这个方向就不在组合模式的经典讨论范围里了但值得了解。简单说std::variant在栈上存储没有虚函数表访问时是编译期分派性能更好缺点是节点类型必须预先穷举无法在运行期从插件或外部库增加新类型。我在实际项目中是这样取舍的如果节点类型几乎不会增加比如文件系统就只有File和Directory两类就愿意用std::variant如果树更像是某套可扩展框架的一部分业务上可能不断新增节点类型比如编辑器里的各种节点、流程图里的各类组件用经典虚函数访问者模式更合适。5. 实战中的常见坑与排查经验5.1 忘了虚析构程序崩得毫无规律这个是C多态最经典的坑。如果在基类中没有声明virtual ~Node()当你通过Node*删除一个Directory时只会调用Node的析构函数而不会调用Directory析构函数Directory内部的vector和字符串可能就没有被正确释放。表现上不一定是内存泄漏更可能是堆损坏或者析构顺序造成后续崩盘。排查方法也不难检查基类析构是否加了virtual或者在写任何可继承的类时默认就把析构函数写成virtual。编译器可以选择加-Wnon-virtual-dtor选项来报警clang和gcc都支持。花几秒加上这个编译选项能省下一整晚的调试时间。5.2 unique_ptr接管后别再碰旧指针下面这段代码是我见过很多次的重灾区auto file std::make_uniqueFile(a.txt, 100); dir-add(std::move(file)); file-print(std::cout, 0); // 悬垂file已经被移走unique_ptr被move之后就成了空指针上面这行代码直接解引用空指针。调试时还经常遇到一种更隐蔽的情况把file传给某个函数然后这个函数内部又把file move到容器里函数返回后主函数继续用file一样崩。我的习惯是只要调用了std::move把unique_ptr传出去后面就不要再碰它实在需要持有节点的话在move之前先保存一份裸指针但这要建立在所有权管理非常清晰的前提下。如果主流程后面还要用就根本不要move传一个裸指针进去即可。5.3 shared_ptr的反向引用陷阱某些场景你会想给子节点加一个parent指针方便从子节点向上回溯到父节点。如果parent也是shared_ptr那问题就来了父节点持有子节点的shared_ptr子节点持有父节点的shared_ptr两者互相引用shared_ptr的引用计数永远降不到0于是整棵树不释放造成内存泄漏。更隐蔽的是如果你把子节点拿出来单独使用子节点又反过来让父节点存活对象生命周期完全脱出你的预期。解决办法是反向引用用裸指针或weak_ptr。裸指针最快但要求父节点生命周期长于子节点在严格所有权树中这通常成立所以用裸指针做parent是合理的。如果结构动态变化父可能先于子被删除就用weak_ptr并在使用时lock。组合树通常父子一起析构裸指针就够了但要记住parent指针不参与所有权只用于访问。5.4 递归太深或者遍历顺序不对递归深度问题前面在2.4提过。实际业务里还有一个容易忽视的点打印目录结构时很多人直接在print里写“打印当前目录名然后遍历所有孩子”这样其实打印出来的顺序是前序遍历正符合目录树直觉。但如果你要统计“目录内所有直接子文件数”却递归到所有嵌套子目录统计口径就错了。这种bug不报错结果看着也合理只在业务验收时才暴露。我的做法是每个递归函数在注释里写清楚它处理的是“本层还是全树”比如size()明确是“全树”directChildCount()明确是“本层”。写完递归后用一棵三层小树手工跑一遍确认输出再用单元测试固化下来。组合模式代码本身不难难的是递归时的边界条件。5.5 拷贝整个树从被unique_ptr禁用的拷贝到自定义clone如果你在项目里使用了unique_ptr保存子节点会立刻发现整棵树变得不可拷贝。这是好事它帮你避开了“浅拷贝两个父节点共享同一批子节点”的坑。但如果业务确实需要复制一棵树比如做一个配置模板副本那就必须实现深拷贝。给Node加一个虚函数virtual std::unique_ptrNode clone() const 0;File的实现可以复用拷贝构造函数如果File只有string和double默认拷贝构造就够了所以可以简单写成std::unique_ptrNode File::clone() const override { return std::make_uniqueFile(*this); }Directory的实现需要逐个clone子节点再组装成一个新Directorystd::unique_ptrNode Directory::clone() const override { auto result std::make_uniqueDirectory(name_); for (const auto child : children_) { result-add(child-clone()); } return result; }注意clone()在基类声明时就返回unique_ptr这样复制出来树的所有权归属清晰不会出现资源泄露。iostream的操作符、调试打印这些功能尽量留在打印函数中而不是塞进clone职责会清楚很多。我个人在实际操作中沉淀下来的一个小习惯是拿到树形需求后先别急着写业务逻辑而是先用最简单的打印函数把整棵树的结构打出来确认层级和名字正确再继续写查找、统计等复杂功能。组合模式最大的价值不是省几行代码而是把树结构的“形”和遍历的“魂”统一在一起让新增一种节点类型时所有基于接口的调用都能自动兼容。如果你手头正好有一个树形需求先看看当前代码里是不是到处都在写if判断类型再考虑要不要用组合模式定义统一接口。设计模式是经验沉淀不是教条。照着我这套思路搭出来的组合模式骨架在文件系统、菜单树、组织架构这类场景里基本可以直接套用遇到更复杂的遍历需求配合访问者模式再扩展一层接口你会发现树形代码里那些讨厌的重复判断都被“递归虚函数”消掉了。