设计模式预计阅读 20 分钟7735 字3281 次浏览

设计模式修炼之路 ⑨ 组合模式——叶子和容器一视同仁

部分-整体、叶子和容器一视同仁、在 DOM/MyBatis 里找它

目录

上一篇过滤器模式的结尾,And 嵌着 Or 拼出的那棵"条件树"已经提前剧透了一切:条件是树、文件系统是树、组织架构是树、前端组件还是树。今天正主登场:组合模式(Composite Pattern),GoF 23 人名单正牌选手、结构型第四位,有的书也叫它"部分-整体模式(Part-Whole)"。它专门伺候一种结构——树,而它给出的答案简单到让人怀疑:让叶子和整棵树,用同一个接口。

一、什么是组合模式?

先看定义:

将对象组合成树形结构以表示"部分-整体"的层次结构。组合模式使客户端对单个对象和组合对象的使用保持一致性。

翻译成人话:叶子和容器实现同一个接口,容器里装的还是这个接口——树就这么长出来了。客户端拿到一个节点,不用关心它是单片叶还是一整棵树,调就完了。

生活类比是文件资源管理器:想知道一个文件夹多大,右键属性,系统自己一层层加到底——你不需要(也不可能)区分"文件的算法"和"文件夹的算法",因为系统早就替你统一了。或者想想公司算人数:CEO 管总监、总监管经理、经理管员工,但"全公司多少人"从来不分"领导怎么算、员工怎么算",就是一路加到底。

它有三个角色:

  • Component(组件接口):叶子和容器的公共抽象,本篇里是 getName、getSizeKB、print;
  • Leaf(叶子):树的最末端,没有孩子,干活的,本篇里是 File;
  • Composite(容器):持有 List<Component>,本篇里是 Folder,它的方法基本都是"把孩子挨个问一遍再汇总"。

精髓在 Composite 的字段类型:容器持有的不是叶子,是组件自己这个类型。孩子可以是叶子,也可以是另一个容器,容器套容器,树形结构是这个类型的自然结果。上一篇里 AndCriteria 装着 Criteria、还能再被别的组合器装走,是同一个把戏——组合模式就是它的"有户口"版本。递归也不用谁特意发明:容器的方法是"把孩子的事问一遍",孩子若是容器,它会继续往下问,一路问到叶子为止。

二、组合模式的优缺点

优点

  1. 客户端一视同仁:拿到 Component 就调用,没有 instanceof、没有分支——定义里那句"单个对象和组合对象的使用保持一致性",整个模式就为这一句话活着。
  2. 新增节点类型零成本:哪天要加一种"快捷方式"节点?实现 FileComponent 就能进树,全树所有遍历、统计、打印代码一行不改——开闭原则。
  3. 树形操作天然递归:算大小、打印结构、查找、序列化……一套递归通吃,而且每种逻辑全树只写一份——对照反面教材的"文件一遍、文件夹一遍",下面看事故现场。

缺点

  1. 接口不好设计:Component 的方法必须对叶子和容器都有意义。getSize 好说,"打开文件内容"这种叶子独有的行为就尴尬了——硬塞进接口,容器只能抛异常;不塞,客户端又得下转型。接口粒度是这个模式里最见功力的地方。
  2. 时机敏感:它属于"动工前就该想清楚"的模式。项目半路想把两个既有类硬统一成树,牵动面往往大到不值得。
  3. 类型安全总有让步:透明式叶子被迫实现无意义方法(错误拖到运行时才炸),安全式客户端想管理孩子得拿到具体容器类型——两种姿势各有代价,第五节专门掰扯。

三、组合模式的应用场景

  • AWT / Swing(JDK):教科书级实现。Component 是组件抽象,Button、Label 是叶子,Container 继承 Component 并持有组件列表——Panel 里塞得进 Button,Button 里什么都塞不进,类型系统如实相告。
  • DOM(org.w3c.dom):XML/HTML 的文档对象模型。Node 是组件,Element 是容器(可 appendChild),Text、Comment 是叶子。前端世界的前二十年,就活在这棵树里。
  • MyBatis 动态 SQL:SqlNode 接口只有一个 apply() 方法;TextSqlNode 是叶子,MixedSqlNode、IfSqlNode、ForEachSqlNode 全是容器——ForEachSqlNode 里套着的还是 SqlNode,动态标签互相嵌套出任意结构,靠的就是这棵树。本系列已经第三次在 MyBatis 里撞见老朋友了。
  • Jackson 的 JsonNode:JSON 天生是树。ObjectNode、ArrayNode 是容器,TextNode、IntNode、NullNode 是叶子,readTree 之后一套递归走天下。
  • 前端组件树:React/Vue 里组件嵌组件、递归 render,本质就是运行时在组装组合模式——所以前端工程师其实天天在写"JSX 版组合模式"。
  • 后台系统里的各种树:菜单树、部门树、权限树、评论盖楼、商品类目——前端那个 Tree 组件渲染的每一份数据,后端十有八九在给组合模式供货。

什么时候该用:需求里出现"层级""树""容器套容器"这样的字眼,或者你发现自己在写"递归 + instanceof"——就该想起它。反过来,对象之间没有层级关系时,别为了套模式硬造一棵树。

四、组合模式的实现方式

示例代码在 design-pattern 项目的 composite 包里:

design-pattern/src/main/java
└── composite                 // 组合模式
    ├── BadFile.java          // 反面教材:文件类(与 BadFolder 无公共抽象)
    ├── BadFolder.java        // 反面教材:文件、子文件夹两个列表
    ├── FileComponent.java    // 组件接口:叶子和容器的公共抽象
    ├── File.java             // 叶子:文件
    ├── Folder.java           // 容器:文件夹
    └── CompositeTest.java    // 测试入口

场景:文件系统。文件有大小,文件夹由内容组成,要打印目录树、算总大小。先看文件、文件夹"两套体系"的下场,再看组合模式怎么救。

1. 反面教材:两套体系

最直觉的建模:文件一个类、文件夹一个类,各管各的。文件夹为了同时装下文件和子文件夹,开了两个列表:

// 反面教材:文件一个列表、子文件夹另一个列表,每个操作都得遍历两遍
public class BadFolder {
    private final String name;
    private final List<BadFile> files = new ArrayList<>();
    private final List<BadFolder> subFolders = new ArrayList<>();

    // 算总大小:先遍历文件再遍历子文件夹,递归还得自己写
    public long totalSizeKB() {
        long total = 0;
        for (BadFile file : files) {
            total += file.getSizeKB();
        }
        for (BadFolder folder : subFolders) {
            total += folder.totalSizeKB();
        }
        return total;
    }

    // 打印目录树:又是两遍遍历,结构和上面几乎重复
    public void print(String indent) {
        System.out.println(indent + name + "/");
        for (BadFile file : files) {
            System.out.println(indent + "  " + file.getName() + "(" + file.getSizeKB() + "KB)");
        }
        for (BadFolder folder : subFolders) {
            folder.print(indent + "  ");
        }
    }
}

(构造器、getter、addFile/addFolder 三个简单方法略,完整代码见项目。)

测试代码,末尾专门演示"想统一处理"时的下场:

BadFolder docs = new BadFolder("docs");
docs.addFile(new BadFile("简历.pdf", 512));
BadFolder projects = new BadFolder("projects");
projects.addFile(new BadFile("pom.xml", 8));
projects.addFile(new BadFile("App.java", 16));
docs.addFolder(projects);

docs.print("");
System.out.println("docs 总大小:" + docs.totalSizeKB() + "KB");

// 想把文件和文件夹放进同一个列表统一处理?没有公共父类型,只能 instanceof 硬判
List<Object> mixed = new ArrayList<>();
mixed.add(new BadFile("临时文件.txt", 1));
mixed.add(new BadFolder("新建文件夹"));
for (Object o : mixed) {
    if (o instanceof BadFile f) {
        System.out.println("这是个文件:" + f.getName());
    } else if (o instanceof BadFolder f) {
        System.out.println("这是个文件夹:" + f.getName());
    }
}

运行结果:

===== 反面教材:文件、文件夹两套体系 =====
docs/
  简历.pdf(512KB)
  projects/
    pom.xml(8KB)
    App.java(16KB)
docs 总大小:536KB
这是个文件:临时文件.txt
这是个文件夹:新建文件夹

能跑,但三个雷已经埋下:

  1. 每个操作都得"两遍遍历":算总大小,文件列表一遍、子文件夹一遍再递归;打印目录树,又是同样的两遍——两个方法结构几乎是复制粘贴。再要加个"统计文件个数"?还是两遍。
  2. 没法统一处理:BadFile 和 BadFolder 之间没有任何公共抽象,想把它们放进同一个列表统一处理,只能 List 加 instanceof 硬判——每来一种新成员,所有硬判处全部加分支。
  3. 加新成员全链路遭殃:哪天要支持"快捷方式":BadFolder 再加一个 List<Shortcut>,totalSizeKB、print 以及一切遍历过这个结构的方法,挨个改一遍。
  4. 病根只有一个:文件和文件夹被建成了两个没有血缘关系的类型。

    2. 组合模式版:一个接口,一棵树

    先立公共抽象,这是全模式的定海神针:

    // 组合模式的核心:叶子和容器的公共接口,客户端只认它
    public interface FileComponent {
        String getName();
    
        long getSizeKB();
    
        // indent 参数只是为了打印出树形缩进,不是模式的一部分
        void print(String indent);
    }
    

    叶子——文件,大小就是自己:

    // 叶子节点:文件,大小就是自己
    public class File implements FileComponent {
        private final String name;
        private final long sizeKB;
    
        public File(String name, long sizeKB) {
            this.name = name;
            this.sizeKB = sizeKB;
        }
    
        @Override
        public long getSizeKB() {
            return sizeKB;
        }
    
        @Override
        public void print(String indent) {
            System.out.println(indent + name + "(" + sizeKB + "KB)");
        }
    }
    

    (getName 就是返回 name,略。)

    重头戏是容器——文件夹。注意它的孩子列表是什么类型:

    // 容器节点:文件夹,把孩子的大小加起来就是自己——递归由此而来
    public class Folder implements FileComponent {
        private final String name;
        private final List<FileComponent> children = new ArrayList<>();
    
        // 安全式:管理孩子的方法只放在容器里,叶子压根没这方法;为什么放这里,第五节专门说
        public Folder add(FileComponent component) {
            children.add(component);
            return this;
        }
    
        @Override
        public long getSizeKB() {
            long total = 0;
            for (FileComponent child : children) {
                // 孩子可能是文件也可能是文件夹,一律递归
                total += child.getSizeKB();
            }
            return total;
        }
    
        @Override
        public void print(String indent) {
            System.out.println(indent + name + "/(共 " + getSizeKB() + "KB)");
            for (FileComponent child : children) {
                // 不用关心孩子是什么类型,转发就行
                child.print(indent + "  ");
            }
        }
    }
    

    List<FileComponent>——容器装的是组件自己这个类型,文件、文件夹、文件夹套文件夹,全都合法。建一棵三层目录树,然后玩本篇的核心戏法:把叶子和整棵树扔进同一个列表:

    private static FileComponent buildTree() {
        Folder projects = new Folder("projects");
        projects.add(new File("pom.xml", 8));
        Folder src = new Folder("src");
        src.add(new File("App.java", 16));
        src.add(new File("Config.java", 4));
        Folder test = new Folder("test");
        test.add(new File("AppTest.java", 2));
        src.add(test);
        projects.add(src);
        projects.add(new File("README.md", 2));
        return projects;
    }
    
    private static void uniform() {
        List<FileComponent> mixed = new ArrayList<>();
        mixed.add(new File("临时文件.txt", 1));
        mixed.add(buildTree());
        for (FileComponent component : mixed) {
            System.out.println(component.getName() + " → " + component.getSizeKB() + "KB");
        }
    }
    

    运行结果:

    ===== 正解:组合模式 =====
    projects/(共 32KB)
      pom.xml(8KB)
      src/(共 22KB)
        App.java(16KB)
        Config.java(4KB)
        test/(共 2KB)
          AppTest.java(2KB)
      README.md(2KB)
    整棵树总大小:32KB
    
    ===== 统一处理:叶子和容器一视同仁 =====
    临时文件.txt → 1KB
    projects → 32KB
    

    对照反面教材逐条验收:

    • 反面教材"两遍遍历"的 totalSizeKB 和 print,正解里只剩一份"遍历孩子"——孩子是文件还是文件夹,一个 for 通吃,两种代码全树各只有一份;
    • 文件的 getSizeKB 是"自己",文件夹的是"孩子的和"——同一个方法名,"部分"和"整体"各自给出自己的答案,这就是定义里"部分-整体"的字面意思;
    • List<FileComponent> 里文件和文件夹混装,一个循环统一处理,instanceof 退场,add 的时候也没有任何转型;
    • root.print("") 一行,整棵树递归打印出来,缩进都是各节点自己传下去的;
    • 哪天加"快捷方式":写一个 Shortcut 实现 FileComponent 就能进树,BadFolder 那种"新列表 + 全链路改"的事故不再发生——开闭原则。

    五、透明式 vs 安全式:GoF 留下的经典取舍

    有个细节不知道你注意到没有:add/remove 为什么放在 Folder 里,而不上提到 FileComponent 接口?这不是随手一放,是 GoF 原书里就摆上台面的抉择。

    透明式(GoF 原版):管理孩子的方法直接进接口,叶子和容器都实现,客户端彻底不用区分手里是什么:

    // 透明式:管理孩子的方法上提到接口,叶子被迫"假装"能生孩子
    public interface FileComponent {
        void add(FileComponent component);
        void remove(FileComponent component);
        long getSizeKB();
    }
    
    public class File implements FileComponent {
        @Override
        public void add(FileComponent component) {
            throw new UnsupportedOperationException("文件没有孩子");
        }
        // remove 同理抛异常,其余方法照旧
    }
    

    安全式(本篇示例):管理孩子的方法只放在 Composite 身上,叶子压根没有这方法。

    透明式(GoF 原版) 安全式(本篇示例)
    add/remove 放哪 Component 接口 只在 Composite
    叶子怎么交差 抛 UnsupportedOperationException 压根没这方法
    客户端加孩子 拿到组件就能加,零转型 得拿到具体容器类型
    代价 违背单一职责与里氏替换,错误拖到运行时才炸 "完全透明"打了折扣

    JDK 两种都选过:AWT 走安全式(add 在 Container 上,Button 上没有这个方法),DOM 走透明式(Node 接口就有 appendChild,往 Text 节点里塞孩子会抛 DOMException)。两家都活得好好的,说明这不是对错题,是口味题。

    给个倾向性建议:业务代码默认安全式——让类型系统如实反映"叶子生不了孩子",编译期诚实好过运行期惊吓;而且只要客户端的操作以"读"为主(遍历、统计、渲染),两种方式的体验完全一致,本篇示例的 uniform() 就证明了这点——一个循环处理混合列表,谁也没下转型,也没谁抛异常。

    写在最后

    回顾这一篇:组合模式把"部分-整体"组织成树——叶子与容器实现同一接口,容器持有组件列表,树形结构是类型的自然结果,递归不需要发明;反面教材"两套体系"两遍遍历、instanceof 硬判、加成员全链路遭殃;AWT、DOM、MyBatis 的 SqlNode、Jackson 的 JsonNode,以及后台系统里的每一棵菜单树部门树,都是它的地盘;安全式与透明式是 GoF 留下的经典取舍,业务代码建议默认安全式。

    下一篇 ⑩ 装饰器模式,它是组合模式的近亲:UML 画出来,几乎是"只准养一个孩子的组合模式"。用途上两家分了工——组合管"一对多长成树",装饰器管"一对一层层加"。不改原类动态加功能,Java IO 流里 new BufferedInputStream(new FileInputStream(file)) 那套套娃,正是它的封神之作。

花

花开空白

开发者 · 西安

相关文章

评论(0)

还没有评论,来抢沙发吧

发表评论