第11章:组合模式——让部分与整体统一对待
11.1 模式动机:为什么需要组合模式?
先问大家一个问题:你在开发文件系统时,怎么处理文件和文件夹?
文件可以双击打开,文件夹可以双击展开。它们的行为有相似之处,但又有区别。最麻烦的是,当你需要计算整个目录的大小时,你得递归遍历所有子文件夹和文件。
我早期做项目时,就遇到过这种场景。当时要做一个资源管理器,代码写得那叫一个痛苦——到处都是if (isFile) {...} else if (isDirectory) {...}。每次新增一种资源类型,就得改一堆判断逻辑。
组合模式就是来解决这个问题的。它的核心思想很简单:让客户端可以一致地对待单个对象和组合对象。说白了,就是让文件和文件夹用同一个接口操作。
组合模式的定义:将对象组合成树形结构以表示"部分-整体"的层次结构。组合模式使得用户对单个对象和组合对象的使用具有一致性。
11.2 透明式 vs 安全式:两种实现风格
组合模式有两种经典实现方式。嗯,这里要注意,它们各有优劣,没有绝对的好坏。
11.2.1 透明式组合模式
透明式,就是把所有操作都定义在抽象基类中。不管是叶子节点还是容器节点,接口都一样。
这样做的好处是:客户端完全不用关心对象类型。但坏处也很明显——叶子节点本来不需要Add()、Remove()这些方法,却不得不实现它们。
我曾经踩过的坑:用透明式时,叶子节点的Add()方法只能抛异常或空实现。结果有次新同事没看文档,直接调了叶子节点的Add(),运行时才发现异常。这种问题很难排查。
11.2.2 安全式组合模式
安全式,就是把容器特有的操作(Add、Remove等)只放在容器类中。叶子节点压根没有这些方法。
这样更安全,但客户端需要做类型判断——你得知道当前对象是叶子还是容器。
| 对比维度 | 透明式 | 安全式 |
|---|---|---|
| 接口统一性 | 完全统一 | 部分统一 |
| 类型安全性 | 低(运行时可能出错) | 高(编译期检查) |
| 客户端复杂度 | 低(无需类型判断) | 高(需要类型判断) |
| 适用场景 | 叶子操作与容器操作高度一致 | 叶子与容器行为差异较大 |
我个人习惯用安全式。虽然客户端代码稍微复杂点,但至少不会在运行时出幺蛾子。你想想看,线上系统出个空指针异常,那可比多写几行if判断要命多了。
11.3 C++代码实现:安全式组合模式
下面给出一个完整的C++实现。我选安全式,因为实际项目中更实用。
// 抽象组件
class FileSystemNode {
public:
virtual ~FileSystemNode() = default;
virtual void Display(int depth) = 0;
virtual long long GetSize() = 0;
};
// 叶子节点:文件
class File : public FileSystemNode {
private:
std::string name;
long long size;
public:
File(const std::string& n, long long s) : name(n), size(s) {}
void Display(int depth) override {
std::cout << std::string(depth * 2, ' ') << "- " << name
<< " (" << size << " bytes)" << std::endl;
}
long long GetSize() override {
return size;
}
};
// 容器节点:文件夹
class Directory : public FileSystemNode {
private:
std::string name;
std::vector<std::unique_ptr<FileSystemNode>> children;
public:
Directory(const std::string& n) : name(n) {}
void Add(std::unique_ptr<FileSystemNode> node) {
children.push_back(std::move(node));
}
void Remove(int index) {
if (index >= 0 && index < children.size()) {
children.erase(children.begin() + index);
}
}
void Display(int depth) override {
std::cout << std::string(depth * 2, ' ') << "+ " << name
<< " (directory)" << std::endl;
for (auto& child : children) {
child->Display(depth + 1);
}
}
long long GetSize() override {
long long total = 0;
for (auto& child : children) {
total += child->GetSize();
}
return total;
}
};
使用提示:注意这里用了std::unique_ptr管理子节点生命周期。如果你需要共享所有权,可以换成std::shared_ptr。但一般情况下,组合模式中父节点拥有子节点,unique_ptr就够了。
11.4 文件系统示例:实战演练
来看一个完整的例子。假设我们要模拟这样一个目录结构:
root/
├── documents/
│ ├── report.docx (500KB)
│ └── notes.txt (50KB)
├── photos/
│ ├── vacation.jpg (2MB)
│ └── family.jpg (1.5MB)
└── readme.txt (1KB)
用组合模式来构建:
int main() {
// 创建根目录
auto root = std::make_unique<Directory>("root");
// 创建documents目录
auto docs = std::make_unique<Directory>("documents");
docs->Add(std::make_unique<File>("report.docx", 500));
docs->Add(std::make_unique<File>("notes.txt", 50));
// 创建photos目录
auto photos = std::make_unique<Directory>("photos");
photos->Add(std::make_unique<File>("vacation.jpg", 2048));
photos->Add(std::make_unique<File>("family.jpg", 1536));
// 组装
root->Add(std::move(docs));
root->Add(std::move(photos));
root->Add(std::make_unique<File>("readme.txt", 1));
// 显示目录结构
std::cout << "Directory structure:" << std::endl;
root->Display(0);
// 计算总大小
std::cout << "\nTotal size: " << root->GetSize() << " KB" << std::endl;
return 0;
}
输出结果:
Directory structure:
+ root (directory)
+ documents (directory)
- report.docx (500 bytes)
- notes.txt (50 bytes)
+ photos (directory)
- vacation.jpg (2048 bytes)
- family.jpg (1536 bytes)
- readme.txt (1 bytes)
Total size: 4135 KB
看到没?不管是文件还是文件夹,调用Display()和GetSize()的方式完全一样。这就是组合模式的威力——递归遍历让代码变得极其简洁。
11.5 核心知识体系
下面这张图总结了组合模式的核心逻辑:
11.6 避坑指南与最佳实践
最后,分享几个实战经验:
- 不要滥用组合模式:只有树形结构才适合。如果你的对象关系是网状的,组合模式反而会带来麻烦。
- 注意内存管理:C++中要明确所有权。我一般用
unique_ptr,如果子节点需要被多个父节点共享,才考虑shared_ptr。 - 缓存计算结果:如果
GetSize()频繁调用,可以考虑在Directory中缓存总大小,只在子节点变化时更新。 - 迭代器支持:如果容器节点需要遍历,可以配合迭代器模式使用,让客户端更方便地访问子节点。
核心要点回顾:
- 组合模式让部分与整体被一致对待
- 透明式接口统一但牺牲安全性
- 安全式更可靠但客户端需做类型判断
- 递归遍历是组合模式的灵魂
- 文件系统是组合模式的经典应用场景
好了,组合模式就讲到这里。记住一句话:当你发现代码里到处都是类型判断时,就该考虑组合模式了。
公众号:蓝海资料掘金营,微信deep3321