拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

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

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

⭐️在这个怀疑的年代,我们依然需要信仰。

个人主页 :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 透明组合(Transparent)

4.2 安全组合(Safe)

4.3 选择

5. 优缺点与适用环境

6. 面试专题

6.1 开场题:说说组合模式吧

6.2 必问:透明组合 vs 安全组合

6.3 必问:组合 vs 装饰器

6.4 高频追问清单

结语


文件夹里可以有文件,也可以有子文件夹,子文件夹里还能再放文件……但你右键"删除"时,从来没想过眼前这个图标到底是文件还是文件夹——系统的处理方式是一样的。

组合模式(Composite Pattern)就是把这种"树形结构 + 统一对待"用面向对象的方式表达出来。别名很直白:部分-整体模式(Part-Whole)。

本文代码取自 design-pattern-cpp 仓库的design-pattern/composite/,使用 C++11。为阅读方便,片段省略了头文件保护宏。


1. 概述

Wikipedia:The 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::vector<LetterComposite*> 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(); } }; }

这里有三处值得留意:

  1. print()是模板方法:流程(前 → 递归子节点 → 后)在抽象构件里固定,差异点通过printBefore/printAfter两个钩子交给子类。叶子覆盖printBefore打印自己,单词覆盖printBefore打印空格,句子覆盖printAfter打印换行。

  2. children和addChild定义在抽象构件上:写死的是"透明组合",见 4.1。

  3. 析构函数是虚的且递归删除子节点:这是 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: template<typename ... Rest> Words(Rest ... c) { // 展开可变参 std::initializer_list<int>{([&] { auto letter = new Letter(c); this->addChild(letter); }(), 0)...}; } protected: void printBefore() override { cout << " "; // 单词前面总是有一个空格 } }; class Sentence : public LetterComposite { public: template<typename ... Rest> Sentence(Rest ... words) { initializer_list<int>{(this->addChild(words), 0)...}; } protected: void printAfter() override { cout << endl; // 句子以换行结尾 } };

两个容器都用可变参数模板收参,再用初始化列表展开技巧把每个参数变成子节点。initializer_list<int>{ f(x)..., 0 }配合逗号表达式,是 C++11 里做参数包展开的常用写法(C++17 起可以直接用折叠表达式(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() { // What's the hurry ? I'm 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::vector<std::shared_ptr<LetterComposite>>或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,后面还有更精彩的内容,希望各位能多多关注支持一下主包。

无限进步,我们下次再见!

返回列表