云计算百科
云计算领域专业知识百科平台

设计模式 10 · 组合模式

前面三篇(代理、装饰器、适配器)都是"包装一个对象"。从这一篇起,结构型模式换个话题——组织一群对象。第一站是组合模式(Composite),它专门对付一种特定的数据结构:树。

树形结构在业务里无处不在:文件系统里,文件夹装着文件,文件夹里还能装文件夹;公司组织里,部门下有小组,小组下有员工;前端的 DOM 里,节点嵌套着节点。它们有个共同特征:存在"单个元素"和"一组元素(容器)"两种角色,而容器里又能装单个元素或更小的容器,层层嵌套。 我们的订单场景也有这样的结构——一个订单里有商品,但有的"商品"其实是个套餐,套餐里又包含好几个子商品,子商品理论上还能再嵌套。

处理这种结构,最头疼的是"区别对待":你想算一个订单的总价,就得先判断"这个元素是单个商品还是套餐?“——是商品就直接取价,是套餐就得遍历它的子项再累加,而子项里可能又有套餐……代码里到处是"如果是叶子怎么办、如果是容器怎么办"的判断,一旦嵌套深了,就是一团递归的乱麻。组合模式的核心价值,就是消灭这种区别对待:让你能用完全一致的方式,去处理"一个商品"和"一整个套餐”,不用关心手上的到底是叶子还是容器。

这篇文章按这条线索展开:先看"区别对待"带来的麻烦;再引出组合模式如何用一个统一接口抹平"单个"与"一组"的差异;然后讲清它的角色和那个关键的"透明 vs 安全"设计取舍;接着看它在标准库里的经典身影;最后给出适用边界。贯穿例是订单里的套餐嵌套。

目录

  • 树形结构的麻烦:到处都在区别对待
  • 组合模式:让单个和一组长得一样
  • 角色、骨架与递归之美
  • 一个关键取舍:透明模式 vs 安全模式
  • 现实身影:树的地方就有它
  • 什么时候用组合模式
  • 一、树形结构的麻烦:到处都在区别对待

    先看订单里的套餐结构。一个订单可能是这样的:

    订单
    ├── 商品:可乐 ×1 (单个商品)
    ├── 套餐:午餐套餐 (容器,里面还有子项)
    │ ├── 商品:汉堡 ×1
    │ ├── 商品:薯条 ×1
    │ └── 套餐:饮料组合 (容器里还嵌套容器)
    │ ├── 商品:咖啡 ×1
    │ └── 商品:甜点 ×1
    └── 商品:纸巾 ×1 (单个商品)

    现在要算这个订单的总价。如果不用组合模式,你得区分"单个商品"和"套餐"两种类型,代码大概长这样:

    double calcTotal(List<Object> items) {
    double total = 0;
    for (Object item : items) {
    if (item instanceof Product) { // 如果是单个商品
    total += ((Product) item).getPrice();
    } else if (item instanceof ComboPackage) { // 如果是套餐
    // 得手动递归进去,把套餐里的子项再算一遍
    total += calcTotal(((ComboPackage) item).getChildren());
    }
    }
    return total;
    }

    这段代码的问题很典型:调用方被迫知道"元素有两种类型",并为每种写不同的处理逻辑(instanceof + 分支)。不光是算总价,展示订单、统计商品数、导出清单……每一个操作都得重复这套"判断类型 + 分别处理 + 手动递归"的模板。一旦以后又多出一种容器类型(比如"礼包"),所有这些地方都得加分支。这违反了开闭原则,也让代码充满了易错的类型判断。

    问题的根源在于:在调用方眼里,"单个商品"和"套餐"是两种不同的东西,得区别对待。 可如果我们退一步想——不管是单个商品还是套餐,它们对外都应该能回答同一个问题:“你多少钱?”。单个商品回答自己的价;套餐回答"我所有子项加起来的价"。如果它们能用同一个方法回答,调用方不就不用区分了吗? 这正是组合模式的突破口。

    二、组合模式:让单个和一组长得一样

    组合模式的核心思想:让"单个元素(叶子)"和"容器(组合)"实现同一个接口,从而让调用方可以用一致的方式对待它们。 容器在实现接口方法时,把请求"转发"给它的所有子元素——而子元素可能又是叶子或容器,于是天然形成递归。

    第一步,定义一个所有元素共同的抽象接口(叫"组件"):

    // 组件:叶子和容器共同的接口
    public interface OrderComponent {
    double getPrice(); // 不管你是商品还是套餐,都得能回答"多少钱"
    void print(String indent); // 也都能打印自己
    }

    第二步,叶子节点(单个商品)——它没有子元素,直接返回自己的数据:

    public class Product implements OrderComponent {
    private final String name;
    private final double price;
    public Product(String name, double price) { this.name = name; this.price = price; }

    public double getPrice() { return price; } // 叶子:返回自己的价
    public void print(String indent) {
    System.out.println(indent + "商品:" + name + " ¥" + price);
    }
    }

    第三步,容器节点(套餐)——它持有一批子元素,把请求转发给每个子元素再汇总:

    public class ComboPackage implements OrderComponent {
    private final String name;
    private final List<OrderComponent> children = new ArrayList<>(); // 子元素

    public ComboPackage(String name) { this.name = name; }
    public void add(OrderComponent child) { children.add(child); } // 往容器里加

    public double getPrice() {
    double total = 0;
    for (OrderComponent child : children) {
    total += child.getPrice(); // 转发给每个子元素,子元素自己会算
    }
    return total; // 递归的关键:不关心 child 是叶子还是容器
    }
    public void print(String indent) {
    System.out.println(indent + "套餐:" + name);
    for (OrderComponent child : children) {
    child.print(indent + " "); // 同样地转发
    }
    }
    }

    看 ComboPackage.getPrice() 里那行 child.getPrice()——它压根不检查 child 是商品还是套餐,直接调 getPrice() 就完了。如果 child 是商品,返回商品价;如果是套餐,套餐自己又会去遍历它的子项……递归就这么自然地发生了,而代码里没有一个 instanceof。

    现在用起来,构建和计算都清爽了:

    ComboPackage lunch = new ComboPackage("午餐套餐");
    lunch.add(new Product("汉堡", 20));
    lunch.add(new Product("薯条", 10));
    ComboPackage drinks = new ComboPackage("饮料组合");
    drinks.add(new Product("咖啡", 15));
    lunch.add(drinks); // 套餐里嵌套套餐,没问题

    // 关键:算总价时,完全不区分它是商品还是套餐
    System.out.println(lunch.getPrice()); // 45,内部递归自动算好

    对比第一节,升级点非常清晰:调用方不再需要 instanceof 和分支判断,面对任何一个 OrderComponent,直接调 getPrice() 就行。 "单个"和"一组"在调用方眼里长得一模一样了——这就是组合模式最核心的价值。

    三、角色、骨架与递归之美

    组合模式有三个角色:

    角色本例中是谁职责
    抽象组件(Component) OrderComponent 接口 叶子和容器的共同接口
    叶子(Leaf) Product 没有子节点的终端元素
    容器/组合(Composite) ComboPackage 持有子组件,把操作转发给子组件

    用一张树形图看这个结构最直观:

    在这里插入图片描述

    图里最值得体会的是那条从容器指回抽象组件的引用(容器持有的是 List<OrderComponent>,而不是 List<Product>)。正是这个"容器持有的是抽象组件"的设计,让容器既能装叶子、也能装容器,从而支持任意深度的嵌套。而所有操作(算价、打印、统计)都变成了对这棵树的递归遍历:遇到叶子就直接处理,遇到容器就转发给它的孩子——递归的终止条件(叶子)和递归步骤(容器转发)被优雅地分派给了两个不同的类,你甚至不用自己写 if 来控制递归,类型系统帮你搞定了。这就是组合模式的"递归之美":把一个复杂的树形递归,拆解成每个节点各自简单的、局部的行为。

    四、一个关键取舍:透明模式 vs 安全模式

    组合模式有一个必须讲清楚的设计取舍,关于"管理子节点的方法(add、remove)该放在哪"。

    看个矛盾:add(child)、remove(child) 这些管理子节点的方法,只有容器才需要——叶子是没有子节点的,给一个商品调 add 毫无意义。那这些方法,应该定义在抽象组件接口里,还是只定义在容器类里?两种选择,各有利弊,分别叫"透明模式"和"安全模式":

    透明模式:把 add/remove 也放进抽象组件接口 OrderComponent。

    • 好处:叶子和容器接口完全一致,调用方拿到任何组件都能一视同仁,真正做到"透明"——这也是 GoF 原书推荐的方式。
    • 代价:叶子(Product)被迫也实现了 add/remove,但它其实不支持,只能空实现或抛异常。这就把"不支持"的问题从编译期推迟到了运行时——你给一个商品调 add,编译不报错,运行时才抛 UnsupportedOperationException。这其实违反了里氏替换(第一篇讲过的:子类不该抛父类不抛的异常)。

    安全模式:add/remove 只定义在容器类 ComboPackage 里,抽象组件接口保持干净。

    • 好处:类型安全,叶子根本没有 add 方法,你在编译期就不可能对叶子误调用。
    • 代价:叶子和容器接口不一致了,调用方想调 add 时,必须先判断"这是不是容器"、并向下转型成 ComboPackage——又回到了 instanceof 的老路,失去了部分"透明"。

    怎么选?这是"透明性"和"类型安全"之间的权衡,没有绝对答案:

    • 如果你更看重"调用方完全不区分叶子和容器"的极致透明(比如做一个通用的树遍历框架),选透明模式,接受叶子那几个空实现;
    • 如果你更看重编译期的类型安全、不希望出现"对叶子调 add"的运行时错误,选安全模式,接受调用方偶尔要做类型判断。

    实际项目里,透明模式用得更多,因为组合模式的全部意义就是"统一对待",安全模式的类型判断某种程度上削弱了这个初衷。但要清楚它的代价,并在叶子的空实现里给出清晰的异常信息。理解这个取舍,比记住哪个"更好"更重要——它再次说明:模式不是死板的教条,而是一组需要你根据场景权衡的选择。

    五、现实身影:树的地方就有它

    组合模式在标准库和框架里,凡是有"树"的地方几乎都有它:

    • java.awt / Swing 的 UI 组件树:Component 是抽象组件,Button、Label 是叶子,Container(如 Panel、Frame)是容器,容器里能放组件也能放容器。你调 container.paint(),它会递归地把自己和所有子组件都画出来——标准的组合模式。
    • XML/HTML 的 DOM 树:Node 是抽象组件,文本节点是叶子,元素节点是容器,层层嵌套构成文档树。
    • 文件系统抽象:File 既可以代表文件(叶子),也可以代表目录(容器),listFiles() 递归遍历——虽然 Java 的 java.io.File 没有严格按组合模式设计,但思想一致。
    • 各类菜单树、权限树、组织架构树、评论嵌套回复:业务系统里凡是"可无限嵌套的层级结构",组合模式都是标准解法。

    一个识别信号:只要你看到"XX 里可以包含 XX,且能无限套下去",并且希望对单个和一组用统一方式处理,那就是组合模式的主场。

    六、什么时候用组合模式

    老规矩,泼冷水。组合模式很优雅,但它有明确的适用前提。

    适合用的信号:

    • 你的数据天然是树形/层级结构(部分-整体的关系,能无限嵌套);
    • 你希望用一致的方式处理"单个对象"和"对象组合",不想在业务代码里到处 instanceof 判断类型;
    • 新增一种叶子或容器类型时,不想改动已有的遍历逻辑。

    不必用的信号:

    • 数据结构就是扁平的一层(比如订单直接挂一堆商品,永远不会嵌套)——那用普通的 List 遍历就够了,套组合模式纯属把简单问题复杂化;
    • 各种元素的行为差异极大、根本抽象不出一个统一接口——强行统一反而别扭。

    判断的核心仍是那句话:先确认数据真的是"会嵌套的树",且真的需要"统一处理单个与一组",组合模式才配得上。 给一个永远只有一层的结构套上 Component/Leaf/Composite 三角色,是典型的过度设计。


    小结。组合模式专治树形结构,它让"单个元素(叶子)“和"容器(组合)“实现同一个接口,使调用方能用完全一致的方式处理它们,彻底消灭了"区别对待"和满地的 instanceof。它的精髓是"容器持有的是抽象组件”,从而支持任意深度嵌套,并把复杂的树递归拆解成每个节点简单的局部行为。它有透明模式(接口统一但叶子有空实现)和安全模式(类型安全但需类型判断)两种取舍,实际多用透明模式。Swing 组件树、DOM 树、文件系统,都是它的经典身影。下一篇我们讲外观模式——如果说组合是"把一群对象组织成树”,那外观就是"给一堆复杂的子系统盖一个简单的门面",让你一句话就能调动背后一整套复杂流程。

    赞(0)
    未经允许不得转载:网硕互联帮助中心 » 设计模式 10 · 组合模式
    分享到: 更多 (0)

    评论 抢沙发

    评论前必须登录!