组合模式:透明式与安全式、文件系统设计、HashMap中的组合

组合模式,说白了就是处理「部分-整体」关系的一种设计模式。它让客户端可以用一致的方式对待单个对象和组合对象。嗯,这个定义听起来有点绕,我换个说法——你想想看,在文件系统里,一个文件夹可以包含文件,也可以包含其他文件夹。对用户来说,双击一个文件和双击一个文件夹,操作方式是一样的。这就是组合模式要解决的问题。

为什么需要组合模式?

我在做企业网盘项目时遇到过这样一个场景:我们需要实现一个权限管理系统,用户、部门、角色之间存在着复杂的包含关系。一个部门包含多个用户,一个角色可以包含多个部门,部门下面还有子部门。如果不用组合模式,代码会写成什么样?

// 不用组合模式,判断逻辑散落在各处
if (obj instanceof User) {
    // 处理用户
} else if (obj instanceof Department) {
    // 处理部门
} else if (obj instanceof Role) {
    // 处理角色
}

这种代码,每增加一种类型就要改一堆地方。我当时就重构了,用组合模式统一了处理逻辑。

组合模式的核心结构

组合模式有三个核心角色:

  • Component(抽象构件):定义叶子节点和容器节点的公共接口
  • Leaf(叶子节点):没有子节点的对象
  • Composite(容器节点):包含子节点的对象

关键点:叶子节点和容器节点实现相同的接口,客户端不需要区分它们。

我画了一张图,帮你理清它们的关系:

Component 抽象构件 + operation() + add(Component) + remove(Component) Leaf 叶子节点 Composite 容器节点 + children: List<Component> + operation() { for child in children}

透明式 vs 安全式

组合模式有两种实现风格,我分别说说它们的区别。

透明式组合模式

透明式把管理子节点的方法(add、remove、getChild)都定义在抽象构件中。叶子节点和容器节点都有这些方法。叶子节点虽然不支持添加子节点,但方法签名还在,调用时会抛出异常。

// 透明式:所有方法都在抽象类中定义
abstract class Component {
    protected String name;
    
    public Component(String name) {
        this.name = name;
    }
    
    // 叶子节点和容器节点都有这些方法
    public abstract void operation();
    public void add(Component c) {
        throw new UnsupportedOperationException();
    }
    public void remove(Component c) {
        throw new UnsupportedOperationException();
    }
    public Component getChild(int i) {
        throw new UnsupportedOperationException();
    }
}

class File extends Component {
    public File(String name) {
        super(name);
    }
    
    @Override
    public void operation() {
        System.out.println("文件:" + name);
    }
}

class Folder extends Component {
    private List<Component> children = new ArrayList<>();
    
    public Folder(String name) {
        super(name);
    }
    
    @Override
    public void operation() {
        System.out.println("文件夹:" + name);
        for (Component child : children) {
            child.operation();
        }
    }
    
    @Override
    public void add(Component c) {
        children.add(c);
    }
    
    @Override
    public void remove(Component c) {
        children.remove(c);
    }
    
    @Override
    public Component getChild(int i) {
        return children.get(i);
    }
}

透明式的优点是客户端完全不用区分叶子节点和容器节点,代码写起来很统一。缺点是叶子节点有一些无意义的方法,违反了接口隔离原则。

安全式组合模式

安全式把管理子节点的方法只放在容器节点中。抽象构件只定义公共的业务方法。客户端需要自己判断对象类型。

// 安全式:抽象构件只定义业务方法
abstract class Component {
    protected String name;
    
    public Component(String name) {
        this.name = name;
    }
    
    public abstract void operation();
}

class File extends Component {
    public File(String name) {
        super(name);
    }
    
    @Override
    public void operation() {
        System.out.println("文件:" + name);
    }
}

class Folder extends Component {
    private List<Component> children = new ArrayList<>();
    
    public Folder(String name) {
        super(name);
    }
    
    @Override
    public void operation() {
        System.out.println("文件夹:" + name);
        for (Component child : children) {
            child.operation();
        }
    }
    
    // 管理方法只在容器节点中
    public void add(Component c) {
        children.add(c);
    }
    
    public void remove(Component c) {
        children.remove(c);
    }
    
    public Component getChild(int i) {
        return children.get(i);
    }
}

安全式的问题是客户端需要做类型判断。我曾经在一个项目中用了安全式,结果到处都是 if (component instanceof Folder) 这样的代码,维护起来很痛苦。

我个人习惯用透明式。虽然叶子节点有一些空方法,但换来的是客户端代码的简洁和统一。你想想看,如果每个调用处都要判断类型,那组合模式的意义就大打折扣了。

实战:文件系统设计

我们用透明式组合模式来实现一个简单的文件系统。这个例子我当年在面试时用过,面试官挺认可的。

// 客户端代码
public class FileSystemClient {
    public static void main(String[] args) {
        // 创建文件系统
        Folder root = new Folder("根目录");
        Folder docs = new Folder("文档");
        Folder pics = new Folder("图片");
        
        File readme = new File("README.md");
        File design = new File("设计文档.doc");
        File photo = new File("照片.jpg");
        
        // 组装树形结构
        root.add(docs);
        root.add(pics);
        docs.add(readme);
        docs.add(design);
        pics.add(photo);
        
        // 统一操作
        root.operation();
        
        // 输出:
        // 文件夹:根目录
        // 文件夹:文档
        // 文件:README.md
        // 文件:设计文档.doc
        // 文件夹:图片
        // 文件:照片.jpg
    }
}

这个例子展示了组合模式最核心的价值:对根目录调用 operation(),它会递归遍历所有子节点。不管是文件还是文件夹,调用方式完全一样。

HashMap中的组合模式

说到组合模式在JDK中的应用,HashMap是一个很好的例子。你可能会问:HashMap跟组合模式有什么关系?

HashMap的 putAll 方法就体现了组合模式的思想。一个HashMap可以包含另一个HashMap,对整体Map的操作会递归到子Map上。

// HashMap中的组合模式体现
Map<String, Object> config = new HashMap<>();
Map<String, Object> dbConfig = new HashMap<>();
dbConfig.put("url", "jdbc:mysql://localhost");
dbConfig.put("username", "root");

Map<String, Object> cacheConfig = new HashMap<>();
cacheConfig.put("type", "redis");
cacheConfig.put("host", "127.0.0.1");

config.put("database", dbConfig);
config.put("cache", cacheConfig);

// 统一遍历
printConfig(config, "");

void printConfig(Map<String, Object> map, String prefix) {
    for (Map.Entry<String, Object> entry : map.entrySet()) {
        if (entry.getValue() instanceof Map) {
            System.out.println(prefix + entry.getKey() + ":");
            printConfig((Map<String, Object>) entry.getValue(), prefix + "  ");
        } else {
            System.out.println(prefix + entry.getKey() + " = " + entry.getValue());
        }
    }
}

嗯,这里要注意,HashMap本身并没有显式实现组合模式。但它的设计思路和组合模式是一致的——一个Map可以包含其他Map,对整体Map的操作会递归处理子Map。这种思想在很多框架中都能看到,比如Spring的配置管理。

避坑指南

我曾经在一个项目中过度使用组合模式,把业务逻辑也塞进了树形结构里。结果代码变得很难调试,因为递归调用太深了。后来我总结了几条经验:

  • 不要滥用递归:树深度超过100层时,考虑用栈代替递归
  • 注意循环引用:子节点不能包含父节点,否则会死循环
  • 区分共享和独立:叶子节点可以被多个容器共享吗?这会影响你的设计
  • 性能监控:递归操作要考虑性能,必要时加缓存

组合模式最适合处理树形结构的数据。如果你遇到的是网状结构,那就不太合适了。

组合模式说白了就是「树形结构的统一处理」。它让客户端代码变得简单,但代价是抽象层要承担更多责任。用不用、怎么用,取决于你的具体场景。我个人建议:如果你的业务天然就是树形结构(文件系统、组织架构、菜单树),组合模式是首选。如果只是偶尔用到树形结构,那用简单的递归遍历就够了。


公众号:蓝海资料掘金营,微信deep3321