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 原则:
printTree、totalSalary 等每一处遍历代码都要跟着加一个分支、改一遍——对修改开放,对扩展关闭;Employee / Department,而不是一个抽象的"组织单元";根本问题在于:"部门"既是树的节点,又是树的容器,而代码把这两件事拆成了两套互不相通的 API。组合模式要做的,就是让"部分"与"整体"共享同一个接口——递归,代替了所有散落的手写遍历。
组合模式的核心是递归定义:"整体由部分组成,而部分本身又可以是整体。"它把对象划分为两类角色,但给它们一个共同的抽象基类:
所谓"递归转发",就是容器自己不真正执行操作,而是把操作"下放"给孩子;孩子如果是容器,继续下放;直到叶子真正执行。这样,一个操作作用于整棵树,成本由树的遍历天然承担,而客户端永远只面对 Component 一个类型。
生活类比——文件系统:"文件"是叶子,"文件夹"是容器,而文件夹里可以再放文件夹。"删除"一个文件夹,会递归删除里面的所有文件和子文件夹;"计算大小"一个文件夹,会递归累加所有子孙的字节数。你永远不会问"这个路径是文件还是文件夹"才决定怎么删——操作系统替你统一了。组合模式就是把这个机制搬进你的类设计里。
同样的类比:公司组织架构(员工是叶子、部门是容器)、军队编制(士兵是叶子、班排连是容器,"集合"命令从连逐级下达到每个士兵)、HTML 的 DOM 树(<div> 既能包文本也能包另一个 <div>)、电商的"套餐"(套餐里可以包含单品,也可以包含另一个套餐)。
组合模式的类图是一棵"自我指涉"的树:
┌─────────────────────────┐
│ Component(抽象组件) │
│ + operation() │
│ + add()/remove()/child() │← 孩子管理接口(可选)
└────────────┬────────────┘
┌───────────────┴───────────────┐
┌──────────┴──────────┐ ┌───────────┴───────────┐
│ Leaf(叶子) │ │ Composite(组合) │
│ · 没有子节点 │ │ · children: 容器 │
│ · operation() 真干活│ │ · operation() 遍历转发 │
└─────────────────────┘ │ · add/remove 管理孩子 │
└───────────────────────┘
▲
│ 只依赖抽象 Component
┌───────┴────────┐
│ Client(客户端) │
└────────────────┘
角色职责:
operation();是否把 add()/remove()/getChild() 也放进抽象类,决定了"透明式"还是"安全式"(见下);operation() 的真实逻辑;没有子节点,对 add() 通常给出空实现或抛异常;std::vector<std::unique_ptr<Component>> 之类),operation() 的实现就是"遍历 children,逐个调用它们的 operation()";同时实现孩子管理方法;Component* 打交道,从不关心(也不需要关心)它指向的是叶子还是容器。两个经典设计变体:
add()/remove() 放在 Component 基类里,客户端可以统一调用——但叶子调用 add() 是非法操作,只能空实现或抛异常,牺牲了一点运行时安全,换来了完全的一致性;add()——类型安全,但客户端想添加子节点时必须先 dynamic_cast 成 Composite,丧失了一致性。C++ 实践中的取舍:Qt 的 QObject 父子树把 setParent() 放在基类,属于偏透明式;而现代 C++ 代码更偏爱安全式 + 智能指针,因为 dynamic_cast 的成本和风险都比运行时异常可控。
用组合模式实现一个极简文件系统: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。
做一个 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::setParent() 就是 add(),children()/findChild()/findChildren() 就是遍历接口;父对象析构时递归删除所有子对象——deleteLater() 与父子树共同构成了 Qt 对象生命周期的地基;setLayout()/addWidget() 把 widget 组装成界面树,布局系统递归计算每个节点的尺寸;QLayoutItem 是抽象 Component,QLayout 是 Composite(可嵌套布局),QSpacerItem/部件项是 Leaf,sizeHint() 沿树递归聚合;QGraphicsItemGroup 可以包含任意 QGraphicsItem,场景的碰撞检测、绘制都对整棵图元树递归进行;index()/parent() 显式建立树形父子关系,QTreeView 展示的正是组合结构;这解释了为什么"学 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,客户端代码一字不改——这就是组合模式最迷人的地方。
组合模式的每一个设计决策,都对应一个具体的工程收益:
Component 抽象而不是具体类,高层模块不再被低层细节绑架;诚实地讲,它也有代价:类型被过度统一后,客户端难以利用具体类型的特有功能(比如"文件"的扩展名、"部门"的预算)——通常需要配合访问者模式(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;
}
它的四宗罪:
switch (kind) 就多一份;每增加一种节点类型(链接、压缩包、快捷方式),每一个 switch 都要改——修改点数量 = 操作数 × 类型数;children 谁负责 delete?浅拷贝会双重释放,深拷贝要手写递归——而组合模式 + unique_ptr 让这一切自动发生。当"操作数"和"节点类型数"都在增长时,这种代码的维护成本是指数级的。组合模式的本质,就是把"类型判断"从每次操作里剥离,塞进类的继承体系里,让多态替你分派。
适合使用的场景:
不适合使用的场景:
std::vector 就够,抽象反而添乱;过度设计提醒:只有两层、且几乎不会变深的结构(一个部门管一群员工),不要上组合模式——一个 std::vector<Employee> 就是正确答案。"万一以后会树化"不是抽象的理由,等你真的写出第二份重复的递归遍历时,再重构为组合,成本极低(这正是"三次法则")。
① 组合模式 vs 装饰器模式(Composite vs Decorator)——最易混淆的一对。两者都长着"基类 + 子类持有基类引用"的脸,但方向完全不同:
| 维度 | 组合模式 | 装饰器模式 |
|---|---|---|
| 结构 | 一对多聚合:容器装多个孩子,树形分叉 | 一对一包装:装饰器只包一个对象,线性链 |
| 目的 | 统一"部分-整体",让客户端一致对待 | 不改变接口,动态地为单个对象叠加职责 |
| 关键动作 | 把操作递归转发给所有孩子 | 转发给唯一被包装者,前后可附加行为 |
两者还经常合作:用装饰器给组合树中的节点动态加职责(比如给文件系统节点加"加密/压缩"装饰),用组合把多个装饰结果组装成整体。
② 组合模式 vs 享元模式(Composite vs Flyweight):组合树规模一大(上百万个叶子),叶子之间的重复内部状态(相同的图标、相同的权限组)会浪费内存。享元让大量叶子共享同一份内部状态,只各自持有外部状态。组合解决"结构",享元解决"存储"。③ 组合模式 vs 迭代器模式(Composite vs Iterator):组合负责"制造"树,迭代器负责"遍历"树。把前序/后序/广度优先遍历写进迭代器,客户端就能用统一的方式遍历任何组合结构,两者是天然的搭档。
组合模式在 Qt 里不是"某个类",而是整个框架的骨架——尤其是 QObject 体系:
QObject(QObject* parent) 构造即挂树,setParent() 即 add(),children()/findChild()/findChildren() 即遍历接口;父对象析构时递归删除全部子对象,这是 Qt 内存管理的基石。注意 Qt 选择的是"透明式":孩子管理接口放在基类 QObject 上,代价是 QTimer、QAction 这类"本不该有孩子"的叶子对象也能挂孩子——Qt 用文档约束替代了编译期禁止;setParent() 或布局系统把 widget 组织成界面树;重绘(paintEvent)、事件分发都沿树递归;QLayoutItem 是抽象 Component,QLayout 是 Composite(布局可以嵌布局),QSpacerItem 与 widget 条目是 Leaf;sizeHint()/setGeometry() 沿布局树递归聚合——这就是"一个窗口的尺寸是怎么算出来的";QGraphicsItemGroup 是容器,任意 QGraphicsItem 都可入组;场景的绘制、碰撞检测、itemAt() 全部递归处理整棵图元树;index(row, column, parent) 和 parent(index) 显式表达父子关系,QTreeView 就是组合结构的视图;给一个可以直接验证的观察: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 的验收标准。