【设计模式系列 (八) 】组合模式

📅 发布时间:2026/10/3 19:56:07
【设计模式系列 (八) 】组合模式
⭐️在这个怀疑的年代我们依然需要信仰。个人主页 YYYing.⭐️设计模式系列专栏设计模式系列系列上期内容【设计模式系列 (七) 】桥接模式系列下期内容暂无目录1. 概述2. 结构3. 仓库实现拼句子composite/3.1 场景3.2 Component抽象构件3.3 Leaf叶子构件3.4 Composite两个容器构件3.5 Client统一对待3.6 C 实现要点递归析构4. 两种实现方式透明 vs 安全4.1 透明组合Transparent4.2 安全组合Safe4.3 选择5. 优缺点与适用环境6. 面试专题6.1 开场题说说组合模式吧6.2 必问透明组合 vs 安全组合6.3 必问组合 vs 装饰器6.4 高频追问清单结语文件夹里可以有文件也可以有子文件夹子文件夹里还能再放文件……但你右键删除时从来没想过眼前这个图标到底是文件还是文件夹——系统的处理方式是一样的。组合模式Composite Pattern就是把这种树形结构 统一对待用面向对象的方式表达出来。别名很直白部分-整体模式Part-Whole。本文代码取自 design-pattern-cpp 仓库的design-pattern/composite/使用 C11。为阅读方便片段省略了头文件保护宏。1. 概述WikipediaThe composite pattern describes thata group of objects is to be treated in the same way as a single instance of an object. The intent of a composite is to compose objects into tree structures to represent part-whole hierarchies. Implementing the composite pattern lets clients treat individual objects and compositions uniformly.组合模式描述了在对待一组对象实例的时候使用以单个对象实例相同的方式对待。 组合的目的是将对象“组合”成树形结构以表示部分-整体层次结构。 通过实现组合模式客户端可以统一对待各个对象与组合。GoF将对象组合成树形结构以表示部分-整体的层次结构使得客户端对单个对象和组合对象的使用具有一致性。两个关键词① 树形结构组合模式解决的场景本质上都是树文件系统的目录、UI 的控件树、公司的组织架构、菜单、公文流转、表达式语法树。凡是整体里能装部分、部分里还能再装部分的地方都是它的地盘。② 统一对待一致性这是组合模式真正的价值。客户端拿着抽象构件就能工作不必写if (是容器) {...} else {...}也不必递归地写类型判断。递归的复杂度被封装在抽象构件自己内部。组合模式属于结构型模式。2. 结构角色职责Component抽象构件为叶子和容器声明共同的接口通常也包含管理子构件的方法增加、删除、获取和公共行为的默认实现Leaf叶子构件没有子节点实现抽象构件中定义的行为对管理子构件的方法一般给空实现或抛异常Composite容器构件含子节点用一个集合存储子节点子节点可以是叶子也可以是容器业务方法里递归调用子节点的业务方法关键点Composite 与 Component 之间是聚合关系且 Composite 的子节点类型也是 Component——这条自引用把递归封闭起来了树想长多深就长多深。3. 仓库实现拼句子composite/3.1 场景每个句子由单词组成单词由字母组成每个对象都是可打印的而且打印时前后要加点东西——句子以句号/换行结尾单词前面总是有个空格。翻译成角色Letter是叶子Words和Sentence都是容器容器的子节点既可以是字母也可以是单词LetterComposite是抽象构件。3.2 Component抽象构件namespace comp { class LetterComposite { protected: std::vectorLetterComposite* children; // ★ 自引用集合 virtual void printBefore() {} // 钩子默认空 virtual void printAfter() {} // 钩子默认空 public: void addChild(LetterComposite* comp) { children.push_back(comp); } size_t count() { return children.size(); } void print() { // ★ 模板方法 printBefore(); for (auto child : children) { child-print(); // ★ 递归 } printAfter(); } virtual ~LetterComposite() { for (auto child : children) { delete child; // ★ 递归析构 } children.clear(); } }; }这里有三处值得留意print()是模板方法流程前 → 递归子节点 → 后在抽象构件里固定差异点通过printBefore/printAfter两个钩子交给子类。叶子覆盖printBefore打印自己单词覆盖printBefore打印空格句子覆盖printAfter打印换行。children和addChild定义在抽象构件上写死的是透明组合见 4.1。析构函数是虚的且递归删除子节点这是 C 里组合模式的内存关键见 3.6。3.3 Leaf叶子构件class Letter : public LetterComposite { private: char character; public: Letter(char c) { this-character c; } protected: void printBefore() override { cout character; // 叶子把打印自己挂到钩子上 } };Letter没有子节点children永远是空的——它的递归print()自然就退化成打印一个字符。3.4 Composite两个容器构件class Words : public LetterComposite { public: templatetypename ... Rest Words(Rest ... c) { // 展开可变参 std::initializer_listint{([] { auto letter new Letter(c); this-addChild(letter); }(), 0)...}; } protected: void printBefore() override { cout ; // 单词前面总是有一个空格 } }; class Sentence : public LetterComposite { public: templatetypename ... Rest Sentence(Rest ... words) { initializer_listint{(this-addChild(words), 0)...}; } protected: void printAfter() override { cout endl; // 句子以换行结尾 } };两个容器都用可变参数模板收参再用初始化列表展开技巧把每个参数变成子节点。initializer_listint{ f(x)..., 0 }配合逗号表达式是 C11 里做参数包展开的常用写法C17 起可以直接用折叠表达式(f(x), ...)。Sentence收到的是Words*Words收到的是char——但两者都把结果addChild进自己的children因为LetterComposite*这个抽象类型把差异抹平了。3.5 Client统一对待int main() { Messenger msg; auto ddg msg.messageFromDdg(); // 是个 Sentence* auto awei msg.messageFromAwei(); ​ // ★ 往句子里直接塞一个单词、再塞两个字母 ddg-addChild(new Words(h, e, l, l, o)); ddg-addChild(new Letter(c)); ddg-addChild(new Letter(c)); ​ ddg-print(); // 客户端只管调 print不关心对象是叶子还是容器 awei-print(); ​ delete ddg; delete awei; return 0; }注意main里那三行addChild往一个句子容器里塞字母叶子和单词容器的语法完全一样编译器也不区分。这就是一致性落到代码上的样子。Messenger里的句子是这么拼出来的LetterComposite* messageFromAwei() { // Whats the hurry ? Im not up yet . return new Sentence( new Words(W, h, a, t), new Words(i, s), // ... new Words(.) ); }一个Sentence挂着若干Words每个Words挂着若干Letter——树就这么长出来了。3.6 C 实现要点递归析构组合模式在 C 里最容易踩的坑是内存泄漏容器的children里存的是裸指针delete sentence只析构Sentence自己子节点全漏。仓库的解法是在抽象构件的析构里递归删virtual ~LetterComposite() { for (auto child : children) { delete child; // 叶子也没关系children 是空的 } children.clear(); }三个要点虚析构必须有否则delete一个LetterComposite*指向的Sentence是未定义行为递归链自洽delete child又会触发 child 的析构一路删到叶子为止所有权唯一子节点由父节点独占并负责释放所以 3.5 的main里只需要delete ddg和delete awei子节点不能再单独 delete也不能有第二个指针指向它——否则就是双重释放。现代 C 改写成std::vectorstd::shared_ptrLetterComposite或unique_ptr能免掉这段手写代码但要小心父子互相引用导致谁都释放不掉的循环引用问题用weak_ptr破环。4. 两种实现方式透明 vs 安全这是组合模式最常考的一个点仓库用的是前者。4.1 透明组合Transparent管理子构件的方法addChild/remove/getChild声明在抽象构件 Component 里叶子和容器拥有完全一样的接口。✅优点客户端完全透明拿到Component*就能无差别调用不用做任何类型判断❌缺点不够安全——Letter这种叶子也能调addChild编译期拦不住要么给空实现调用被静默忽略出 bug 难查要么在运行时抛异常。仓库里LetterComposite把addChild放在基类Letter继承后就带着一个语义上无意义的addChild——这正是透明组合的典型代价。3.5 中main敢直接往Sentence上加节点靠的也是这个统一接口。4.2 安全组合Safe管理子构件的方法只声明在 Composite 里Component 和 Leaf 都没有。✅优点安全叶子根本调不到addChild编译期就能查错❌缺点不透明——客户端想加子节点就得知道手里的对象是容器还是叶子必须做类型判断/强制转换直接违背了统一对待的初衷。4.3 选择透明组合安全组合管理子构件的接口在哪Component抽象构件Composite仅容器客户端是否需要类型判断不需要需要编译期能否拦住叶子的addChild不能能代价叶子带无用方法破坏一致性客户端依赖具体类型结论优先透明组合。组合模式的卖点就是统一对待安全组合把这个卖点丢了换来的那点类型安全在多数场景下不划算。5. 优缺点与适用环境主要优点清楚地定义分层次的复杂对象让客户端忽略层次差异方便对整个层次结构进行控制客户端一致地使用组合结构或单个对象不必关心处理的是单个对象还是整个组合结构简化了客户端代码增加新的容器构件和叶子构件都很方便无须修改现有类库符合开闭原则为树形结构提供了灵活的解决方案递归组合能造出任意复杂的树而对树的控制极其简单。主要缺点增加新构件时很难对容器中的构件类型进行限制。比如希望某个文件夹里只能放文本文件由于叶子和容器来自同一个抽象层类型系统拦不住只能在运行时做类型检查实现复杂且容易漏抽象构件的接口如果太泛例如把所有子类的方法都往基类堆会让设计过于抽象、违背接口隔离透明组合的叶子有无用方法就是它的一种表现在 C 里需要自己处理递归析构与所有权裸指针方案容易出内存问题。适用环境需要实现树状对象结构——组合模式提供了简单叶节点和复杂容器两种共享公共接口的元素类型容器中可嵌套叶节点和其他容器从而构建树状嵌套的递归结构希望客户端代码以相同方式处理简单和复杂元素——所有元素共用同一接口客户端不必在意具体类。6. 面试专题6.1 开场题说说组合模式吧概念组合模式将对象组合成树形结构以表示部分-整体的层次结构使客户端对单个对象和组合对象的使用具有一致性。别名部分-整体模式属于结构型模式。角色抽象构件 Component 为叶子和容器声明共同接口叶子构件 Leaf 无子节点容器构件 Composite 用集合存储子节点在业务方法里递归调用子节点的方法。关键容器与抽象构件之间是聚合关系且子节点类型也是抽象构件——这条自引用让树可以无限延伸。适用场景需要表示对象的部分-整体层次希望客户端忽略层次差异、统一处理单个对象和组合对象。可扩展接上透明组合 vs 安全组合见 6.2。6.2 必问透明组合 vs 安全组合见第 4 节。浓缩成三句话接口位置不同透明组合把add/remove放在抽象构件里安全组合只放在容器构件里。代价不同透明组合牺牲类型安全叶子也能调add但调用没意义换来客户端无需判断类型安全组合牺牲一致性客户端必须知道手里是容器还是叶子换来编译期安全。实践中首选透明组合因为统一对待正是这个模式存在的理由。6.3 必问组合 vs 装饰器两个模式都靠递归组合 共享同一抽象接口新手很容易混。区别在子节点的数量和目的组合 Composite装饰器 Decorator目的表示部分-整体的树形层次动态给对象添加职责子节点个数多个一个容器挂 N 个子节点通常一个层层包裹像洋葱结构形状树链是否改变行为只在聚合层面组织叶子/容器的行为各自独立在转发前后增加行为客户端视角看到的是一棵树看到的是一个功能叠加后的对象一句话记忆组合是一对多、搭树装饰器是一对一、套娃。6.4 高频追问清单追问答法组合模式属于哪一类结构型模式别名部分-整体Part-Whole模式组合模式的关键是什么定义了一个抽象构件类它既能代表叶子又能代表容器客户端针对它编程无须知道具体是哪种容器里的子节点能是什么叶子和容器都行——正因如此才能递归形成树容器与抽象构件是什么关系聚合关系aggregation / has-a且是自引用这是递归的基础递归调用在哪里在容器构件的业务方法里遍历children逐个调用子节点的同名方法组合模式的缺点难以对容器中的子构件类型做编译期约束只能运行时判断透明组合下叶子会带上无意义的方法C 里要自行处理递归析构什么时候不该用层次结构不是树、或者客户端本来就需要区别对待叶子与容器时硬套只会多一层无用的抽象组合和继承怎么选继承表达is-a且是静态的组合表达part-whole且能在运行时动态增删子节点。树形结构天然适合组合树很深会有什么问题递归调用会消耗栈空间深度过大可能栈溢出另外反复的虚函数分派有性能开销。极端深度场景要考虑改成显式栈的迭代实现框架里哪里见过组合文件系统、GUI 控件树、DOM 节点、组织架构与公文流转、菜单、表达式/语法树解析、java.awt.Container、前端组件树结语我是YYYing后面还有更精彩的内容希望各位能多多关注支持一下主包。无限进步我们下次再见