设计模式-组合模式

文章目录

  • 一、概述
    • [1.1 结构与角色](#1.1 结构与角色)
    • [1.2 适用场景](#1.2 适用场景)
  • 二、实现方式
    • [2.1 透明组合](#2.1 透明组合)
    • [2.2 安全组合](#2.2 安全组合)
    • [2.3 透明组合 vs 安全组合](#2.3 透明组合 vs 安全组合)
  • [三、JDK 源码中的组合模式](#三、JDK 源码中的组合模式)
    • [3.1 AWT/Swing 组件体系](#3.1 AWT/Swing 组件体系)
    • [3.2 Java NIO 文件系统](#3.2 Java NIO 文件系统)
    • [3.3 MyBatis SqlNode 体系](#3.3 MyBatis SqlNode 体系)
  • 四、总结

一、概述

在软件开发中,经常会遇到这样的场景:需要处理一种树形结构 的数据,其中包含"容器"节点和"叶子"节点两种类型。例如,文件系统中的文件夹(容器)和文件(叶子)、组织架构中的部门(容器)和员工(叶子)、GUI 中的容器组件(容器)和基本组件(叶子)。如果将容器和叶子区别对待,客户端就需要使用 if-elseinstanceof 来判断节点类型,代码既复杂又难扩展。

组合模式(Composite Pattern)正是为了解决这个问题而诞生的------它将对象组合成树形结构 以表示"部分-整体"的层次关系,使得客户端对单个对象和组合对象的使用具有一致性。组合模式让客户端可以统一地对待单个对象和组合对象,而不需要关心它们的具体类型。

生活中的组合模式例子比比皆是:

  • 文件系统:文件夹可以包含子文件夹和文件,文件是最小单位。用户对文件夹和文件执行"复制""删除"操作时,不需要区分两者
  • 组织架构:公司由部门组成,部门又可以包含子部门和员工。计算部门人数时,叶子节点(员工)和组合节点(子部门)的统计方式可以统一处理
  • 菜单系统:菜单可以包含子菜单和菜单项,用户点击菜单时的交互方式是一致的

核心:将对象组合成树形结构以表示"部分-整体"的层次关系,使得客户端对单个对象和组合对象的使用具有一致性

1.1 结构与角色

组合模式包含以下角色:
实现
实现
持有
包含
包含
Client 客户端
Component 抽象构件
Leaf 叶子构件
Composite 组合构件
Composite 子组合

  • Component(抽象构件):为叶子构件和组合构件声明公共接口,可以提供默认行为
  • Leaf(叶子构件):表示叶子节点,没有子节点,定义组合中最基本的行为
  • Composite(组合构件):表示容器节点,可以包含子节点(叶子或其他组合),提供添加、删除子节点的功能,并委托子节点执行操作
  • Client(客户端):通过抽象构件接口统一地操作叶子构件和组合构件

1.2 适用场景

  • 需要表示对象的部分-整体层次结构(树形结构)
  • 希望客户端统一地对待单个对象和组合对象,而不需要区分它们的类型
  • 需要动态地组合对象,且组合的方式可以递归嵌套

二、实现方式

组合模式有两种实现方式:透明组合安全组合。两者的核心区别在于:管理子节点的方法(add、remove、getChild)放在哪里------是放在抽象构件中,还是只放在组合构件中。

2.1 透明组合

透明组合将管理子节点的方法(add、remove、getChild)定义在抽象构件中,这样叶子构件和组合构件对于客户端来说是"透明"的------客户端不需要区分两者,可以通过统一的接口操作所有节点。
add / remove / operation
add / remove / operation
持有
客户端
Component 抽象构件
Leaf 叶子构件
Composite 组合构件
叶子构件的add和remove方法抛出UnsupportedOperationException

(1)抽象构件------统一接口

java 复制代码
import java.util.List;

/**
 * 抽象构件:统一接口
 * 透明组合:管理子节点的方法也定义在抽象构件中
 */
public abstract class Component {

    /**
     * 添加子节点
     *
     * @param component 子构件
     */
    public void add(Component component) {
        throw new UnsupportedOperationException("不支持添加操作");
    }

    /**
     * 移除子节点
     *
     * @param component 子构件
     */
    public void remove(Component component) {
        throw new UnsupportedOperationException("不支持移除操作");
    }

    /**
     * 获取子节点
     *
     * @param index 索引
     * @return 子构件
     */
    public Component getChild(int index) {
        throw new UnsupportedOperationException("不支持获取子节点操作");
    }

    /**
     * 业务操作
     */
    public abstract void operation();
}

(2)叶子构件------文件

java 复制代码
/**
 * 叶子构件:文件
 * 没有子节点,add / remove / getChild 方法继承默认实现(抛出异常)
 */
public class File extends Component {

    private final String name;

    public File(String name) {
        this.name = name;
    }

    @Override
    public void operation() {
        System.out.println("文件:" + name);
    }
}

(3)组合构件------文件夹

java 复制代码
import java.util.ArrayList;
import java.util.List;

/**
 * 组合构件:文件夹
 * 可以包含子节点(文件或子文件夹),重写 add / remove / getChild 方法
 */
public class Folder extends Component {

    private final String name;
    private final List<Component> children = new ArrayList<>();

    public Folder(String name) {
        this.name = name;
    }

    @Override
    public void add(Component component) {
        children.add(component);
    }

    @Override
    public void remove(Component component) {
        children.remove(component);
    }

    @Override
    public Component getChild(int index) {
        return children.get(index);
    }

    @Override
    public void operation() {
        System.out.println("文件夹:" + name);
        for (Component child : children) {
            child.operation();
        }
    }
}

(4)客户端调用

java 复制代码
public class TransparentCompositeDemo {
    public static void main(String[] args) {
        // 构建文件系统树
        Component root = new Folder("根目录");

        Component srcFolder = new Folder("src");
        srcFolder.add(new File("Main.java"));
        srcFolder.add(new File("Utils.java"));

        Component resFolder = new Folder("resources");
        resFolder.add(new File("config.properties"));

        root.add(srcFolder);
        root.add(resFolder);
        root.add(new File("pom.xml"));

        // 统一调用------不需要区分 File 和 Folder
        root.operation();
        // 文件夹:根目录
        // 文件夹:src
        // 文件:Main.java
        // 文件:Utils.java
        // 文件夹:resources
        // 文件:config.properties
        // 文件:pom.xml

        // 透明性体现------客户端可以用 Component 类型操作所有节点
        Component file = new File("test.txt");
        file.add(new File("不可能")); // UnsupportedOperationException
    }
}

透明组合的特点 :客户端不需要区分叶子构件和组合构件,统一通过 Component 接口操作。但叶子构件继承了它不需要的方法(add、remove、getChild),调用时会抛出异常,不够安全。

2.2 安全组合

安全组合将管理子节点的方法(add、remove、getChild)只定义在组合构件中,抽象构件只声明公共的业务方法。这样叶子构件不会拥有不需要的方法,更加安全。但客户端需要区分叶子构件和组合构件,丧失了一定的透明性。
只有operation
只有operation
add / remove / getChild
客户端
Component 抽象构件
Leaf 叶子构件
Composite 组合构件
Component 子构件
管理子节点的方法只在Composite中定义

(1)抽象构件------只声明业务方法

java 复制代码
/**
 * 抽象构件:只声明业务方法
 * 安全组合:管理子节点的方法不放在抽象构件中
 */
public abstract class Component {

    /**
     * 业务操作
     */
    public abstract void operation();
}

(2)叶子构件------文件

java 复制代码
/**
 * 叶子构件:文件
 * 只需要实现业务方法,不包含管理子节点的方法
 */
public class File extends Component {

    private final String name;

    public File(String name) {
        this.name = name;
    }

    @Override
    public void operation() {
        System.out.println("文件:" + name);
    }
}

(3)组合构件------文件夹

java 复制代码
import java.util.ArrayList;
import java.util.List;

/**
 * 组合构件:文件夹
 * 自己定义管理子节点的方法,不继承自抽象构件
 */
public class Folder extends Component {

    private final String name;
    private final List<Component> children = new ArrayList<>();

    public Folder(String name) {
        this.name = name;
    }

    /**
     * 添加子节点------只在组合构件中定义
     *
     * @param component 子构件
     */
    public void add(Component component) {
        children.add(component);
    }

    /**
     * 移除子节点------只在组合构件中定义
     *
     * @param component 子构件
     */
    public void remove(Component component) {
        children.remove(component);
    }

    /**
     * 获取子节点------只在组合构件中定义
     *
     * @param index 索引
     * @return 子构件
     */
    public Component getChild(int index) {
        return children.get(index);
    }

    @Override
    public void operation() {
        System.out.println("文件夹:" + name);
        for (Component child : children) {
            child.operation();
        }
    }
}

(4)客户端调用

java 复制代码
public class SafeCompositeDemo {
    public static void main(String[] args) {
        // 构建文件系统树------需要使用 Folder 类型来调用 add 方法
        Folder root = new Folder("根目录");

        Folder srcFolder = new Folder("src");
        srcFolder.add(new File("Main.java"));
        srcFolder.add(new File("Utils.java"));

        Folder resFolder = new Folder("resources");
        resFolder.add(new File("config.properties"));

        root.add(srcFolder);
        root.add(resFolder);
        root.add(new File("pom.xml"));

        // 统一调用------operation 方法仍然可以通过 Component 类型调用
        Component component = root;
        component.operation();
        // 文件夹:根目录
        // 文件夹:src
        // 文件:Main.java
        // 文件:Utils.java
        // 文件夹:resources
        // 文件:config.properties
        // 文件:pom.xml

        // 安全性体现------File 没有 add 方法,不会误调用
        // File file = new File("test.txt");
        // file.add(new File("不可能")); // 编译错误,File 没有 add 方法
    }
}

安全组合的特点 :叶子构件不会拥有不需要的方法,编译期就能发现问题,更加安全。但客户端在构建树形结构时需要区分叶子构件和组合构件(需要使用 Folder 类型来调用 add 等方法),丧失了一定的透明性。

2.3 透明组合 vs 安全组合

对比维度 透明组合 安全组合
子节点管理方法位置 抽象构件中(所有节点都有) 只在组合构件中
叶子构件安全性 低(调用 add 等方法会抛异常) 高(编译期就能发现问题)
客户端透明性 高(统一通过 Component 操作) 低(构建时需要区分类型)
符合设计原则 不符合接口隔离原则(叶子被迫实现不需要的方法) 符合接口隔离原则
典型应用 AWT/Swing 组件体系 文件系统、组织架构

选型建议:

  • 如果希望客户端完全透明 地操作所有节点,不需要区分叶子和组合------选择透明组合
  • 如果希望类型安全 ,在编译期就能发现问题------选择安全组合
  • 实际开发中,安全组合更为常用,因为编译期的错误比运行时的异常更容易定位

三、JDK 源码中的组合模式

组合模式在 JDK 中有着广泛的应用,最典型的就是 AWT/Swing 的组件体系和 Java NIO 的文件系统。

3.1 AWT/Swing 组件体系

java.awt.Componentjava.awt.Container 是组合模式在 JDK 中最经典的应用。Component 是抽象构件,Container 是组合构件,ButtonLabel 等是叶子构件。
持有
Component 抽象构件
Container 组合构件
Panel 面板
Window 窗口
Button 叶子构件
Label 叶子构件
TextField 叶子构件

java 复制代码
/**
 * Component 是抽象构件
 * 它声明了所有组件的公共方法
 */
public abstract class Component implements ImageObserver, MenuContainer, Serializable {

    public abstract void paint(Graphics g);

    public void repaint() {
        // ...
    }

    public void setVisible(boolean b) {
        // ...
    }
}

/**
 * Container 是组合构件
 * 它继承 Component,并增加了管理子组件的方法
 */
public class Container extends Component {

    /**
     * 存放子组件的列表
     */
    java.util.List<Component> component = new java.util.ArrayList<>();

    /**
     * 添加子组件
     */
    public Component add(Component comp) {
        // ...
        component.add(comp);
        return comp;
    }

    /**
     * 移除子组件
     */
    public void remove(int index) {
        // ...
        component.remove(index);
    }

    /**
     * 获取子组件
     */
    public Component getComponent(int n) {
        return component.get(n);
    }

    /**
     * 获取所有子组件
     */
    public Component[] getComponents() {
        return component.toArray(new Component[0]);
    }
}

在这个例子中:

  • Component(抽象构件)java.awt.Component
  • Composite(组合构件)java.awt.Container(继承 Component,拥有 add/remove/getComponent 等方法)
  • Leaf(叶子构件)ButtonLabelTextField 等(继承 Component,没有子组件)

使用方式:

java 复制代码
// 组合构件------窗口
Frame frame = new Frame("我的窗口");

// 组合构件------面板
Panel panel = new Panel();

// 叶子构件------按钮和标签
Button button = new Button("点击");
Label label = new Label("标签");

// 组合------面板添加叶子组件
panel.add(button);
panel.add(label);

// 组合------窗口添加面板
frame.add(panel);

// 统一操作------对所有组件执行 setVisible
frame.setVisible(true);

说明 :这是安全组合 的实现------addremovegetComponent 等方法只定义在 Container 中,ButtonLabel 等叶子构件没有这些方法。客户端在构建界面时需要区分容器和组件,但类型更加安全。

3.2 Java NIO 文件系统

java.nio.file 包中的文件系统 API 也使用了组合模式。Path 接口表示文件路径,既可以表示文件(叶子),也可以表示目录(组合)。

java 复制代码
/**
 * Path 接口------抽象构件
 * 既可以表示文件,也可以表示目录
 */
public interface Path extends Comparable<Path>, Iterable<Path>, Watchable {

    /**
     * 获取文件名
     */
    Path getFileName();

    /**
     * 获取父路径
     */
    Path getParent();

    /**
     * 解析子路径(类似添加子节点)
     */
    Path resolve(Path other);

    /**
     * 解析子路径(字符串形式)
     */
    Path resolve(String other);
}

使用方式:

java 复制代码
import java.nio.file.Path;
import java.nio.file.Paths;

// 创建路径------组合模式让文件和目录的路径操作一致
Path dir = Paths.get("/home/user/project");
Path file = dir.resolve("src/Main.java");

System.out.println(dir.getFileName());   // project
System.out.println(file.getFileName());  // Main.java
System.out.println(file.getParent());    // /home/user/project/src

说明Path 采用的是透明组合 的设计------文件和目录使用同一个 Path 接口,resolve 方法既可以用于目录(拼接子路径),也可以用于文件(虽然语义上不太合理,但不会报错)。

3.3 MyBatis SqlNode 体系

MyBatis 的动态 SQL 解析使用了组合模式。各种 SQL 标签(<if><where><choose><foreach> 等)被解析为不同的 SqlNode 实现,而 MixedSqlNode 作为组合构件可以包含多个子 SqlNode

java 复制代码
/**
 * 抽象构件:SqlNode
 */
public interface SqlNode {

    /**
     * 应用动态 SQL 逻辑
     */
    boolean apply(DynamicContext context);
}

/**
 * 组合构件:MixedSqlNode
 * 包含多个子 SqlNode,委托调用每个子节点
 */
public class MixedSqlNode implements SqlNode {

    private final List<SqlNode> contents;

    public MixedSqlNode(List<SqlNode> contents) {
        this.contents = contents;
    }

    @Override
    public boolean apply(DynamicContext context) {
        for (SqlNode sqlNode : contents) {
            sqlNode.apply(context);
        }
        return true;
    }
}

/**
 * 叶子构件:StaticTextSqlNode(静态文本)
 */
public class StaticTextSqlNode implements SqlNode {

    private final String text;

    public StaticTextSqlNode(String text) {
        this.text = text;
    }

    @Override
    public boolean apply(DynamicContext context) {
        context.appendSql(text);
        return true;
    }
}

/**
 * 叶子构件:IfSqlNode(if 标签)
 */
public class IfSqlNode implements SqlNode {

    private final ExpressionEvaluator evaluator;
    private final String test;
    private final SqlNode contents;

    public IfSqlNode(SqlNode contents, String test) {
        this.test = test;
        this.contents = contents;
        this.evaluator = new ExpressionEvaluator();
    }

    @Override
    public boolean apply(DynamicContext context) {
        if (evaluator.evaluateBoolean(test, context.getBindings())) {
            contents.apply(context);
            return true;
        }
        return false;
    }
}

在这个例子中:

  • Component(抽象构件)SqlNode 接口
  • Composite(组合构件)MixedSqlNode(包含子 SqlNode 列表,递归调用)
  • Leaf(叶子构件)StaticTextSqlNodeIfSqlNode

说明 :MyBatis 的 SqlNode 体系采用透明组合 ------所有节点都实现 SqlNode 接口,客户端统一调用 apply 方法。MixedSqlNode 可以包含任意类型的 SqlNode(包括其他 MixedSqlNode),形成递归嵌套的树形结构。


四、总结

组合模式的核心思想是将对象组合成树形结构以表示"部分-整体"的层次关系,使得客户端对单个对象和组合对象的使用具有一致性。

优点:

  • 统一对待叶子和组合:客户端不需要区分单个对象和组合对象,简化了客户端代码
  • 易于扩展:新增叶子构件或组合构件非常容易,符合开闭原则
  • 树形结构天然支持:递归嵌套的组合结构非常适合表示树形层次关系
  • 简化客户端代码:客户端不需要使用 if-elseinstanceof 来判断节点类型

缺点:

  • 难以限制节点类型:在透明组合中,无法在编译期限制叶子构件不调用 add/remove 等方法
  • 增加新构件时需要修改现有代码:如果需要新增统一的行为,需要修改抽象构件
  • 透明组合不够安全:叶子构件继承了不需要的方法,运行时调用会抛出异常
  • 安全组合不够透明:客户端需要区分叶子和组合,丧失了一致性

适用场景:

  • 需要表示对象的部分-整体层次结构(树形结构)
  • 希望客户端统一地对待单个对象和组合对象
  • 需要动态地组合对象,且组合可以递归嵌套
  • 需要对树形结构中的所有节点执行统一的操作

透明 vs 安全:透明组合将子节点管理方法放在抽象构件中,客户端完全透明但叶子构件不够安全;安全组合将子节点管理方法只放在组合构件中,类型安全但客户端需要区分节点类型。实际开发中,安全组合更为常用。


参考博客:

组合模式 | 菜鸟教程:https://www.runoob.com/design-pattern/composite-pattern.html

相关推荐
多加点辣也没关系2 小时前
设计模式-外观模式
设计模式·外观模式
咖啡八杯2 小时前
GoF设计模式——抽象工厂模式
java·后端·spring·设计模式·抽象工厂模式
是个西兰花2 小时前
单列模式和C++中的类型转换
c++·单例模式·设计模式·rtti
多加点辣也没关系3 小时前
设计模式-享元模式
数据库·设计模式·享元模式
熠熠仔4 小时前
《Agentic Design Patterns》概览
学习·设计模式
geovindu5 小时前
python: Mutex Pattern
开发语言·python·设计模式·互斥锁模式
Carl_奕然5 小时前
【智能体】Agent的四种设计模式之:Plan-and-Execute
人工智能·python·设计模式
mit6.8246 小时前
[Panyim] C++ 比 C 更好吗
设计模式
nnsix6 小时前
设计模式 - 单例模式 笔记
笔记·单例模式·设计模式