设计模式 10 · 组合模式
前面三篇(代理、装饰器、适配器)都是包装一个对象。从这一篇起,结构型模式换个话题——组织一群对象。第一站是组合模式(Composite),它专门对付一种特定的数据结构:树。树形结构在业务里无处不在:文件系统里,文件夹装着文件,文件夹里还能装文件夹;公司组织里,部门下有小组,小组下有员工;前端的 DOM 里,节点嵌套着节点。它们有个共同特征:存在单个元素和一组元素(容器)两种角色,而容器里又能装单个元素或更小的容器,层层嵌套。我们的订单场景也有这样的结构——一个订单里有商品,但有的商品其实是个套餐,套餐里又包含好几个子商品,子商品理论上还能再嵌套。处理这种结构,最头疼的是区别对待:你想算一个订单的总价,就得先判断这个元素是单个商品还是套餐?“——是商品就直接取价,是套餐就得遍历它的子项再累加,而子项里可能又有套餐……代码里到处是如果是叶子怎么办、如果是容器怎么办的判断,一旦嵌套深了,就是一团递归的乱麻。组合模式的核心价值,就是消灭这种区别对待:让你能用完全一致的方式,去处理一个商品和一整个套餐”,不用关心手上的到底是叶子还是容器。这篇文章按这条线索展开:先看区别对待带来的麻烦;再引出组合模式如何用一个统一接口抹平单个与一组的差异;然后讲清它的角色和那个关键的透明 vs 安全设计取舍;接着看它在标准库里的经典身影;最后给出适用边界。贯穿例是订单里的套餐嵌套。目录树形结构的麻烦:到处都在区别对待组合模式:让单个和一组长得一样角色、骨架与递归之美一个关键取舍:透明模式 vs 安全模式现实身影:树的地方就有它什么时候用组合模式一、树形结构的麻烦:到处都在区别对待先看订单里的套餐结构。一个订单可能是这样的:订单 ├── 商品:可乐 ×1 (单个商品) ├── 套餐:午餐套餐 (容器,里面还有子项) │ ├── 商品:汉堡 ×1 │ ├── 商品:薯条 ×1 │ └── 套餐:饮料组合 (容器里还嵌套容器) │ ├── 商品:咖啡 ×1 │ └── 商品:甜点 ×1 └── 商品:纸巾 ×1 (单个商品)现在要算这个订单的总价。如果不用组合模式,你得区分单个商品和套餐两种类型,代码大概长这样:doublecalcTotal(ListObjectitems){doubletotal0;for(Objectitem:items){if(iteminstanceofProduct){// 如果是单个商品total((Product)item).getPrice();}elseif(iteminstanceofComboPackage){// 如果是套餐// 得手动递归进去,把套餐里的子项再算一遍totalcalcTotal(((ComboPackage)item).getChildren());}}returntotal;}这段代码的问题很典型:调用方被迫知道元素有两种类型,并为每种写不同的处理逻辑(instanceof 分支)。不光是算总价,展示订单、统计商品数、导出清单……每一个操作都得重复这套判断类型 分别处理 手动递归的模板。一旦以后又多出一种容器类型(比如礼包),所有这些地方都得加分支。这违反了开闭原则,也让代码充满了易错的类型判断。问题的根源在于:在调用方眼里,单个商品和套餐是两种不同的东西,得区别对待。可如果我们退一步想——不管是单个商品还是套餐,它们对外都应该能回答同一个问题:“你多少钱?”。单个商品回答自己的价;套餐回答我所有子项加起来的价。如果它们能用同一个方法回答,调用方不就不用区分了吗?这正是组合模式的突破口。二、组合模式:让单个和一组长得一样组合模式的核心思想:让单个元素(叶子)和容器(组合)实现同一个接口,从而让调用方可以用一致的方式对待它们。容器在实现接口方法时,把请求转发给它的所有子元素——而子元素可能又是叶子或容器,于是天然形成递归。第一步,定义一个所有元素共同的抽象接口(叫组件):// 组件:叶子和容器共同的接口publicinterfaceOrderComponent{doublegetPrice();// 不管你是商品还是套餐,都得能回答多少钱voidprint(Stringindent);// 也都能打印自己}第二步,叶子节点(单个商品)——它没有子元素,直接返回自己的数据:publicclassProductimplementsOrderComponent{privatefinalStringname;privatefinaldoubleprice;publicProduct(Stringname,doubleprice){this.namename;this.priceprice;}publicdoublegetPrice(){returnprice;}// 叶子:返回自己的价publicvoidprint(Stringindent){System.out.println(indent商品:name ¥price);}}第三步,容器节点(套餐)——它持有一批子元素,把请求转发给每个子元素再汇总:publicclassComboPackageimplementsOrderComponent{privatefinalStringname;privatefinalListOrderComponentchildrennewArrayList();// 子元素publicComboPackage(Stringname){this.namename;}publicvoidadd(OrderComponentchild){children.add(child);}// 往容器里加publicdoublegetPrice(){doubletotal0;for(OrderComponentchild:children){totalchild.getPrice();// 转发给每个子元素,子元素自己会算}returntotal;// 递归的关键:不关心 child 是叶子还是容器}publicvoidprint(Stringindent){System.out.println(indent套餐:name);for(OrderComponentchild:children){child.print(indent );// 同样地转发}}}看ComboPackage.getPrice()里那行child.getPrice()——它压根不检查child是商品还是套餐,直接调getPrice()就完了。如果child是商品,返回商品价;如果是套餐,套餐自己又会去遍历它的子项……递归就这么自然地发生了,而代码里没有一个instanceof。现在用起来,构建和计算都清爽了:ComboPackagelunchnewComboPackage(午餐套餐);lunch.add(newProduct(汉堡,20));lunch.add(newProduct(薯条,10));ComboPackagedrinksnewComboPackage(饮料组合);drinks.add(newProduct(咖啡,15));lunch.add(drinks);// 套餐里嵌套套餐,没问题// 关键:算总价时,完全不区分它是商品还是套餐System.out.println(lunch.getPrice());// 45,内部递归自动算好对比第一节,升级点非常清晰:调用方不再需要instanceof和分支判断,面对任何一个OrderComponent,直接调getPrice()就行。单个和一组在调用方眼里长得一模一样了——这就是组合模式最核心的价值。三、角色、骨架与递归之美组合模式有三个角色:角色本例中是谁职责抽象组件(Component)OrderComponent接口叶子和容器的共同接口叶子(Leaf)Product没有子节点的终端元素容器/组合(Composite)ComboPackage持有子组件,把操作转发给子组件用一张树形图看这个结构最直观:图里最值得体会的是那条从容器指回抽象组件的引用(容器持有的是ListOrderComponent,而不是ListProduct)。正是这个容器持有的是抽象组件的设计,让容器既能装叶子、也能装容器,从而支持任意深度的嵌套。而所有操作(算价、打印、统计)都变成了对这棵树的递归遍历:遇到叶子就直接处理,遇到容器就转发给它的孩子——递归的终止条件(叶子)和递归步骤(容器转发)被优雅地分派给了两个不同的类,你甚至不用自己写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 树、文件系统,都是它的经典身影。下一篇我们讲外观模式——如果说组合是把一群对象组织成树”,那外观就是给一堆复杂的子系统盖一个简单的门面,让你一句话就能调动背后一整套复杂流程。

相关新闻

Debugger for Java 全配置属性盘点

Debugger for Java 全配置属性盘点

VSCode 生态中,由 Microsoft 出品的 Debugger for Java 扩展是 Java 开发者日常调试的核心工具。它基于 Java Debug Server 与 Eclipse JDT 配合,通过 DAP(Debug Adapter Protocol)将完整的 Java 调试能力接入 VSCode,…

2026/8/7 7:13:10 阅读更多 →
教育数智基座哪家有实力

教育数智基座哪家有实力

在当今数字化转型的大潮中,教育行业也在积极拥抱新技术,以提升管理效率和教学质量。然而,在众多教育信息化解决方案提供商中,如何选择一家真正具备实力的公司呢?本文将通过具体数据和案例,为你推荐一家在教…

2026/8/7 7:13:10 阅读更多 →
环形链表检测:哈希表与快慢指针算法详解

环形链表检测:哈希表与快慢指针算法详解

1. 题目背景与核心需求环形链表 II(LeetCode #142)是数据结构与算法领域的经典面试题,主要考察对链表结构的理解和双指针技巧的掌握。题目要求给定一个链表的头节点,返回链表开始入环的第一个节点。如果链表无环,则返回…

2026/8/7 7:12:10 阅读更多 →

最新新闻

从逆向工程历史学视角:先秦两汉传统工艺集群对现代科技的整体启示

从逆向工程历史学视角:先秦两汉传统工艺集群对现代科技的整体启示

摘要 中国先秦至两汉大量手工业遗存,长期被简单归为“古代手工艺、传统美学”。但借助逆向工程历史学方法重新解构可以发现:水排、青铜冰鉴、筒车、错金银、百炼钢、铜壶滴漏等一大批器物,并不是零散的奇技淫巧,而是一整套成熟的经…

2026/8/7 7:47:29 阅读更多 →
RT-Thread Studio集成STM32 HAL库:解决UART_HandleTypeDef未知类型错误

RT-Thread Studio集成STM32 HAL库:解决UART_HandleTypeDef未知类型错误

1. 问题现象与根源剖析最近在RT-Thread Studio里折腾一个基于STM32的项目,用CubeMX生成了HAL库的初始化代码,然后导入到RT-Thread Studio里准备进行RT-Thread的适配。编译的时候,啪的一下,很快啊,就报错了。错误信息非…

2026/8/7 7:47:29 阅读更多 →
沙盒工具实现程序多开与隔离保护系统

沙盒工具实现程序多开与隔离保护系统

软件介绍 今天给大家推荐的是一款沙盒工具——Sandboxie。这款软件以前是收费的,但因为破解版太泛滥,作者在2025年4月直接宣布开源免费了。对于需要程序隔离或多开的朋友来说,这是个好消息。 安装小贴士 安装过程中会跳出一个要求输入激活…

2026/8/7 7:47:29 阅读更多 →
STM32 GPIO深度解析:从硬件架构到实战配置与避坑指南

STM32 GPIO深度解析:从硬件架构到实战配置与避坑指南

1. 项目概述:从“开关”到“万能接口”的认知跃迁 刚接触STM32那会儿,我最先被灌输的概念就是GPIO。很多人把它简单理解成单片机上的“引脚”,能输出高电平点亮LED,能输入低电平读取按键。这种认知没错,但太浅了&#…

2026/8/7 7:47:29 阅读更多 →
云GPU实战指南:从零搭建深度学习环境到高效训练部署

云GPU实战指南:从零搭建深度学习环境到高效训练部署

1. 项目概述:为什么我们需要云GPU? 如果你是一名开发者、研究者,或者对AI、深度学习、图形渲染、科学计算等领域感兴趣,那么“算力焦虑”这个词你一定不陌生。本地的高性能显卡(GPU)价格昂贵、功耗巨大、更…

2026/8/7 7:47:29 阅读更多 →
嵌入式总线技术全解析:从并行串行到单双工,实战选型与调试指南

嵌入式总线技术全解析:从并行串行到单双工,实战选型与调试指南

1. 从“路”到“总线”:为什么我们需要理解这些基础概念?如果你刚开始接触嵌入式开发、计算机组成原理,或者正在调试一个串口通信问题,看到“并行总线”、“串行总线”、“单工”、“全双工”这些词,是不是感觉既熟悉又…

2026/8/7 7:46:28 阅读更多 →

日新闻

为什么scrcpy成为Android投屏的终极解决方案:完整实战指南

为什么scrcpy成为Android投屏的终极解决方案:完整实战指南

为什么scrcpy成为Android投屏的终极解决方案:完整实战指南 【免费下载链接】scrcpy Display and control your Android device 项目地址: https://gitcode.com/GitHub_Trending/sc/scrcpy 想要将Android手机屏幕完美投射到电脑上,享受大屏操作的自…

2026/8/7 0:00:19 阅读更多 →
如何在5分钟内掌握Tom Select:打造现代化表单选择器的终极指南

如何在5分钟内掌握Tom Select:打造现代化表单选择器的终极指南

如何在5分钟内掌握Tom Select:打造现代化表单选择器的终极指南 【免费下载链接】tom-select Tom Select is a lightweight (~16kb gzipped) hybrid of a textbox and select box. Forked from selectize.js to provide a framework agnostic autocomplete widget wi…

2026/8/7 0:00:19 阅读更多 →
5分钟快速上手:NSZ压缩工具终极指南,轻松管理Switch游戏文件

5分钟快速上手:NSZ压缩工具终极指南,轻松管理Switch游戏文件

5分钟快速上手:NSZ压缩工具终极指南,轻松管理Switch游戏文件 【免费下载链接】nsz NSZ - Homebrew compatible NSP/XCI compressor/decompressor 项目地址: https://gitcode.com/gh_mirrors/ns/nsz 你是否在为Nintendo Switch游戏文件占用大量存储…

2026/8/7 0:00:19 阅读更多 →

周新闻

最大流算法详解:从水管网络到Ford-Fulkerson与Dinic实战

最大流算法详解:从水管网络到Ford-Fulkerson与Dinic实战

1. 从水管网络到最大流:一个核心问题的诞生想象一下,你是一个城市供水系统的总工程师。你的城市有多个水源(水库),需要通过一个复杂的地下管道网络,将水输送到各个居民区。每条管道都有其最大通水能力&…

2026/8/6 22:02:27 阅读更多 →
基于Springboot的企业门户网站(源码+LW+调试文档+讲解)

基于Springboot的企业门户网站(源码+LW+调试文档+讲解)

温馨提示:本人主页置顶文章(点我)开头有 CSDN 平台官方提供的学长联系方式的名片! 温馨提示:本人主页置顶文章(点我)开头有 CSDN 平台官方提供的学长联系方式的名片! 温馨提示:本人主页置顶文章(点我)开头有 CSDN 平台…

2026/8/6 22:02:27 阅读更多 →
MATLAB xcorr函数详解:从互相关原理到四大实战应用

MATLAB xcorr函数详解:从互相关原理到四大实战应用

1. 从一次信号“找茬”说起:为什么我们需要互相关几年前,我在处理一组声学传感器数据时遇到了一个棘手的问题。我有两个麦克风记录了一段相同的音频信号,理论上它们接收到的声音波形应该非常相似,只是由于麦克风位置不同&#xff…

2026/8/6 22:02:27 阅读更多 →

月新闻

免费解锁百度网盘SVIP加速:macOS用户必备的下载提速终极指南

免费解锁百度网盘SVIP加速:macOS用户必备的下载提速终极指南

免费解锁百度网盘SVIP加速:macOS用户必备的下载提速终极指南 【免费下载链接】BaiduNetdiskPlugin-macOS For macOS.百度网盘 破解SVIP、下载速度限制~ 项目地址: https://gitcode.com/gh_mirrors/ba/BaiduNetdiskPlugin-macOS 还在为百度网盘macOS版的龟速下…

2026/8/5 23:28:39 阅读更多 →
终极ncmdump指南:3分钟实现网易云NCM音乐解密与格式转换

终极ncmdump指南:3分钟实现网易云NCM音乐解密与格式转换

终极ncmdump指南:3分钟实现网易云NCM音乐解密与格式转换 【免费下载链接】ncmdump 项目地址: https://gitcode.com/gh_mirrors/ncmd/ncmdump 还在为网易云音乐下载的NCM格式文件无法在其他播放器播放而烦恼吗?ncmdump解密工具帮你轻松解决这个困…

2026/8/6 22:02:28 阅读更多 →
HarmonyOS 应用开发《掌上英语》第81篇: 智能体卡片:为英语学习 App 打造桌面级学习助手

HarmonyOS 应用开发《掌上英语》第81篇: 智能体卡片:为英语学习 App 打造桌面级学习助手

AgentCard 智能体卡片:为英语学习 App 打造桌面级学习助手适用平台:HarmonyOS 7.0 (API 26 Beta)一、引言 HarmonyOS 7.0(API 26 Beta)新增了 AgentCard 智能体卡片能力,这是继 HMAF(鸿蒙智能体框架&#x…

2026/8/5 23:46:51 阅读更多 →