组合模式:模式动机与定义

组合模式,说白了就是「部分-整体」的树形结构。你想想看,文件系统里一个文件夹可以包含文件,也可以包含其他文件夹。这种「容器-叶子」的关系,在代码里怎么优雅表达?

我刚开始做架构时,遇到过一个UI渲染引擎。每个控件都要绘制自己,但容器控件还要先绘制子控件。那时候我写了一大堆if-else判断类型,代码又臭又长。后来用了组合模式,整个世界清净了。

模式动机

为什么需要组合模式?核心动机就一个:让客户端可以一致地处理单个对象和组合对象

举个例子,你写一个绘图程序:

  • 圆形是叶子节点,直接绘制
  • 矩形是叶子节点,直接绘制
  • 组合图形包含多个子图形,绘制时先绘制自己再绘制子节点

如果没有组合模式,客户端代码得这么写:

if (obj is Circle) {
    drawCircle(obj);
} else if (obj is Rectangle) {
    drawRectangle(obj);
} else if (obj is Composite) {
    for (auto child : obj.children) {
        // 递归判断类型...
    }
}

这代码,我看着都头疼。组合模式就是要干掉这些类型判断。

模式定义

组合模式的定义其实很简单:将对象组合成树形结构以表示「部分-整体」的层次结构。组合模式使得用户对单个对象和组合对象的使用具有一致性。

核心角色有三个:

角色 说明 我见过的坑
Component(抽象构件) 声明叶子节点和容器节点的公共接口 接口设计太胖,把容器方法也塞进去了
Leaf(叶子构件) 没有子节点的对象,实现具体行为 叶子节点实现add/remove时抛出异常,容易忘
Composite(容器构件) 有子节点,实现管理子节点的方法 递归操作时忘记处理空指针

核心思想:树形结构中的每个节点,不管是叶子还是容器,对外暴露的接口都一样。客户端不需要知道自己在操作单个对象还是组合对象。

透明式与安全式

这里有个经典争论:叶子节点要不要有add/remove方法?

这就引出了组合模式的两种实现风格。

透明式(Transparent)

透明式把所有方法都放在抽象基类里。叶子节点和容器节点拥有完全相同的接口。

优点很明显:客户端完全透明,不用区分类型。但缺点也致命:叶子节点得实现一些没意义的方法,比如add一个子节点——叶子哪来的子节点?

我见过一个项目,叶子节点的add方法直接抛异常。结果线上出bug,因为有人忘了catch这个异常。嗯,这种设计其实是在埋雷。

// 透明式:叶子节点也有add/remove
class FileSystemNode {
public:
    virtual void add(FileSystemNode* node) = 0;
    virtual void remove(FileSystemNode* node) = 0;
    virtual void display() = 0;
};

class File : public FileSystemNode {
public:
    void add(FileSystemNode* node) override {
        throw std::runtime_error("File cannot add child");
    }
    void remove(FileSystemNode* node) override {
        throw std::runtime_error("File cannot remove child");
    }
    void display() override {
        std::cout << "File: " << name << std::endl;
    }
};

安全式(Safe)

安全式把管理子节点的方法放在容器类里。抽象基类只声明公共操作。

这样做的好处是:叶子节点干净利落,不会出现「不支持的操作」。但客户端代码需要判断类型,才能调用add/remove。

// 安全式:抽象基类只有公共操作
class FileSystemNode {
public:
    virtual void display() = 0;
    virtual ~FileSystemNode() = default;
};

class File : public FileSystemNode {
public:
    void display() override {
        std::cout << "File: " << name << std::endl;
    }
};

class Directory : public FileSystemNode {
public:
    void add(FileSystemNode* node) {
        children.push_back(node);
    }
    void remove(FileSystemNode* node) {
        // 查找并移除
    }
    void display() override {
        std::cout << "Directory: " << name << std::endl;
        for (auto child : children) {
            child->display();
        }
    }
private:
    std::vector<FileSystemNode*> children;
};

我的建议:大多数场景用安全式。虽然客户端多了一个类型判断,但接口语义清晰,不会出现运行时异常。如果你做的是框架设计,客户端完全由你控制,透明式也可以考虑。

C++代码实现

咱们用C++17写一个完整的组合模式实现。这次我选安全式,因为——嗯,我吃过透明式的亏。

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

// 抽象构件
class Graphic {
public:
    virtual void draw() const = 0;
    virtual ~Graphic() = default;
};

// 叶子构件:圆形
class Circle : public Graphic {
public:
    explicit Circle(std::string name) : name_(std::move(name)) {}
    
    void draw() const override {
        std::cout << "Draw Circle: " << name_ << std::endl;
    }
    
private:
    std::string name_;
};

// 叶子构件:矩形
class Rectangle : public Graphic {
public:
    explicit Rectangle(std::string name) : name_(std::move(name)) {}
    
    void draw() const override {
        std::cout << "Draw Rectangle: " << name_ << std::endl;
    }
    
private:
    std::string name_;
};

// 容器构件:组合图形
class CompositeGraphic : public Graphic {
public:
    void add(std::unique_ptr<Graphic> graphic) {
        children_.push_back(std::move(graphic));
    }
    
    void draw() const override {
        std::cout << "Composite Graphic start:" << std::endl;
        for (const auto& child : children_) {
            child->draw();
        }
        std::cout << "Composite Graphic end" << std::endl;
    }
    
private:
    std::vector<std::unique_ptr<Graphic>> children_;
};

// 使用示例
int main() {
    auto root = std::make_unique<CompositeGraphic>();
    
    auto circle1 = std::make_unique<Circle>("Red Circle");
    auto rect1 = std::make_unique<Rectangle>("Blue Rectangle");
    
    auto subGroup = std::make_unique<CompositeGraphic>();
    subGroup->add(std::make_unique<Circle>("Small Circle"));
    subGroup->add(std::make_unique<Rectangle>("Small Rectangle"));
    
    root->add(std::move(circle1));
    root->add(std::move(rect1));
    root->add(std::move(subGroup));
    
    root->draw();
    
    return 0;
}

注意:这里用了std::unique_ptr管理所有权。如果你需要共享子节点,改用std::shared_ptr。我曾经在一个项目中混用了原始指针和智能指针,结果内存泄漏查了两天——别学我。

文件系统示例

文件系统是组合模式的经典应用。咱们来实现一个简化版。

class FileSystemNode {
public:
    virtual size_t getSize() const = 0;
    virtual void list(int indent = 0) const = 0;
    virtual ~FileSystemNode() = default;
    
protected:
    void printIndent(int indent) const {
        for (int i = 0; i < indent; ++i) {
            std::cout << "  ";
        }
    }
};

class File : public FileSystemNode {
public:
    File(std::string name, size_t size) 
        : name_(std::move(name)), size_(size) {}
    
    size_t getSize() const override {
        return size_;
    }
    
    void list(int indent = 0) const override {
        printIndent(indent);
        std::cout << "[File] " << name_ 
                  << " (" << size_ << " bytes)" << std::endl;
    }
    
private:
    std::string name_;
    size_t size_;
};

class Directory : public FileSystemNode {
public:
    explicit Directory(std::string name) 
        : name_(std::move(name)) {}
    
    void add(std::unique_ptr<FileSystemNode> node) {
        children_.push_back(std::move(node));
    }
    
    size_t getSize() const override {
        size_t total = 0;
        for (const auto& child : children_) {
            total += child->getSize();
        }
        return total;
    }
    
    void list(int indent = 0) const override {
        printIndent(indent);
        std::cout << "[Dir] " << name_ << " ("
                  << getSize() << " bytes)" << std::endl;
        for (const auto& child : children_) {
            child->list(indent + 1);
        }
    }
    
private:
    std::string name_;
    std::vector<std::unique_ptr<FileSystemNode>> children_;
};

使用起来很直观:

auto root = std::make_unique<Directory>("root");
root->add(std::make_unique<File>("readme.txt", 1024));
root->add(std::make_unique<File>("config.ini", 512));

auto subDir = std::make_unique<Directory>("src");
subDir->add(std::make_unique<File>("main.cpp", 2048));
subDir->add(std::make_unique<File>("utils.h", 1024));

root->add(std::move(subDir));
root->list();

输出结果:

[Dir] root (4608 bytes)
  [File] readme.txt (1024 bytes)
  [File] config.ini (512 bytes)
  [Dir] src (3072 bytes)
    [File] main.cpp (2048 bytes)
    [File] utils.h (1024 bytes)

关键点:Directory的getSize()递归调用子节点的getSize()。客户端调用root->getSize()时,根本不用关心root是文件还是目录。这就是组合模式的价值——统一接口,递归处理。

下面这张图展示了组合模式在文件系统中的结构:

组合模式 - 文件系统结构图 root (Directory) readme.txt (File) config.ini (File) src (Directory) main.cpp (File) utils.h (File) build.sh (File) Directory(容器) File(叶子) 子Directory(容器)

看到没?Directory和File都继承自FileSystemNode,但Directory可以包含子节点。客户端调用list()时,完全不用区分当前节点是文件还是目录——递归帮你搞定一切。

我个人习惯在项目中使用组合模式来处理任何树形结构:菜单系统、组织架构、XML解析、UI控件树。只要遇到「部分-整体」的层次关系,第一个想到的就是它。

避坑指南:我曾经在一个项目中把组合模式用在了非树形结构上,结果代码变得极其别扭。记住,组合模式只适合树形结构,不是所有「包含」关系都适合用。如果子节点类型差异太大,或者层次深度有限,用简单的循环可能更合适。

公众号:蓝海资料掘金营,微信deep3321