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

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

个人主页 :YYYing.

⭐️设计模式系列专栏:设计模式系列

系列上期内容:【设计模式系列 (七) 】桥接模式

系列下期内容:暂无


目录

[1. 概述](#1. 概述)

[2. 结构](#2. 结构)

[3. 仓库实现:拼句子(composite/)](#3. 仓库实现:拼句子(composite/))

[3.1 场景](#3.1 场景)

[3.2 Component:抽象构件](#3.2 Component:抽象构件)

[3.3 Leaf:叶子构件](#3.3 Leaf:叶子构件)

[3.4 Composite:两个容器构件](#3.4 Composite:两个容器构件)

[3.5 Client:统一对待](#3.5 Client:统一对待)

[3.6 C++ 实现要点:递归析构](#3.6 C++ 实现要点:递归析构)

[4. 两种实现方式:透明 vs 安全](#4. 两种实现方式:透明 vs 安全)

[4.1 透明组合(Transparent)](#4.1 透明组合(Transparent))

[4.2 安全组合(Safe)](#4.2 安全组合(Safe))

[4.3 选择](#4.3 选择)

[5. 优缺点与适用环境](#5. 优缺点与适用环境)

[6. 面试专题](#6. 面试专题)

[6.1 开场题:说说组合模式吧](#6.1 开场题:说说组合模式吧)

[6.2 必问:透明组合 vs 安全组合](#6.2 必问:透明组合 vs 安全组合)

[6.3 必问:组合 vs 装饰器](#6.3 必问:组合 vs 装饰器)

[6.4 高频追问清单](#6.4 高频追问清单)

结语


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

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

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


1. 概述

Wikipedia :The composite pattern describes that a 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:抽象构件

cpp 复制代码
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:叶子构件

cpp 复制代码
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:两个容器构件

cpp 复制代码
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:统一对待

cpp 复制代码
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 里的句子是这么拼出来的:

cpp 复制代码
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 自己,子节点全漏。

仓库的解法是在抽象构件的析构里递归删:

cpp 复制代码
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,后面还有更精彩的内容,希望各位能多多关注支持一下主包。

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

相关推荐
IT_陈寒1 小时前
Redis集群切换主节点时,服务竟然全员掉线
前端·人工智能·后端
艾莉丝努力练剑1 小时前
【QT】系统相关:多线程QThread基础
开发语言·网络·c++·qt·学习·大模型
l1t1 小时前
DeepSeek总结的PIVCO-Huffman编码性能优化
数据结构·c++·算法
打工仔折腾 AI1 小时前
多台服务器日志分散难查?用Promtail+Loki+Grafana搭建集中检索平台
服务器·人工智能·后端·python·性能优化·django·grafana
打工仔折腾 AI1 小时前
DiPlay 实测:iPhone 绕过硬件盒子直连 BYD 车机的思路拆解
android·人工智能·后端·python·gradle·iphone·ai agent 实战
王维同学2 小时前
ETW监控-KswordARK功能展示
c++·windows·安全
東隅已逝,桑榆非晚2 小时前
c++模板进阶
开发语言·c++·笔记·学习
左手の明天11 小时前
SLIP 协议封装与解析详解:原理、C 代码与实战案例
linux·c语言·开发语言·c++
青少儿编程课堂11 小时前
后缀自动机解析:本质不同子串与最长重复片段统计
c++·python·算法·bfs·信息学竞赛