组合模式:模式动机与定义
组合模式,说白了就是「部分-整体」的树形结构。你想想看,文件系统里一个文件夹可以包含文件,也可以包含其他文件夹。这种「容器-叶子」的关系,在代码里怎么优雅表达?
我刚开始做架构时,遇到过一个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是文件还是目录。这就是组合模式的价值——统一接口,递归处理。
下面这张图展示了组合模式在文件系统中的结构:
看到没?Directory和File都继承自FileSystemNode,但Directory可以包含子节点。客户端调用list()时,完全不用区分当前节点是文件还是目录——递归帮你搞定一切。
我个人习惯在项目中使用组合模式来处理任何树形结构:菜单系统、组织架构、XML解析、UI控件树。只要遇到「部分-整体」的层次关系,第一个想到的就是它。
避坑指南:我曾经在一个项目中把组合模式用在了非树形结构上,结果代码变得极其别扭。记住,组合模式只适合树形结构,不是所有「包含」关系都适合用。如果子节点类型差异太大,或者层次深度有限,用简单的循环可能更合适。