组合模式:透明组合与安全组合、叶子节点与容器节点,以及文件系统树形结构实现
组合模式,说白了就是处理「部分-整体」层次结构的一种设计模式。你想想看,文件系统里一个文件夹可以包含文件,也可以包含子文件夹,子文件夹又能继续嵌套。这种树形结构在软件开发中太常见了。
我个人习惯把组合模式理解为「让客户端用统一的方式对待单个对象和组合对象」。嗯,这里要注意,这个模式的核心不在于「组合」本身,而在于「一致性」。
为什么需要组合模式?
我在项目中遇到过这样一个场景:一个图形编辑器,里面有圆形、矩形这样的基本图形,也有由多个图形组合成的复杂图形。用户希望拖拽、缩放、旋转操作对单个图形和组合图形都一样。如果不用组合模式,代码里到处都是 if (obj instanceof Group) { ... } else { ... } 这样的判断,维护起来非常痛苦。
组合模式解决了这个问题。它让叶子节点(单个对象)和容器节点(组合对象)实现同一个接口,客户端调用时根本不需要关心操作的是叶子还是容器。
两种实现风格:透明组合 vs 安全组合
这里有个经典的分歧点——透明组合和安全组合。我刚开始学的时候也纠结过,到底用哪个?
| 对比维度 | 透明组合 | 安全组合 |
|---|---|---|
| 接口定义 | 叶子节点和容器节点共用完整接口 | 叶子节点只暴露叶子操作,容器节点暴露容器操作 |
| 叶子节点 | 有 add/remove 等方法,但抛出异常或空实现 | 没有 add/remove 等方法 |
| 客户端使用 | 完全透明,无需类型判断 | 需要 instanceof 判断才能调用容器方法 |
| 安全性 | 运行时可能调用非法方法 | 编译期就能避免非法调用 |
| 我的推荐 | 适合叶子节点行为差异小的场景 | 适合对安全性要求高的场景 |
核心原则:透明组合追求「接口统一」,安全组合追求「类型安全」。没有绝对的好坏,取决于你的业务场景。
文件系统树形结构实现
我们拿文件系统来实战。这个例子太经典了,我每次讲组合模式都会用它。先看整体结构:
我选择安全组合来实现文件系统。为什么?因为文件系统中,文件(叶子)和文件夹(容器)的行为差异很大。文件不能添加子节点,文件夹可以。如果强行让文件实现 add() 方法然后抛异常,反而容易误导调用者。
我的经验:曾经在一个项目中用了透明组合,结果新同事调用了文件的 add 方法,虽然抛了异常但日志里全是错误堆栈。后来改成安全组合,编译期就发现问题,省了不少排查时间。
Java 实现代码
// 抽象组件
public abstract class FileSystemNode {
protected String name;
public FileSystemNode(String name) {
this.name = name;
}
public abstract long getSize();
public abstract void display(String indent);
public String getName() {
return name;
}
}
// 叶子节点 - 文件
public class File extends FileSystemNode {
private long size;
public File(String name, long size) {
super(name);
this.size = size;
}
@Override
public long getSize() {
return size;
}
@Override
public void display(String indent) {
System.out.println(indent + "📄 " + name + " (" + size + " bytes)");
}
}
// 容器节点 - 目录
public class Directory extends FileSystemNode {
private List<FileSystemNode> children = new ArrayList<>();
public Directory(String name) {
super(name);
}
public void add(FileSystemNode node) {
children.add(node);
}
public void remove(FileSystemNode node) {
children.remove(node);
}
public FileSystemNode getChild(int index) {
return children.get(index);
}
@Override
public long getSize() {
long totalSize = 0;
for (FileSystemNode child : children) {
totalSize += child.getSize();
}
return totalSize;
}
@Override
public void display(String indent) {
System.out.println(indent + "📁 " + name + "/");
for (FileSystemNode child : children) {
child.display(indent + " ");
}
}
}
// 客户端使用
public class Client {
public static void main(String[] args) {
Directory root = new Directory("root");
File readme = new File("readme.txt", 1024);
root.add(readme);
Directory src = new Directory("src");
File main = new File("main.java", 2048);
File utils = new File("utils.java", 1536);
src.add(main);
src.add(utils);
root.add(src);
root.display("");
System.out.println("总大小: " + root.getSize() + " bytes");
}
}
C++ 实现代码
#include <iostream>
#include <vector>
#include <memory>
#include <string>
// 抽象组件
class FileSystemNode {
protected:
std::string name;
public:
FileSystemNode(const std::string& name) : name(name) {}
virtual ~FileSystemNode() = default;
virtual long long getSize() const = 0;
virtual void display(const std::string& indent) const = 0;
std::string getName() const { return name; }
};
// 叶子节点 - 文件
class File : public FileSystemNode {
private:
long long size;
public:
File(const std::string& name, long long size)
: FileSystemNode(name), size(size) {}
long long getSize() const override {
return size;
}
void display(const std::string& indent) const override {
std::cout << indent << "📄 " << name
<< " (" << size << " bytes)" << std::endl;
}
};
// 容器节点 - 目录
class Directory : public FileSystemNode {
private:
std::vector<std::shared_ptr<FileSystemNode>> children;
public:
Directory(const std::string& name) : FileSystemNode(name) {}
void add(std::shared_ptr<FileSystemNode> node) {
children.push_back(node);
}
void remove(std::shared_ptr<FileSystemNode> node) {
auto it = std::find(children.begin(), children.end(), node);
if (it != children.end()) {
children.erase(it);
}
}
long long getSize() const override {
long long totalSize = 0;
for (const auto& child : children) {
totalSize += child->getSize();
}
return totalSize;
}
void display(const std::string& indent) const override {
std::cout << indent << "📁 " << name << "/" << std::endl;
for (const auto& child : children) {
child->display(indent + " ");
}
}
};
// 客户端使用
int main() {
auto root = std::make_shared<Directory>("root");
auto readme = std::make_shared<File>("readme.txt", 1024);
root->add(readme);
auto src = std::make_shared<Directory>("src");
auto main = std::make_shared<File>("main.cpp", 2048);
auto utils = std::make_shared<File>("utils.cpp", 1536);
src->add(main);
src->add(utils);
root->add(src);
root->display("");
std::cout << "总大小: " << root->getSize() << " bytes" << std::endl;
return 0;
}
避坑指南
我曾经在一个电商项目中用组合模式处理商品分类。分类下面有商品(叶子),也有子分类(容器)。一开始我用了透明组合,结果商品对象暴露了 addCategory() 方法,测试同学不小心调用了,虽然没报错但数据乱了。后来改成安全组合,问题迎刃而解。
注意:组合模式不是万能的。如果你的树形结构层级很深(比如超过10层),递归遍历可能导致栈溢出。这时候可以考虑用迭代器模式或者显式栈来替代递归。
什么时候用组合模式?
- 树形结构:文件系统、组织架构、菜单系统、UI组件树
- 统一操作:希望客户端用相同方式处理单个对象和组合对象
- 递归组合:组合对象内部可以包含其他组合对象,形成递归结构
嗯,组合模式其实不难,关键是要想清楚你的场景适合透明还是安全。我个人更倾向安全组合,虽然客户端代码多了一点点类型判断,但换来的是编译期的安全保障。你想想看,与其在运行时排查「为什么文件不能添加子节点」,不如在写代码时就避免这种可能性。
最后说一句,组合模式经常和迭代器模式、访问者模式搭配使用。迭代器帮你遍历树,访问者帮你对树中节点执行不同操作。这些我们后面会讲到。
公众号:蓝海资料掘金营,微信deep3321