C++ / Qt · 设计模式 · 结构型

Day 8:组合模式(Composite Pattern)

2026-08-20 · 把"单个对象"和"组合对象"放进同一棵树,让叶子与枝干共用一张脸

①今日主题

组合模式(Composite Pattern):将对象组织成树形结构以表示"部分—整体"的层次关系,让客户端能够用一致的方式对待单个对象(叶子)和组合对象(容器)——对叶子调用一个操作,与对容器调用同一个操作,效果完全一致:容器会把操作递归地转发给它的所有子孙节点。

它是 GoF 23 种设计模式中的结构型模式。GoF 原书定义:"Compose objects into tree structures to represent part-whole hierarchies. Composite lets clients treat individual objects and compositions of objects uniformly."(将对象组合成树形结构以表示"部分—整体"的层次结构,使得用户对单个对象和组合对象的使用具有一致性。)

核心句:叶子与容器,共用一张脸。客户端手里拿到的是一个文件还是一整个文件夹,它根本不需要关心——对它说"显示出来",文件显示自己,文件夹会连带着所有子孙一起显示。树的深度对客户端完全透明。

②为什么需要这个模式

设想你要做一个"公司组织架构系统":需要打印部门树、统计总工资。一个部门(Department)里既有普通员工(Employee),也可能嵌套子部门。没有设计模式时,最自然的写法是让调用方"分别处理"两种类型:

// 坏味道:客户端被迫区分"员工"和"部门"两种类型,遍历逻辑散落在外
void printTree(const std::vector<Employee*>& emps,
               const std::vector<Department*>& depts, int indent) {
    for (auto* e : emps) {
        std::cout << std::string(indent, ' ') << e->name() << "\n";   // 处理叶子
    }
    for (auto* d : depts) {
        std::cout << std::string(indent, ' ') << d->name() << ":\n"; // 处理容器
        printTree(d->employees(), d->departments(), indent + 2);        // 递归只能自己手写
    }
}

// 统计工资时,同样的二分支逻辑要再写一遍……
double totalSalary(const std::vector<Employee*>& emps,
                   const std::vector<Department*>& depts) {
    double sum = 0;
    for (auto* e : emps) sum += e->salary();            // 叶子:直接累加
    for (auto* d : depts) sum += totalSalary(d->employees(), d->departments()); // 容器:递归
    return sum;
}

这个写法会随着需求增长迅速崩盘,它违背了多条 SOLID 原则:

根本问题在于:"部门"既是树的节点,又是树的容器,而代码把这两件事拆成了两套互不相通的 API。组合模式要做的,就是让"部分"与"整体"共享同一个接口——递归,代替了所有散落的手写遍历。

③核心思想

组合模式的核心是递归定义:"整体由部分组成,而部分本身又可以是整体。"它把对象划分为两类角色,但给它们一个共同的抽象基类:

所谓"递归转发",就是容器自己不真正执行操作,而是把操作"下放"给孩子;孩子如果是容器,继续下放;直到叶子真正执行。这样,一个操作作用于整棵树,成本由树的遍历天然承担,而客户端永远只面对 Component 一个类型。

生活类比——文件系统:"文件"是叶子,"文件夹"是容器,而文件夹里可以再放文件夹。"删除"一个文件夹,会递归删除里面的所有文件和子文件夹;"计算大小"一个文件夹,会递归累加所有子孙的字节数。你永远不会问"这个路径是文件还是文件夹"才决定怎么删——操作系统替你统一了。组合模式就是把这个机制搬进你的类设计里。

同样的类比:公司组织架构(员工是叶子、部门是容器)、军队编制(士兵是叶子、班排连是容器,"集合"命令从连逐级下达到每个士兵)、HTML 的 DOM 树(<div> 既能包文本也能包另一个 <div>)、电商的"套餐"(套餐里可以包含单品,也可以包含另一个套餐)。

④UML/角色关系

组合模式的类图是一棵"自我指涉"的树:

                 ┌─────────────────────────┐
                 │   Component(抽象组件)    │
                 │  + operation()           │
                 │  + add()/remove()/child() │← 孩子管理接口(可选)
                 └────────────┬────────────┘
              ┌───────────────┴───────────────┐
   ┌──────────┴──────────┐        ┌───────────┴───────────┐
   │  Leaf(叶子)        │        │  Composite(组合)     │
   │  · 没有子节点        │        │  · children: 容器      │
   │  · operation() 真干活│        │  · operation() 遍历转发 │
   └─────────────────────┘        │  · add/remove 管理孩子  │
                                  └───────────────────────┘
                ▲
                │ 只依赖抽象 Component
        ┌───────┴────────┐
        │ Client(客户端) │
        └────────────────┘

角色职责:

两个经典设计变体:

C++ 实践中的取舍:Qt 的 QObject 父子树把 setParent() 放在基类,属于偏透明式;而现代 C++ 代码更偏爱安全式 + 智能指针,因为 dynamic_cast 的成本和风险都比运行时异常可控。

⑤最小C++示例:文件系统

用组合模式实现一个极简文件系统:File 是叶子,Directory 是容器,两者都继承自 FileSystemNode。客户端可以完全不管"这是文件还是文件夹",统一调用 show() 打印、size() 统计大小。C++17 完整可编译:

#include <iostream>
#include <memory>
#include <string>
#include <vector>

// ── Component:叶子与容器的统一抽象 ──
class FileSystemNode {
public:
    explicit FileSystemNode(std::string name) : name_(std::move(name)) {}
    virtual ~FileSystemNode() = default;   // 多态析构,配合 unique_ptr 安全释放
    virtual void show(int indent = 0) const = 0;  // 统一操作:打印自己(及子孙)
    virtual std::size_t size() const = 0;         // 统一操作:统计大小
    const std::string& name() const { return name_; }

protected:
    std::string name_;
};

// ── Leaf:文件,没有子节点,真正"干活" ──
class File : public FileSystemNode {
public:
    File(std::string name, std::size_t bytes)
        : FileSystemNode(std::move(name)), bytes_(bytes) {}

    void show(int indent = 0) const override {
        std::cout << std::string(indent * 2, ' ') << "📄 " << name_
                  << " (" << bytes_ << " B)\n";
    }
    std::size_t size() const override { return bytes_; }

private:
    std::size_t bytes_;
};

// ── Composite:文件夹,持有子节点列表,操作递归转发 ──
class Directory : public FileSystemNode {
public:
    using FileSystemNode::FileSystemNode;   // 继承构造函数

    // 用 unique_ptr 独占子节点所有权:析构自动整树释放,无内存泄漏
    void add(std::unique_ptr<FileSystemNode> child) {
        children_.push_back(std::move(child));
    }

    void show(int indent = 0) const override {
        std::cout << std::string(indent * 2, ' ') << "📁 " << name_ << "/\n";
        for (const auto& c : children_) c->show(indent + 1);  // 递归转发给每个孩子
    }

    std::size_t size() const override {
        std::size_t total = 0;
        for (const auto& c : children_) total += c->size();    // 递归累加
        return total;
    }

private:
    std::vector<std::unique_ptr<FileSystemNode>> children_;
};

// ── Client:只面对 FileSystemNode,不区分叶子/容器 ──
int main() {
    auto root = std::make_unique<Directory>("project");
    auto src  = std::make_unique<Directory>("src");
    src->add(std::make_unique<File>("main.cpp", 2048));
    src->add(std::make_unique<File>("util.h", 512));
    auto doc = std::make_unique<Directory>("docs");
    doc->add(std::make_unique<File>("readme.md", 1024));
    root->add(std::move(src));   // 容器套容器:src 是 root 的孩子,自身又是容器
    root->add(std::move(doc));

    root->show();                // 统一调用,管它是文件还是文件夹
    std::cout << "总大小: " << root->size() << " bytes\n";
    return 0;
}

程序输出:

📁 project/
  📁 src/
    📄 main.cpp (2048 B)
    📄 util.h (512 B)
  📁 docs/
    📄 readme.md (1024 B)
总大小: 3584 bytes

注意三个关键点:main() 里没有任何 if (是文件) ... else (是文件夹) 的分支;树的构建与树的遍历彻底分离;unique_ptr 让整棵树的析构自动级联完成,不用手写任何 delete。

⑥Qt实战示例:可组合的图形图元

做一个 Qt6 绘图程序:Shape 是统一抽象,CircleShape / RectShape 是叶子,GroupShape 是容器——它既能装单个图元,也能装另一个 GroupShape。渲染时对整个根图元调用一次 draw(),整棵图元树全部被画出来,这就是 Qt 图形系统里"组合"的标准用法。

shapes.h(统一抽象 + 叶子 + 组合):

#pragma once
#include <QPainter>
#include <QRectF>
#include <memory>
#include <vector>

// ── Component:所有图元(叶子或组合)的统一接口 ──
class Shape {
public:
    virtual ~Shape() = default;
    virtual void draw(QPainter& p) const = 0;      // 统一操作:绘制自己(及子孙)
    virtual QRectF bounds() const = 0;             // 统一操作:包围盒
};

// ── Leaf:圆形 ──
class CircleShape : public Shape {
public:
    CircleShape(QPointF center, qreal radius)
        : center_(center), radius_(radius) {}
    void draw(QPainter& p) const override {
        p.drawEllipse(center_, radius_, radius_);
    }
    QRectF bounds() const override {
        return {center_.x() - radius_, center_.y() - radius_,
                2 * radius_, 2 * radius_};
    }
private:
    QPointF center_;
    qreal   radius_;
};

// ── Leaf:矩形 ──
class RectShape : public Shape {
public:
    explicit RectShape(QRectF rect) : rect_(rect) {}
    void draw(QPainter& p) const override { p.drawRect(rect_); }
    QRectF bounds() const override { return rect_; }
private:
    QRectF rect_;
};

// ── Composite:图元组,可装叶子,也可装其他 GroupShape ──
class GroupShape : public Shape {
public:
    void add(std::unique_ptr<Shape> child) {   // 用 unique_ptr 独占所有权
        children_.push_back(std::move(child));
    }
    void draw(QPainter& p) const override {    // 递归转发给每个孩子
        for (const auto& c : children_) c->draw(p);
    }
    QRectF bounds() const override {           // 所有孩子包围盒的并集
        QRectF total;
        bool first = true;
        for (const auto& c : children_) {
            total = first ? c->bounds() : total.united(c->bounds());
            first = false;
        }
        return total;
    }
private:
    std::vector<std::unique_ptr<Shape>> children_;
};

main.cpp:

#include "shapes.h"
#include <QApplication>
#include <QPainter>
#include <QWidget>

// 客户端:只依赖抽象 Shape,绘制"一棵树"和绘制"一个圆"是同一行代码
class Canvas : public QWidget {
public:
    explicit Canvas(Shape* root) : root_(root) {}

protected:
    void paintEvent(QPaintEvent*) override {
        QPainter p(this);
        p.setRenderHint(QPainter::Antialiasing);
        p.translate(40, 40);
        root_->draw(p);   // 一次调用,递归画出整棵图元树
    }

private:
    Shape* root_;   // 原始指针:root 的生命周期由 main 中的 unique_ptr 管理
};

int main(int argc, char** argv) {
    QApplication app(argc, argv);

    // 组合结构:一栋"房子" = 墙体矩形 + (门 = 门框矩形 + 半圆窗) + 装饰圆
    auto root = std::make_unique<GroupShape>();
    root->add(std::make_unique<RectShape>(QRectF(0, 0, 200, 120)));
    auto door = std::make_unique<GroupShape>();      // 容器套容器
    door->add(std::make_unique<RectShape>(QRectF(80, 60, 40, 60)));
    door->add(std::make_unique<CircleShape>(QPointF(100, 60), 20));
    root->add(std::move(door));
    root->add(std::make_unique<CircleShape>(QPointF(30, 30), 12));

    Canvas canvas(root.get());   // root 先于 canvas 创建、晚于 canvas 析构,安全
    canvas.resize(320, 240);
    canvas.show();
    return app.exec();
}

CMakeLists.txt:

cmake_minimum_required(VERSION 3.16)
project(composite_demo LANGUAGES CXX)

set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)

find_package(Qt6 REQUIRED COMPONENTS Widgets)

qt_add_executable(composite_demo main.cpp)
target_link_libraries(composite_demo PRIVATE Qt6::Widgets)

Qt 自身就是组合模式的重度使用者,甚至可以说:你每次写 Qt 程序都在用组合模式——

  • QObject 父子树:QObject::setParent() 就是 add(),children()/findChild()/findChildren() 就是遍历接口;父对象析构时递归删除所有子对象——deleteLater() 与父子树共同构成了 Qt 对象生命周期的地基;
  • QWidget 嵌套:QLabel 是"叶子",QWidget/QFrame 是"容器",setLayout()/addWidget() 把 widget 组装成界面树,布局系统递归计算每个节点的尺寸;
  • QLayout:QLayoutItem 是抽象 Component,QLayout 是 Composite(可嵌套布局),QSpacerItem/部件项是 Leaf,sizeHint() 沿树递归聚合;
  • QGraphicsItem 体系:QGraphicsItemGroup 可以包含任意 QGraphicsItem,场景的碰撞检测、绘制都对整棵图元树递归进行;
  • QAbstractItemModel:index()/parent() 显式建立树形父子关系,QTreeView 展示的正是组合结构;
  • QDomNode(Qt XML):QDomNode 为统一抽象,QDomElement(容器)、QDomText(叶子)、QDomDocument 构成 DOM 树。

这解释了为什么"学 Qt 用不好"的人总在手动管理 widget 内存——因为他们没有意识到 Qt 整个对象体系就是一棵组合树,把节点挂到树上,剩下的交给递归。

⑦代码执行流程

以最小示例(文件系统)为例,程序从 main() 到输出的完整执行轨迹:

第一步:构建树。main() 依次创建 root(Directory)、两个 File(main.cpp、util.h)并 add() 进 src,创建 readme.md 加进 docs,最后把 src、docs 两个子目录 add() 进 root。此时内存中是一棵深度为 3 的树:root → {src → {main.cpp, util.h}, docs → {readme.md}}。所有子节点都被 unique_ptr 独占持有。

第二步:调用 root->show()。入口只有一个调用,客户端不做任何类型判断。进入 Directory::show():先打印自己的名字 📁 project/,然后进入 for 循环遍历 children_。

第三步:递归下降。第一个孩子是 src(Directory),c->show(1) 再次进入 Directory::show():打印 📁 src/,再遍历它的两个孩子 main.cpp、util.h——它们是 File,调用 File::show() 打印文件信息后直接返回(叶子不再向下)。src 遍历完毕返回,root 继续处理下一个孩子 docs,同样递归一层。

第四步:调用 root->size()。同样的递归路线:Directory 的 size() 把各孩子的返回值累加,File 的 size() 返回自己的字节数。叶子返回值向上逐层汇聚:2048 + 512 = 2560(src),1024(docs),最终 root = 2560 + 1024 = 3584 bytes 打印输出。

第五步:析构。main() 返回,局部 unique_ptr root 析构 → Directory 析构 → 其 children_ 里的 unique_ptr 逐个析构 → src、docs 的 Directory 析构又触发它们孩子的析构……析构同样沿树递归级联,整棵树无泄漏地释放。Qt 示例的流程同理:app.exec() 进入事件循环 → 窗口需要重绘时 Qt 调用 paintEvent() → 创建 QPainter → root_->draw(p) 一次调用沿 GroupShape 递归绘制所有图元 → 事件循环继续。

理解要点:操作是"外来的一个调用",遍历是"容器自己的本能"。树的深度是 1 还是 100,客户端代码一字不改——这就是组合模式最迷人的地方。

⑧为什么这样设计

组合模式的每一个设计决策,都对应一个具体的工程收益:

诚实地讲,它也有代价:类型被过度统一后,客户端难以利用具体类型的特有功能(比如"文件"的扩展名、"部门"的预算)——通常需要配合访问者模式(Visitor)把"按类型分派"从客户端挪到树内部;透明式设计还牺牲了一部分编译期安全;深树递归存在栈溢出风险。这些正是面试里最常追问的权衡点。

⑨不使用会怎样

不用组合模式,面对"部分-整体"层次,代码会沿着下面这条路一步步腐烂:

// 反例:用类型枚举 + switch 模拟层次结构
enum class NodeKind { FILE, DIR };

struct Node {
    NodeKind kind;
    std::string name;
    std::size_t bytes = 0;                 // FILE 用
    std::vector<Node*> children;           // DIR 用
};

void printNode(const Node& n, int indent) {
    std::cout << std::string(indent, ' ') << n.name << "\n";
    if (n.kind == NodeKind::DIR)           // 每写一个操作,都要重新判断一次类型
        for (const auto* c : n.children) printNode(*c, indent + 2);
}

std::size_t totalSize(const Node& n) {
    if (n.kind == NodeKind::FILE) return n.bytes;   // 同样的分支再来一遍
    std::size_t sum = 0;
    for (const auto* c : n.children) sum += totalSize(*c);
    return sum;
}

它的四宗罪:

当"操作数"和"节点类型数"都在增长时,这种代码的维护成本是指数级的。组合模式的本质,就是把"类型判断"从每次操作里剥离,塞进类的继承体系里,让多态替你分派。

⑩何时使用

适合使用的场景:

不适合使用的场景:

过度设计提醒:只有两层、且几乎不会变深的结构(一个部门管一群员工),不要上组合模式——一个 std::vector<Employee> 就是正确答案。"万一以后会树化"不是抽象的理由,等你真的写出第二份重复的递归遍历时,再重构为组合,成本极低(这正是"三次法则")。

⑪与其他模式的区别

① 组合模式 vs 装饰器模式(Composite vs Decorator)——最易混淆的一对。两者都长着"基类 + 子类持有基类引用"的脸,但方向完全不同:

维度组合模式装饰器模式
结构一对多聚合:容器装多个孩子,树形分叉一对一包装:装饰器只包一个对象,线性链
目的统一"部分-整体",让客户端一致对待不改变接口,动态地为单个对象叠加职责
关键动作把操作递归转发给所有孩子转发给唯一被包装者,前后可附加行为

两者还经常合作:用装饰器给组合树中的节点动态加职责(比如给文件系统节点加"加密/压缩"装饰),用组合把多个装饰结果组装成整体。

② 组合模式 vs 享元模式(Composite vs Flyweight):组合树规模一大(上百万个叶子),叶子之间的重复内部状态(相同的图标、相同的权限组)会浪费内存。享元让大量叶子共享同一份内部状态,只各自持有外部状态。组合解决"结构",享元解决"存储"。③ 组合模式 vs 迭代器模式(Composite vs Iterator):组合负责"制造"树,迭代器负责"遍历"树。把前序/后序/广度优先遍历写进迭代器,客户端就能用统一的方式遍历任何组合结构,两者是天然的搭档。

⑫Qt源码中的体现

组合模式在 Qt 里不是"某个类",而是整个框架的骨架——尤其是 QObject 体系:

给一个可以直接验证的观察:QWidget::findChildren<QPushButton*>() 能一次性找出窗口里所有层级的按钮——这个"递归搜索全部后代"的能力,正是 QObject 组合树遍历接口的体现。你在 Qt 里做 UI 布局时,其实每天都在无意识地使用组合模式。

⑬面试常见问题

Q1:透明式(Transparent)和安全式(Safe)两种实现怎么选?

A:透明式把 add/remove 放在抽象基类,客户端一致性好、无需向下转型,但叶子调用 add 是非法操作,只能空实现或抛异常,问题推迟到运行时;安全式只把 add/remove 放在容器类,编译期就杜绝"给叶子加孩子",但客户端管理子节点前必须 dynamic_cast,丧失了一致性。C++ 里若追求编译期安全、客户端又需要区分容器时选安全式;若客户端绝大多数时候只做"统一操作",选透明式更简洁。Qt 的 QObject 是透明式代表。

Q2:树很深、操作又频繁递归,栈溢出怎么办?

A:三个方向——① 用显式栈把递归改成迭代:前序遍历可用 std::stack<Component*> 压栈展开,深度只受堆限制;② 把遍历封装成迭代器(配合 Iterator 模式),客户端逐节点取用,不递归;③ 实在要递归时评估深度上限,必要时增大线程栈。另外要注意析构的递归也可能爆栈——深度极大时考虑用迭代式析构或 shared_ptr 的引用计数释放。

Q3:Composite 和 Decorator 结构上那么像,怎么区分?

A:看"持有几个对象、为了什么"。Composite 持有多个孩子(一对多,树分叉),目的是统一部分-整体、让操作递归作用于整棵树;Decorator 只包一个对象(一对一,线性链),目的是在不改接口的前提下动态叠加职责。二者可组合使用,但语义不同:一个在"组装",一个在"修饰"。

Q4:组合树里子节点的所有权和生命周期怎么管理?

A:现代 C++ 推荐 std::unique_ptr 独占子节点——析构自动级联、无泄漏,容器被移动/复制时所有权随之转移;若需要共享子树则用 std::shared_ptr,但要注意别让子节点反向持有父节点造成循环引用(用裸指针/weak_ptr 指父)。Qt 的答案不同:父 QObject 自动删除子 QObject,所以子对象绝不能栈上创建;深拷贝整棵树需要原型式的递归 clone。

Q5:组合模式怎么和 Visitor 配合?

A:Composite 的缺点之一是"操作多了就往容器里堆 if-else"。解法:在 Component 上加 accept(Visitor&),Composite 的 accept 遍历孩子并逐个转发 accept,叶子执行具体访问逻辑——"操作"从树中剥离成独立的 Visitor 类,新增操作只需新增一个 Visitor,树结构零改动。这样组合负责结构、访问者负责操作,两者叠加才构成完整的"开闭"。

⑭今日练习

需求(15-30 分钟):用组合模式实现"公司组织架构"。统一抽象 OrganizationUnit;Employee(叶子:姓名 + 工资);Department(容器:名字 + 子节点,子节点可以是员工也可以是子部门)。实现三个操作:show(indent) 打印组织树、totalSalary() 统计全公司工资、countEmployees() 统计员工总数(注意:部门不算员工,只算叶子)。然后在 main() 里搭一个"总公司 → 技术部(含 2 名员工)→ 前端组(含 2 名员工)+ 测试部(1 名员工)"的结构并输出。

提示 1:children 用 std::vector<std::unique_ptr<OrganizationUnit>>;Department 的 show 先打印自己的名字,再遍历孩子;countEmployees() 里叶子返回 1、容器返回孩子之和——但"是不是员工"这个判断属于叶子特有的语义,想一想怎么把它表达成统一接口(比如给 Component 加 virtual bool isEmployee() const { return false; },Employee 覆写返回 true)。

提示 2:做完后对照第②节的坏代码,数一数你少写了多少个分支;再问自己一个问题——如果新增一种 Intern(实习生,工资打八折)类型,你的代码要改几处?答案是 0 处,这就是 OCP 的验收标准。

⑮今日总结

一句话记忆:"树上的每一片叶子与每一根枝干共用同一张脸——容器自己不干活,只把活递归地派发给孩子们。"

代码特征信号(闻到这些味道,就该考虑组合模式):

明日预告:Day 9 装饰器模式(Decorator)——不修改类、不改接口,像叠 buff 一样一层层给对象动态加职责,与组合的"树形递归"正好构成一对镜像结构;QStyle、QTextDocument 的渲染链、Qt 事件过滤器里都藏着它的身影。