目录

组合模式的定义

组合模式(Composite)的 GoF 定义是:将对象组合成树形结构以表示”部分-整体”的层次结构。Composite 使得用户对单个对象和组合对象的使用具有一致性。

定义里的”部分”和”整体”,对应到具体结构就是两类节点:不能再往下拆的是叶子节点(比如一个文件),可以继续容纳其他节点的是容器节点(比如一个目录)。这两类节点要能被统一对待,前提是它们实现同一个接口。

这种层次的一个直观例子是文件系统。一个目录里可以放文件,也可以放子目录,子目录里还能继续嵌套。要算一个目录的总大小,朴素做法是先判断每个条目是文件还是文件夹再分别处理;组合模式把这层判断消化在节点内部:直接对每个条目调 getSize(),是文件就返回自身大小,是目录就递归往下算。

组合模式的实现思路是把”叶子”和”容器”统一成一个抽象:两者都实现 Component 接口,容器内部持有一组 Component 指针,操作时递归向下转发。客户端只对着 Component 接口编程,具体如何计算交给节点自身,调用方不需要在调用前关心节点的具体层级和类型。

组合模式用于解决什么问题

如果不用组合模式,处理树形结构时就得对每个节点做类型判断。

比如一个文件系统,有 File 和 Directory 两种节点。

要计算总大小,朴素写法是这样:

class File {
public:
    int size;
    int getSize() { return size; }
};

class Directory {
public:
    std::vector<File*> files;
    std::vector<Directory*> subdirs;
    int getSize() {
        int total = 0;
        for (auto* f : files) total += f->getSize();
        for (auto* d : subdirs) total += d->getSize();
        return total;
    }
};

这里 Directory 必须维护两个列表:一个放文件、一个放子目录。每加一种新的节点类型(比如快捷方式 SymbolicLink),Directory 里就得加一个新列表,getSize() 里也得加一段循环。

更严重的是,客户端拿到一个节点后,如果不知道它是 File 还是 Directory,就得先做类型判断再决定怎么调用。每出现一个新的节点类型,所有遍历树的地方都得改。

组合模式则把 File 和 Directory 统一成 Component 接口。Directory 只持有一个 vector<Component*> 列表,不管里面装的是文件还是子目录,遍历时统一调用 getSize()。新增节点类型只要实现 Component 接口,Directory 和客户端代码零改动。

两种实现方式的对比如下。

节点类型数朴素写法组合模式
22 个列表 + 类型判断Component 接口 + 1 个统一列表
33 个列表 + 新增类型判断1 个统一列表,零改动
N(动态组合)每加一种类型改所有遍历处实现 Component 即可接入

节点类型越多、层级越深,组合模式的价值越大。

什么时候不该用组合模式

组合模式解决的是”部分-整体的树形结构”问题,前提是数据本身有这种层次。如果数据没有天然的层次、或者只是同构的扁平集合,上组合模式反而增加了一层间接,收益却不明显。

典型的反例是普通的一维集合:比如一组配置项、一批用户信息,天然就是扁平的列表,用 std::vector 加标准算法遍历处理就够了,没必要给每个元素套一个 Component 基类再塞进树里。

另一个点是子节点类型的约束。组合模式对”容器里能放什么”几乎没有限制,只要实现 Component 接口就能进 children 列表。如果你需要”这个目录只能放文件、不能放子目录”这类强约束,用组合模式会比较别扭,得在 add() 里手动做类型检查,而类型检查恰恰削弱了组合模式”统一对待”的优势。

简单说,数据呈层次、需要客户端统一操作、类型还可能扩展,这三点同时成立时,组合模式才比较合适。

组合模式 UML 类图

组合模式涉及三个角色:Component(统一接口)、Leaf(叶子节点)、Composite(容器节点)。

Composite 内部持有一个 Component 列表,列表里的元素既可以是 Leaf 也可以是另一个 Composite,这就构成了递归的树形结构。

flowchart BT
    Component["Component<br/>---<br/>&lt;&lt;interface&gt;&gt; Component<br/>---<br/>+getSize() : int<br/>+getName() : string<br/>+display(int) : void"]
    File["File<br/>---<br/>Leaf<br/>-name : string<br/>-size : int<br/>---<br/>+getSize() : int<br/>+getName() : string<br/>+display(int) : void"]
    Directory["Directory<br/>---<br/>Composite<br/>-name : string<br/>-children : vector&lt;Component*&gt;<br/>---<br/>+getSize() : int<br/>+getName() : string<br/>+display(int) : void<br/>+add(Component*) : void<br/>+remove(string) : void"]

    File -- 继承 --> Component
    Directory -- 继承 --> Component
    Directory -. 持有Component* .-> Component

    style Component fill:#dbeafe,stroke:#1e40af,stroke-width:2px
    style File fill:#fef3c7,stroke:#92400e
    style Directory fill:#fef3c7,stroke:#92400e

UML 对象交互图:工作流程

以”客户端对根目录调用 getSize()“为例,看一次递归调用是怎么穿过树的。

sequenceDiagram
    participant C as 客户端 Client
    participant D1 as Directory(根目录)
    participant F1 as File(readme.md)
    participant D2 as Directory(子目录 src)
    participant F2 as File(main.cpp)

    C->>D1: getSize()
    Note over D1: 遍历 children 列表
    D1->>F1: getSize()
    F1-->>D1: return 200
    D1->>D2: getSize()
    Note over D2: 遍历 children 列表
    D2->>F2: getSize()
    F2-->>D2: return 300
    D2-->>D1: return 300
    D1-->>C: return 500

从时序图可以看到,调用从客户端的 getSize() 进入根目录,之后根目录不关心每个子节点是什么类型,一律遍历 children 往下递归。叶子节点把自身大小直接返回,容器节点继续向下展开,直到触到叶子。整个过程中客户端只发起了那一次调用。时序图是简化示意,只画了两条分支,实际 main 算出的是 700 bytes,见下文伪代码。

伪代码:将 UML 类转变为实现

接下来把图里的 Component、File、Directory 三个角色依次落地。

以下代码为说明原理的示意性伪代码,省略头文件与 include guard 等内容。

先定义 Component 接口,Leaf 和 Composite 都实现它:

class Component {
public:
    virtual ~Component() = default;
    virtual int getSize() const = 0;
    virtual std::string getName() const = 0;
    virtual void display(int depth = 0) const = 0;
};

File 是 Leaf,没有子节点,直接返回自己的数据:

class File : public Component {
    std::string name;
    int size;
public:
    File(std::string n, int s) : name(std::move(n)), size(s) {}
    int getSize() const override {
        return size;    // 叶子节点直接返回
    }
    std::string getName() const override { return name; }
    void display(int depth = 0) const override {
        std::string indent(static_cast<size_t>(depth) * 2, ' ');
        std::cout << indent << "[F] " << name
                  << " (" << size << " bytes)\n";
    }
};

Directory 是 Composite,持有子节点列表,操作时递归向下转发:

class Directory : public Component {
    std::string name;
    std::vector<std::unique_ptr<Component>> children;
public:
    explicit Directory(std::string n) : name(std::move(n)) {}

    void add(std::unique_ptr<Component> c) {
        children.push_back(std::move(c));
    }
    void remove(const std::string& childName) {
        // erase-remove idiom
        children.erase(
            std::remove_if(children.begin(), children.end(),
                [&](const auto& c) { return c->getName() == childName; }),
            children.end()
        );
    }

    int getSize() const override {
        int total = 0;
        for (const auto& c : children) {
            total += c->getSize();    // 递归:Leaf 直接返回,Composite 继续往下
        }
        return total;
    }
    std::string getName() const override { return name; }
    void display(int depth = 0) const override {
        std::string indent(static_cast<size_t>(depth) * 2, ' ');
        std::cout << indent << "[D] " << name << "/\n";
        for (const auto& c : children) {
            c->display(depth + 1);    // 每个子节点缩进加一层
        }
    }
};

Client 端组装树并调用:

int main() {
    auto root = std::make_unique<Directory>("root");

    root->add(std::make_unique<File>("readme.md", 200));
    root->add(std::make_unique<File>("config.yaml", 50));

    auto src = std::make_unique<Directory>("src");
    src->add(std::make_unique<File>("main.cpp", 300));
    src->add(std::make_unique<File>("utils.cpp", 150));

    root->add(std::move(src));

    // 客户端只调一次,整棵树递归完成
    std::cout << "Total: " << root->getSize() << " bytes\n";
    // 输出: Total: 700 bytes

    root->display();
    // [D] root/
    //   [F] readme.md (200 bytes)
    //   [F] config.yaml (50 bytes)
    //   [D] src/
    //     [F] main.cpp (300 bytes)
    //     [F] utils.cpp (150 bytes)
}

这段代码里,递归的衔接发生在 File 和 Directory 之间:叶子直接返回、容器继续向下,调用方写一行 getSize() 就拿到了整棵树的合计,连 for 循环都不用自己写。

用 std::variant 取代继承层次

如果节点类型是编译期就能确定的封闭集合,C++17 的 std::variant 配合 std::visit 可以替代整套 Component 继承体系。思路是把”文件或目录”这个二选一关系直接表达成一个和类型,而不是一个基类加两个派生类。

// C++17:用 std::variant 表示一个封闭节点集合
struct File {
    std::string name;
    int size;
};

struct Directory;                        // 前置声明
using Node = std::variant<File, std::unique_ptr<Directory>>;

struct Directory {
    std::string name;
    std::vector<Node> children;
};

int getSize(const Node& node) {
    return std::visit([](const auto& x) -> int {
        using T = std::decay_t<decltype(x)>;
        if constexpr (std::is_same_v<T, File>) {
            return x.size;                        // 叶子直接返回
        } else {
            int total = 0;
            for (const auto& c : x->children)     // 容器,递归往下
                total += getSize(c);
            return total;
        }
    }, node);
}

variant 里放 unique_ptr 是为了让递归结构有合法大小:Directory 自己含 vector,如果 Node 直接含 Directory 就会无限大,改成指针就断开了这个循环。

这套写法有几个好处。一是省掉了虚函数派发的运行时开销:没有 Component 基类,File 直接以值类型存在,只有 Directory 用指针串起子节点。二是 std::visit 要求访问器能处理 variant 里的每一个 alternative,漏掉一种编译器直接报错,比在 Leaf 里忘了重写某个虚函数更早暴露问题。三是节点的持有方式由你自己定,想要值语义就平铺、想要共享就换 shared_ptr,不必被一个抽象基类绑死。

代价是节点类型变成了封闭集合,编译期就固定了。要加一种新节点(比如符号链接),得改 Node 的定义,再把所有 std::visit 的访问器补上对应分支,涉及的文件都要重新编译。经典 Composite 靠继承开放扩展,运行时加一个新子类不用动旧代码,这是 variant 做不到的。

两种写法怎么选,看你的场景:节点类型稳定、追求省去虚函数开销和更紧凑的内存布局,用 std::variant;节点类型需要运行时动态扩展、或者来自外部插件,经典 Composite 仍然更合适。另外,一旦用 variant 表达树,前面讨论的”add / remove 放在 Component 还是 Composite”之争也就不存在了,因为根本没有 Component 接口,容器节点直接操作自己的 children 向量即可。

透明式 vs 安全式:接口放哪里

组合模式有一个经典的设计分歧:add / remove 这些管理子节点的方法,到底放在 Component 接口里,还是只放在 Composite 里?

透明式 把这些方法提升到 Component 接口:

class Component {
public:
    virtual ~Component() = default;
    virtual int getSize() const = 0;
    virtual void add(std::unique_ptr<Component>) {}    // 叶子节点空实现
    virtual void remove(const std::string&) {}
};

好处是客户端拿到 Component* 就能直接调 add(),不用先确认手里是 Leaf 还是 Composite。代价是 Leaf 被迫继承一个它用不到的方法,通常实现为空操作或抛异常。这违反了”不要给子类它不需要的方法”的原则,运行时如果对 Leaf 调 add() 不是编译期错误而是运行时静默失败或异常。

安全式 把这些方法只留在 Composite 里:

class Component {
public:
    virtual ~Component() = default;
    virtual int getSize() const = 0;
    virtual void display(int) const = 0;
    // 不放 add / remove
};

class Directory : public Component {
public:
    void add(std::unique_ptr<Component>);
    void remove(const std::string&);
    // ...
};

好处是类型安全:编译期就不可能对 Leaf 调 add()。代价是客户端如果只有 Component*,想加子节点就得先 dynamic_cast<Directory*>,使用上多了一步。

GoF 原书倾向透明式,追求”统一接口”的纯粹性。实际 C++ 工程更倾向安全式,因为运行时异常比编译期多一次 cast 更麻烦。选哪种取决于你的树有多深、客户端多久需要动态增删子节点。如果树结构基本静态、只在构建期组装,安全式更合适;如果运行时频繁增删且客户端拿到的是泛化引用,透明式更省事。

认清几个类似模式的边界

组合模式容易和几个结构相似的模式搞混,分清楚边界能避免用错地方。

头号易混点是装饰器模式(Decorator)。两者都要求具体类共享同一个接口,也都在内部持有同类型的引用,但目的不同。组合模式是一对多地组织对象、搭出一棵”部分-整体”的树,关心的是结构;装饰器模式是一对一地包裹单个对象、在外面叠一层职责,关心的是行为。装饰器甚至可以把一个组合节点整个包起来,给那条分支上的所有节点统一加上某种行为。

组合模式也常和其他模式搭档。在树形结构上要做多种互不相干的操作(比如既要算大小、又要序列化、还要做校验),又不想给每个节点类都塞一堆方法时,可以用 访问者模式(Visitor) 把操作外提;宏命令本质上是命令模式(Command) 的组合,用组合结构把多个命令串成一个可整体执行的命令;当叶子节点大量重复、内存吃紧,可以用享元模式(Flyweight) 让多个父节点共享同一个叶子实例。

一张小表区分组合与装饰器:

维度组合模式装饰器模式
引用关系一对多,容器持有多个子节点一对一,包裹单个对象
关注点部分-整体的结构单个对象的行为增强
形状树形链式(也可能多层包裹)
典型操作递归遍历整棵子树在外层拦截并增强原方法

落到工程里要注意的几点

所有权要讲清楚。 本文伪代码用 unique_ptr 让 Composite 独占子节点的所有权,子节点的生命周期跟着容器走,容器析构时子节点自动释放。如果多个 Composite 需要共享同一个子节点,就得用 shared_ptr,但要小心循环引用。另一种选择是 Composite 只存裸指针,由外部管理生命周期,但这样就得保证子节点不会比容器先死。C++ 没有垃圾回收,所有权模型必须在设计阶段定下来。

Component 的析构函数必须声明为虚函数。 否则通过 Component 指针删除 Composite 对象时,Composite 的析构不会被调用,children 列表里的 unique_ptr 不会释放,直接内存泄漏。基类写 virtual ~Component() = default; 就够了。

递归调用栈深度是隐患。 getSize() 和 display() 都是递归的,树有多深调用栈就有多深。大多数文件系统的深度不会成问题,但如果你在处理用户构造的树(比如解析 XML/JSON),恶意输入可以构造出极深的嵌套导致栈溢出。对此可以把递归改成显式栈加循环,牺牲可读性换安全性。

遍历时增删子节点会导致迭代器失效。 如果在遍历 children 的过程中 add 或 remove 了某个子节点,vector 的迭代器就废了,继续遍历是未定义行为。解决办法是遍历前拷贝一份 children 列表,或者遍历时只标记、遍历后统一增删。

小心子树自引用和循环引用。 如果 add() 把关心的节点本身、或者它的某个祖先加进了自己的 children,递归遍历会陷入无限循环,直到栈溢出。构建树时应当保证子节点既不是自身也不是祖先,必要时在 add() 里做自检。另一个隐患是内存环:本文示例用 unique_ptr 让 Composite 独占子节点,关系是单向的,天然不会成环;一旦改用 shared_ptr,若子节点又用 shared_ptr 指回父节点,引用计数就归不了零,两个节点都析构不掉。规避办法是让子节点用 weak_ptr 持有父节点的引用,而不是 shared_ptr,把强引用环断开。

display() 不是组合模式的核心。 本文把 display() 放进 Component 接口是为了演示方便,但组合模式的本质是”统一接口加递归转发”,不局限于展示功能。实际工程中 getSize()、search()、serialize()、validate() 这类需要递归遍历整棵树的操作才是组合模式真正发挥作用的地方。