少女祈祷中...
残云的学习笔记

组合模式:叶子与容器一视同仁,以及它和装饰者到底差在哪

当数据天然长成一棵树时,怎么让客户端不去关心"我面前这个节点到底是叶子还是子树"。本文讲清三个角色与那条自聚合的边、组合模式最核心的取舍(透明方式 vs 安全方式)。

一、问题的起点:树形结构,客户端却在做类型判断

组织结构是最典型的一棵树:公司下面有部门,部门下面有小组,小组下面有员工——每一层都可能是"容器",也可能已经是"叶子"。

没有组合模式时,这种结构会让调用方掉进类型判断里:

/** 没有组合时:调用方被迫按类型分支,而且每加一种节点就要改一次 */
void printAll(Object org, String indent) {
    if (org instanceof Employee) {
        System.out.println(indent + ((Employee) org).getName());
    } else if (org instanceof Department) {
        Department dept = (Department) org;
        System.out.println(indent + dept.getName());
        for (Object child : dept.getChildren()) {
            printAll(child, indent + "  ");     // 递归分支也跟着类型走
        }
    } else {
        throw new IllegalArgumentException("未知节点类型");
    }
}

问题有三层,而且一层比一层严重:

  1. 调用方必须知道所有节点类型:每多一种(比如"外包组""虚拟团队"),if/else 就要加一支;
  2. 递归逻辑和类型判断缠在一起:容器要递归、叶子不递归,这个差异被写死在调用方;
  3. "统一对待"根本无从谈起:你没法把"一个员工"和"整个部门"放进同一个方法里调用——它们没有共同的接口。

composite-1-two-ways

二、结构:三个角色,一条「自聚合」的边

组合只有三个角色,但类图里那条自己指向自己的边是关键:

composite-2-structure

  • Component(抽象构件):OrgUnit。叶子与容器共同对外暴露的接口——人数、薪资合计、打印。客户端只认它。
  • Leaf(叶子构件):Employee。没有子节点,headcount() 永远返回 1,salaryFen() 返回自己的月薪。
  • Composite(容器构件):Department。既是 OrgUnit(继承),又持有一组 OrgUnit(聚合)——和装饰者一样是"既实现又持有",但这里持有的是一组,而不是一个。
  • 那条自聚合边:Department o-- "0..*" OrgUnit。它让"部门里可以有子部门"成立,也让递归成为可能——树上任何一层都能当作整棵树来用。

两个容易漏掉的点:

  1. print 在抽象类里给了默认实现(只打印自己),容器再覆盖它做递归。这是组合模式里最常见的写法:叶子不需要知道"递归"这件事,容器负责往下走。
  2. 聚合关系的重数写 0..*:空部门是合法的(刚建好还没加人),客户端不应该为"空容器"写特例。

三、手写一个组织架构

下面是可直接运行的单文件版本(Java 8+):

import java.util.ArrayList;
import java.util.List;

/** Component:叶子与容器统一对外暴露的接口 */
abstract class OrgUnit {
    protected final String name;

    protected OrgUnit(String name) {
        this.name = name;
    }

    /** 人数 */
    abstract int headcount();

    /** 月薪合计(单位:分) */
    abstract long salaryFen();

    /** 默认只打印自己;容器覆盖它做递归 */
    void print(String indent) {
        System.out.println(indent + name);
    }
}

/** Leaf:员工,没有子节点 */
class Employee extends OrgUnit {
    private final long monthlySalaryFen;

    Employee(String name, long monthlySalaryFen) {
        super(name);
        this.monthlySalaryFen = monthlySalaryFen;
    }

    @Override
    int headcount() {
        return 1;                                   // 叶子的"规模"就是它自己
    }

    @Override
    long salaryFen() {
        return monthlySalaryFen;
    }

    @Override
    void print(String indent) {
        System.out.println(indent + "· " + name + "(" + monthlySalaryFen / 100 + " 元/月)");
    }
}

/** Composite:部门——既是 OrgUnit,又持有一组 OrgUnit */
class Department extends OrgUnit {
    private final List<OrgUnit> children = new ArrayList<>();

    Department(String name) {
        super(name);
    }

    void add(OrgUnit unit) {
        children.add(unit);
    }

    void remove(OrgUnit unit) {
        children.remove(unit);
    }

    @Override
    int headcount() {
        int total = 0;
        for (OrgUnit child : children) {
            total += child.headcount();              // 递归:叶子返回 1,子部门继续往下拆
        }
        return total;
    }

    @Override
    long salaryFen() {
        long total = 0L;
        for (OrgUnit child : children) {
            total += child.salaryFen();
        }
        return total;
    }

    @Override
    void print(String indent) {
        System.out.println(indent + name + "(" + headcount() + " 人)");
        for (OrgUnit child : children) {
            child.print(indent + "  ");             // 递归:叶子与容器都只认 print
        }
    }
}

public class CompositeDemo {
    public static void main(String[] args) {
        Employee zhang = new Employee("张三", 1200000L);   // 12000 元
        Employee li = new Employee("李四", 1500000L);
        Employee wang = new Employee("王五", 900000L);

        Department backend = new Department("后端组");
        backend.add(zhang);
        backend.add(li);

        Department tech = new Department("技术部");
        tech.add(backend);
        tech.add(wang);

        // 客户端统一对待:单个员工和整个部门走同一套接口
        System.out.println("技术部人数:" + tech.headcount());
        System.out.println("技术部月薪合计:" + tech.salaryFen() / 100 + " 元");
        System.out.println();
        tech.print("");
        System.out.println();
        System.out.println("单个员工人数:" + zhang.headcount());
    }
}

输出:

技术部人数:3
技术部月薪合计:36000 元

技术部(3 人)
  后端组(2 人)
    · 张三(12000 元/月)
    · 李四(15000 元/月)
  · 王五(9000 元/月)

单个员工人数:1

三点值得注意:

  1. 客户端只写了一行 tech.print(""),却打印出整棵树——因为递归藏在容器里,不在调用方。
  2. tech.headcount() 和 zhang.headcount() 是同一个方法。这就是"统一对待":客户端不需要知道手上拿的是叶子还是整棵树。
  3. 加一层部门不用改任何已有代码:tech.add(backend) 里的 backend 本身也是 Department——容器可以装容器,这正是自聚合边的价值。

四、递归是组合的灵魂

组合模式的所有能力都来自一处:每个容器的操作,都是"对自己 + 对每个孩子"的同一操作的组合。

composite-3-sequence

这段递归有三个性质,值得单独记住:

性质含义好处
对称容器的实现对子节点调用的是同一套接口树可以无限深,代码行数不变
自相似每个子树的算法和整棵树完全一样单元测试只需测一层容器 + 一个叶子
可折叠深度优先即可得到汇总值人数、金额、耗时这类"汇总型"需求天然适配

反过来,如果"汇总"的规则不是简单相加(比如部门人数要去重、跨部门共享的员工只算一次),组合就撑不住了——那不是树的问题,是数据本身不是树。

一个实践提醒:组合的递归是深度优先的,树的深度直接等于调用栈深度。业务上真有超深树(比如几万层的分类树),要么改成显式栈 + 迭代遍历,要么把汇总下推成一次 SQL 的递归查询(CTE)/ 物化结果,别让 headcount() 在深树上把栈撑爆。组合模式擅长的是"结构清楚、层次有限"的树,不是"无限深"的树。

五、透明方式 vs 安全方式

这是组合模式唯一真正需要做取舍的地方:add / remove 到底放在哪一层?

透明方式(放在 Component 里):客户端可以对任何节点无差别调用 add,但叶子必须"假装自己支持",运行时抛异常:

abstract class OrgUnit {
    // 默认实现:叶子继承后调用就抛异常
    void add(OrgUnit unit) {
        throw new UnsupportedOperationException(name + " 是叶子节点,不支持 add");
    }

    void remove(OrgUnit unit) {
        throw new UnsupportedOperationException(name + " 是叶子节点,不支持 remove");
    }
}

安全方式(只放在 Composite 里):编译期就调不到,但客户端必须先判断类型:

class Department extends OrgUnit {
    void add(OrgUnit unit)    { children.add(unit); }
    void remove(OrgUnit unit) { children.remove(unit); }
}

// 客户端:必须先知道它是 Department
if (node instanceof Department) {
    ((Department) node).add(new Employee("赵六", 800000L));
}

怎么选:

  • 树是固定的、客户端几乎只做读取和汇总 → 透明方式,接口最干净(JDK 的 java.awt.Container 就是这种);
  • 客户端会频繁增删节点、且叶子容器混用 → 安全方式,让编译期帮你把关;
  • 折中做法:透明方式 + 容器提供 isComposite() 之类的判断方法,让需要时能提前规避。

六、组合 vs 装饰者

这两个是结构型里结构最像的一对——都是"实现 Component + 持有 Component"。差别在持有一个还是一组,以及意图:

组合 Composite装饰者 Decorator
结构关系实现 Component + 持有 一组 Component实现 Component + 持有 一个 Component
树的形状多叉树(自聚合,0..*)单链(一层套一层)
意图表示「部分-整体」层次动态叠加职责
客户端看到一棵树,但当做一个对象用一个对象,但行为被增强了
典型场景组织架构、菜单、文件系统、UI 容器IO 流、加缓存/日志/加密

一句话记忆:

  • 组合:一个节点下面挂着很多节点,客户端把整棵树当一个对象用;
  • 装饰者:一个对象外面套着一个对象,客户端拿到的还是同一个接口的对象。

七、什么时候不要用组合

  1. 结构本来就是平的:没有层级就别硬套树,只会多出一层无意义的抽象。
  2. 叶子与容器差异太大、无法统一接口:比如叶子需要"下单"、容器需要"批量审批"——硬塞进一个 Component 会让接口又大又空。
  3. 汇总规则不是"逐层相加":需要去重、跨节点约束、按不同维度重新分组的,组合模式会逼你在容器里写越来越多的特例。
  4. 需要严格的类型安全:如果业务上必须区分"这里只能是部门、那里只能是员工",组合模式追求的"统一对待"反而是负担。

八、结构型六个:一张表理清

模式一句话意图结构特征典型场景
代理 Proxy控制对单个对象的访问一对一,接口不变权限、远程调用、延迟加载
适配器 Adapter让接口对不上的双方协作一对一,接口转换接第三方 SDK、老系统兼容
装饰者 Decorator不改源码叠加职责一层套一个,接口不变IO 流、加缓存/日志/加密
桥接 Bridge分离两个独立变化的维度两棵树 + 一座桥JDBC、跨平台 GUI
外观 Facade简化一组子系统的调用多对一,统一入口下单编排、日志门面
组合 Composite表示部分-整体的层次结构一对多自聚合,多叉树组织架构、菜单、文件系统、UI 容器

九、参考资料

  • GoF《设计模式:可复用面向对象软件的基础》——组合模式章节
  • 《Head First 设计模式》第 9 章——组合模式与「统一对待」
  • 本系列相关篇目:装饰者模式(持有一个,叠职责)、桥接模式(两棵树)、外观模式(多对一简化)
分享到

评论