组合模式:把对象装进树里,让调用方一视同仁
组合模式,说白了就是解决「部分-整体」的树形结构问题。
我刚开始接触这个模式时,觉得它有点绕。后来在项目中写菜单系统,才真正体会到它的妙处。你想想看,一个菜单项可以是一个叶子节点(比如「保存文件」),也可以是一个容器节点(比如「文件」菜单,下面还有「新建」「打开」「保存」)。
如果不用组合模式,客户端代码就得写一堆 if-else 来判断当前节点是叶子还是容器。用组合模式,客户端根本不用关心这个——叶子节点和容器节点,调用方式完全一样。
核心思想:透明与安全
组合模式的核心就两句话:
- 透明性:叶子节点和容器节点实现相同的接口
- 递归性:容器节点内部可以包含叶子节点,也可以包含其他容器节点
嗯,这里要注意。透明性带来的是调用方的便利,但代价是叶子节点可能要实现一些「不该有」的方法(比如 add、remove)。我见过不少团队在这个问题上纠结——到底要不要让叶子节点抛出 UnsupportedOperationException?
我个人习惯是:如果项目规模不大,直接让叶子节点空实现 add/remove 方法,调用方用起来最舒服。如果项目对安全性要求高,那就用「安全组合模式」,把 add/remove 放到容器节点独有的接口里。
一个真实的例子:文件系统
文件系统是组合模式的经典案例。每个文件是叶子节点,每个文件夹是容器节点。你打开一个文件夹,里面可能有文件,也可能有子文件夹。
我在项目中做过一个类似的配置管理工具。配置项可以是单个键值对(叶子),也可以是一个配置组(容器),组里还能嵌套组。用组合模式后,遍历所有配置、导出配置、搜索配置,代码量直接砍了一半。
// 组件接口
public interface ConfigNode {
String getName();
String getValue(); // 叶子节点返回实际值,容器节点返回 null 或聚合值
void add(ConfigNode node);
void remove(ConfigNode node);
List<ConfigNode> getChildren();
}
// 叶子节点:单个配置项
public class ConfigItem implements ConfigNode {
private String name;
private String value;
public ConfigItem(String name, String value) {
this.name = name;
this.value = value;
}
@Override
public String getName() { return name; }
@Override
public String getValue() { return value; }
@Override
public void add(ConfigNode node) {
throw new UnsupportedOperationException("叶子节点不支持添加");
}
@Override
public void remove(ConfigNode node) {
throw new UnsupportedOperationException("叶子节点不支持移除");
}
@Override
public List<ConfigNode> getChildren() {
return Collections.emptyList();
}
}
// 容器节点:配置组
public class ConfigGroup implements ConfigNode {
private String name;
private List<ConfigNode> children = new ArrayList<>();
public ConfigGroup(String name) {
this.name = name;
}
@Override
public String getName() { return name; }
@Override
public String getValue() {
// 容器节点返回所有子节点的值拼接
return children.stream()
.map(ConfigNode::getValue)
.filter(Objects::nonNull)
.collect(Collectors.joining(", "));
}
@Override
public void add(ConfigNode node) {
children.add(node);
}
@Override
public void remove(ConfigNode node) {
children.remove(node);
}
@Override
public List<ConfigNode> getChildren() {
return children;
}
}
客户端怎么用?
客户端代码根本不需要知道自己在操作叶子还是容器。遍历整个配置树,直接递归调用 getValue 就行。
public class Client {
public static void main(String[] args) {
ConfigGroup root = new ConfigGroup("root");
ConfigGroup dbGroup = new ConfigGroup("database");
dbGroup.add(new ConfigItem("url", "jdbc:mysql://localhost:3306/mydb"));
dbGroup.add(new ConfigItem("username", "root"));
dbGroup.add(new ConfigItem("password", "123456"));
ConfigGroup cacheGroup = new ConfigGroup("cache");
cacheGroup.add(new ConfigItem("type", "redis"));
cacheGroup.add(new ConfigItem("host", "127.0.0.1"));
cacheGroup.add(new ConfigItem("port", "6379"));
root.add(dbGroup);
root.add(cacheGroup);
// 递归打印所有配置
printConfig(root, 0);
}
static void printConfig(ConfigNode node, int depth) {
String indent = " ".repeat(depth);
System.out.println(indent + node.getName() + " : " + node.getValue());
for (ConfigNode child : node.getChildren()) {
printConfig(child, depth + 1);
}
}
}
你看,printConfig 方法里,不管是叶子节点还是容器节点,调用方式完全一样。这就是组合模式最大的价值——让客户端代码变得极其简洁。
避坑指南
我曾经踩过一个坑:在容器节点的 getValue 方法里,我直接递归调用了所有子节点的 getValue。结果有一个子节点是容器,它的 getValue 又递归调用它的子节点……最后形成了一个死循环。
原因很简单:配置组 A 里包含了配置组 B,配置组 B 里又引用了配置组 A。这在业务上是不允许的,但代码层面没有做环检测。
解决方案:在 add 方法里加一个环检测,或者用 visited 集合做遍历保护。
我的建议:组合模式最适合「树形结构 + 统一操作」的场景。如果你的树只有两层(根节点下面全是叶子),那用组合模式有点大材小用。但如果层级不确定,或者未来可能扩展层级,组合模式就是最佳选择。
组合模式的变体
| 变体 | 特点 | 适用场景 |
|---|---|---|
| 透明组合模式 | 叶子节点和容器节点实现完全相同的接口 | 客户端需要完全透明,不关心节点类型 |
| 安全组合模式 | 叶子节点不实现 add/remove,容器节点单独提供 | 对安全性要求高,避免调用方误操作 |
| 带缓存的组合模式 | 容器节点缓存子节点的聚合结果 | 频繁读取聚合值,且子节点变化不频繁 |
我个人更倾向于透明组合模式。虽然叶子节点要抛出 UnsupportedOperationException,但调用方写起来最舒服。你想想看,如果调用方每次都要用 instanceof 判断节点类型,那组合模式的意义就少了一半。
组合模式与设计原则
组合模式完美体现了开闭原则:你可以新增一种节点类型(比如「符号链接」),只要它实现了 ConfigNode 接口,就能无缝接入现有的树结构。客户端代码不需要做任何修改。
它也体现了接口隔离原则:ConfigNode 接口只暴露了「树节点」该有的操作,没有多余的方法。虽然叶子节点被迫实现了 add/remove,但那是为了透明性付出的代价。
SVG 结构图:组合模式的核心逻辑
什么时候用组合模式?
- 树形结构:你的数据天然就是树状的,比如组织架构、文件系统、菜单系统
- 统一操作:客户端希望对叶子节点和容器节点执行相同的操作,比如遍历、搜索、导出
- 递归处理:你需要在容器节点内部递归处理子节点,而且子节点可能是叶子也可能是容器
反过来,如果你的数据只有两层(根和叶子),或者客户端需要频繁区分叶子节点和容器节点做不同处理,那组合模式就不太合适。强行用反而会让代码更复杂。
嗯,组合模式就讲到这里。记住一句话:把对象装进树里,让调用方一视同仁。下次你写菜单、配置、组织架构这类东西时,不妨试试组合模式。
公众号:蓝海资料掘金营,微信deep3321