组合模式:树形结构,透明式与安全式
组合模式,说白了就是处理「部分-整体」关系的一种设计思路。你想想看,文件系统里一个文件夹可以包含文件,也可以包含子文件夹,子文件夹里又能继续嵌套——这种树形结构,用组合模式来建模再合适不过了。
我个人习惯把组合模式理解为「让树枝和树叶用同样的接口」。这样客户端就不用关心自己操作的是单个对象还是组合对象,统一对待就行。
核心思想
组合模式的核心就三个角色:
- Component(抽象构件):定义叶子节点和容器节点的公共接口
- Leaf(叶子节点):没有子节点的对象,比如文件
- Composite(容器节点):有子节点的对象,比如文件夹
嗯,这里要注意:叶子节点和容器节点都继承自同一个抽象接口,但容器节点额外维护一个子节点列表。
两种实现风格
我在项目中遇到过两种实现方式,分别叫透明式和安全式。它们各有优劣,我分别讲讲。
透明式组合模式
透明式把所有方法都定义在抽象基类里,包括添加、删除子节点的方法。叶子节点虽然不支持这些操作,但也要提供空实现或者抛出异常。
class FileSystemNode {
public:
virtual ~FileSystemNode() = default;
virtual void display(int depth = 0) = 0;
virtual void add(FileSystemNode* node) {
throw std::runtime_error("不支持添加子节点");
}
virtual void remove(FileSystemNode* node) {
throw std::runtime_error("不支持删除子节点");
}
};
class File : public FileSystemNode {
std::string name;
public:
File(const std::string& n) : name(n) {}
void display(int depth = 0) override {
std::cout << std::string(depth, '-') << name << std::endl;
}
};
class Folder : public FileSystemNode {
std::string name;
std::vector<FileSystemNode*> children;
public:
Folder(const std::string& n) : name(n) {}
void add(FileSystemNode* node) override {
children.push_back(node);
}
void remove(FileSystemNode* node) override {
auto it = std::find(children.begin(), children.end(), node);
if (it != children.end()) children.erase(it);
}
void display(int depth = 0) override {
std::cout << std::string(depth, '-') << name << "/" << std::endl;
for (auto child : children) {
child->display(depth + 2);
}
}
};
透明式的优点是客户端代码统一,不用区分文件和文件夹。但缺点也很明显——叶子节点有add/remove方法,调用就会抛异常,这其实违反了接口隔离原则。
安全式组合模式
安全式把add/remove方法只放在容器节点里,抽象基类只定义公共操作。这样叶子节点压根没有add/remove方法,编译期就杜绝了误调用。
class FileSystemNode {
public:
virtual ~FileSystemNode() = default;
virtual void display(int depth = 0) = 0;
};
class File : public FileSystemNode {
std::string name;
public:
File(const std::string& n) : name(n) {}
void display(int depth = 0) override {
std::cout << std::string(depth, '-') << name << std::endl;
}
};
class Folder : public FileSystemNode {
std::string name;
std::vector<FileSystemNode*> children;
public:
Folder(const std::string& n) : name(n) {}
void add(FileSystemNode* node) {
children.push_back(node);
}
void remove(FileSystemNode* node) {
auto it = std::find(children.begin(), children.end(), node);
if (it != children.end()) children.erase(it);
}
void display(int depth = 0) override {
std::cout << std::string(depth, '-') << name << "/" << std::endl;
for (auto child : children) {
child->display(depth + 2);
}
}
};
安全式的缺点是客户端代码需要做类型判断。比如遍历时,你得用dynamic_cast或者typeid来判断当前节点是文件还是文件夹,才能调用add/remove。
两种风格的对比
| 对比维度 | 透明式 | 安全式 |
|---|---|---|
| 接口统一性 | 完全统一,客户端无需区分 | 需要区分叶子与容器 |
| 编译期安全性 | 低,误调用在运行时暴露 | 高,误调用在编译期暴露 |
| 代码复杂度 | 基类方法多,叶子有冗余实现 | 基类简洁,客户端逻辑稍复杂 |
| 适用场景 | 客户端极少区分叶子与容器 | 客户端经常需要区分类型 |
文件系统模拟的完整示例
我写一个安全式的完整例子,模拟一个简单的文件系统。这样你看起来更直观。
#include <iostream>
#include <vector>
#include <string>
#include <algorithm>
class FileSystemNode {
public:
virtual ~FileSystemNode() = default;
virtual void display(int depth = 0) = 0;
virtual size_t getSize() = 0;
};
class File : public FileSystemNode {
std::string name;
size_t size;
public:
File(const std::string& n, size_t s) : name(n), size(s) {}
void display(int depth = 0) override {
std::cout << std::string(depth, ' ') << "📄 " << name
<< " (" << size << " bytes)" << std::endl;
}
size_t getSize() override { return size; }
};
class Folder : public FileSystemNode {
std::string name;
std::vector<FileSystemNode*> children;
public:
Folder(const std::string& n) : name(n) {}
~Folder() {
for (auto child : children) delete child;
}
void add(FileSystemNode* node) {
children.push_back(node);
}
void remove(FileSystemNode* node) {
auto it = std::find(children.begin(), children.end(), node);
if (it != children.end()) {
children.erase(it);
}
}
void display(int depth = 0) override {
std::cout << std::string(depth, ' ') << "📁 " << name << "/" << std::endl;
for (auto child : children) {
child->display(depth + 2);
}
}
size_t getSize() override {
size_t total = 0;
for (auto child : children) {
total += child->getSize();
}
return total;
}
};
// 使用示例
int main() {
Folder* root = new Folder("root");
Folder* docs = new Folder("docs");
Folder* src = new Folder("src");
docs->add(new File("readme.txt", 1024));
docs->add(new File("notes.md", 2048));
src->add(new File("main.cpp", 4096));
src->add(new File("utils.h", 1024));
root->add(docs);
root->add(src);
root->add(new File("Makefile", 512));
root->display();
std::cout << "总大小: " << root->getSize() << " bytes" << std::endl;
delete root;
return 0;
}
组合模式的知识体系
下面这张图帮你理清组合模式的核心脉络:
实际项目中的选择建议
我个人更倾向于安全式。原因很简单——编译期能发现的问题,就不要拖到运行时。尤其是团队协作的项目,你没法保证每个人都清楚哪些节点能add。
不过如果你做的是一个内部工具,团队就你一个人,透明式写起来确实更省事。少写几个dynamic_cast,代码看起来也清爽。
组合模式的适用场景
- 文件系统、目录树
- GUI组件树(窗口包含面板,面板包含按钮)
- XML/JSON文档的节点结构
- 组织架构树(部门包含子部门,子部门包含员工)
- 菜单系统(菜单包含菜单项,菜单项可以包含子菜单)
组合模式的核心价值在于:让客户端用一致的方式处理单个对象和组合对象。你不需要写if-else来判断当前操作的是文件还是文件夹,直接调用display()就行。这种统一性,说白了就是多态的魅力。
嗯,最后提醒一句:组合模式虽然好用,但别滥用。如果你的树只有两层,或者叶子节点和容器节点的行为差异很大,硬套组合模式反而会让代码变得别扭。设计模式是工具,不是教条。