资讯动态

Java组合模式实战:从商品分类树到递归遍历的代码重构

发布时间:2026/9/10 3:55:11 来源:尧图企业网站定制
开篇先跟大家聊个真实的场景我做权限系统的时候要渲染一个多级菜单树第一版代码里全是if (item instanceof ParentMenu)的判断后来菜单层级从两级涨到五级代码越堆越乱项目经理盯着那段逻辑看了半天说了句“这玩意儿迟早要重构”。后来我把它改成Composite模式整个遍历逻辑瞬间干净了。这也是我写这篇文章的初衷——组合模式Composite Pattern不是让你炫技的它是用来解决那种“部分-整体”层次结构里最常见的代码失控问题。如果你正在复习设计模式准备期末考试或者刚看完《大话设计模式》对组合那章还有点懵又或者工作中遇到树形结构不知道怎么写这篇文章应该能帮到你。我会用Java代码带你手写一个商品分类树中间穿插我在实际项目里踩过的坑最后再聊聊透明式和安全式的历史争论以及什么时候千万别硬套这个模式。1. 组合模式到底在解决什么问题用文件系统类比要理解组合模式最好先看一个你每天都在用的例子文件系统。一个文件目录下有子文件夹和文件子文件夹里又有子文件夹和文件层层套娃。对你来说无论你双击的是一个文件夹还是一个文件操作路径是统一的——你不需要双击文件夹时先判断“这是文件夹还是文件”再决定要不要展开系统已经帮你把这种差异封装好了。1.1 没有组合模式时树形结构的代码长什么样很多人的第一版树形遍历代码是这么写的public void display(Node node) { if (node.isDirectory()) { for (Node child : node.getChildren()) { System.out.println(目录: child.getName()); display(child); } } else { System.out.println(文件: node.getName()); } }初看好像没什么问题但一旦节点类型增加比如加了“快捷方式”、“压缩包”、“网络驱动器”每个类型都可能有自己的特殊行为你的if-else会越堆越多。更要命的是调用方必须知道每一个具体类型才能正确处理这就等于把树的内部结构和客户端代码耦合在了一起。下次你新增一个节点类型所有相关的地方都要跟着改一遍这是典型的“类爆炸”前兆。1.2 组合模式的核心思想让叶子与容器统一对待组合模式的定义很精炼将对象组合成树形结构以表示“部分-整体”的层次结构使得用户对单个对象和组合对象的使用具有一致性。翻译成大白话就是叶子节点文件和容器节点文件夹都实现了同一个接口客户端可以完全不在乎自己拿到的到底是一个文件还是一个文件夹统一调用同一个方法。这样做的好处有三点第一客户端代码简单不需要大量的类型判断第二新增节点类型时只要它实现了统一接口就能无缝嵌入已有的树第三树的多层嵌套结构可以递归处理符合这种结构的天然性质。所以组合模式不是某个具体场景的奇技淫巧而是解决“部分-整体”问题的通用思路。2. Component / Leaf / Composite三类角色的职责边界组合模式一共就三个角色但很多人学的时候只记住了名字没搞清楚它们为什么这么分。这里我按自己的理解重新拆一遍。2.1 抽象构件Component该定义哪些方法抽象构件是所有节点的父接口它定义了叶子节点和容器节点的公共行为。最常见的公共行为就是“显示自己”和“获取名字”这类方法。这里的关键问题是要不要把add和remove也定义进抽象构件这个选择直接决定了整个模式的形态后面第4节会详细展开。我个人的建议是在第一次设计时抽象构件里只放那些确定所有子类都必须实现的操作。比如下面这个例子public abstract class ProductComponent { public abstract String getName(); public abstract double getPrice(); public abstract void display(); }一旦加入add方法你就等于在暗示叶子节点也可以有子节点但绝大多数业务场景里叶子节点是不允许有子节点的这种暗示会带来语义上的混乱。2.2 叶子节点与容器节点为什么必须“假装”一致叶子节点Leaf是树中最小的不可再分单元它没有子节点。容器节点Composite内部有一个集合保存着它的子节点子节点可以是叶子也可以是另一个容器。为了让客户端能统一处理叶子节点也必须实现Component接口哪怕它内部根本没有任何子节点可管理。这种“假装一致”到底要“假装”到什么程度是组合模式设计时最大的争议点。有的实现里叶子的add方法直接抛UnsupportedOperationException表示“我不支持这个操作”有的实现里叶子直接在add方法里return或者记录日志静默忽略。两种方式各有利弊但都有一个共同点客户端不用再单独判断节点类型就能调用公共方法递归遍历才能跑起来。这种一致性是模式的核心价值所以你宁可让叶子做一些“无效动作”也不要破坏统一接口。3. 手写一个商品分类树完整实现与代码拆解光说不练没用下面我带你写一个电商系统里常见的商品分类树。需求是这样一个分类节点可以包含子分类也可以包含具体的商品SKUSKU是最小单位不能再往下挂东西。我们要能展示整棵分类树还要能统计某个分类下的所有商品总价。3.1 从抽象类到叶子与容器最简可运行版本先定义一个抽象构件ProductComponent包含展示和获取价格的公共方法public abstract class ProductComponent { public String getName() { throw new UnsupportedOperationException(暂不支持该操作); } public double getPrice() { throw new UnsupportedOperationException(暂不支持该操作); } public void display() { throw new UnsupportedOperationException(暂不支持该操作); } }这里用了默认抛异常的方式而不是定义成抽象方法。原因是分类节点和SKU的行为差别较大如果用抽象方法两个子类都得不情愿地实现对方才需要的方法。用默认抛异常可以让子类只覆盖自己关心的方法这也是一种常见的设计取舍。然后是叶子节点SkuItempublic class SkuItem extends ProductComponent { private String name; private double price; public SkuItem(String name, double price) { this.name name; this.price price; } Override public String getName() { return name; } Override public double getPrice() { return price; } Override public void display() { System.out.println(商品: name 价格: price); } }接着是容器节点CategoryNodepublic class CategoryNode extends ProductComponent { private String name; private ListProductComponent children new ArrayList(); public CategoryNode(String name) { this.name name; } public void add(ProductComponent component) { children.add(component); } public void remove(ProductComponent component) { children.remove(component); } Override public String getName() { return name; } Override public void display() { System.out.println(分类: name); for (ProductComponent child : children) { child.display(); } } }注意这里容器的display方法写成了递归调用每个子节点要么是更深的容器要么是叶子但客户端调用display时根本不需要知道实际类型这就是组合模式最朴素的效果。3.2 新增价格统计功能遍历时的递归细节现在加一个新的需求统计某个分类下所有商品总价。只需要在CategoryNode里增加一个递归方法public double totalPrice() { double total 0; for (ProductComponent child : children) { if (child instanceof CategoryNode) { total ((CategoryNode) child).totalPrice(); } else { total child.getPrice(); } } return total; }这个实现里出现了instanceof很多人会问不是说组合模式为了避免instanceof吗这里必须解释清楚组合模式减少的是客户端对节点类型的判断但在实现内部递归逻辑时你往往仍然需要知道当前节点是容器还是叶子否则就没法决定是继续递归还是一步到位。如果你希望连这个判断也消除可以把totalPrice也定义在抽象构件里让叶子返回自己的价格容器遍历所有子节点后累加。这样客户端调用totalPrice时不需要任何类型判断。public abstract class ProductComponent { public abstract double totalPrice(); }但副作用是抽象构件的职责变重了。实际项目中我更倾向于把这种“树形聚合操作”放在容器类内部因为递归逻辑天然跟子节点列表绑定放在Leaf里反而显得不伦不类。3.3 这段代码踩过的坑add方法放在哪才合理我第一次写组合模式时为了追求所谓的“透明性”把add方法也放进了抽象构件结果在客户端可以写出这样的代码SkuItem sku new SkuItem(手机, 4999); sku.add(new SkuItem(手机壳, 19));编译能过运行时才抛异常而且抛的是UnsupportedOperationException我一度觉得这个设计没毛病——只要客户端约定好用instanceof判断一下就能避开。但后来接到一个需求需要序列化成JSON再渲染到前端序列化框架会反射调用getter结果它把sku里的add方法也当成了可读属性导致一串诡异的数据结构。从那天起我彻底转向了“add方法只放在容器里”的路线。运行时异常只能在测试时暴露问题编译期错误才能防患于未然。这件事也让我明白设计模式里的“透明”不是绝对的好事具体怎么取舍得看你的实际使用场景。4. 透明式与安全式的历史争论以及现代取舍组合模式在GoF书里其实没有强行规定add和remove必须放在哪个层次这就导致后来出现了两个流派透明式Transparent和安全式Safe。4.1 透明式让叶子也拥有add/remove的利与弊透明式的做法是把add/remove方法全部定义在抽象构件里叶子节点也一并实现。这样客户端的任何地方都可以不加区分地调用add/remove不需要向下转型。在UI组件库里这种设计很常见——比如Swing的Container和ComponentContainer继承自ComponentContainer有add(Component)方法。因为UI场景里很多“不透明”的操作反而麻烦比如你要给一个面板加子组件如果先判断面板类型再调用代码会很繁琐。透明式的弊端前面已经讲过了叶子节点的add变成一种“假能力”。更麻烦的是如果业务上不小心往叶子节点塞了子节点它可能不会立即报错只是“静默失败”这类bug最讨厌因为排查成本很高。4.2 安全式用类型判断换安全代价是什么安全式则把add/remove只定义在容器节点里抽象构件里只有公共行为。这样叶子节点永远不暴露add能力编译器就帮你拦截了错误调用。代价是客户端如果想添加子节点必须先把对象向下转型成容器类型ProductComponent component new CategoryNode(分类); if (component instanceof CategoryNode category) { category.add(new SkuItem(商品, 10)); }这种类型判断如果散落在各个地方代码会显得啰嗦。但换个角度看这些判断本质上就是“这个节点是不是容器”的业务逻辑与其隐藏起来不如显式表达反而更清晰。4.3 我在项目里最终选择的方案我在实际项目里最终选了安全式。原因很简单我们的分类树是商品系统的核心容不得半点静默异常。宁可多写一个向下转型也要让错误在编译期就暴露。这个选择的前提是我们的客户端代码本来就跟树打了很多交道偶尔的向下转型成本可以接受。如果你的场景是给外部SDK用的通用组件客户端可能来自很多团队那透明式可能更好因为可以减少使用者的学习成本。没有绝对正确的答案只有适合当前场景的答案。5. 组合模式的真实应用场景与容易被忽略的误用组合模式在真实项目里的核心价值就是处理树形结构但并非所有树形结构都适合套用。这一节我总结了我实际做过和见过的一些场景以及几个反面教材。5.1 非常适合组合模式的四类场景第一类是层次化菜单/目录权限系统的菜单树、系统功能树、配置项分类树。这类结构天然就是“容器包含叶子”的模型用组合模式最顺手。第二类是组织架构公司有总部、分公司、部门部门里有员工。总部和分公司是容器员工是叶子。展示组织架构、计算人员总数时组合模式能统一处理。第三类是UI组件嵌套图形界面中的窗口、面板、按钮。JPanel可以包含JButton也可以包含其他JPanel这种嵌套关系和组合模式简直天作之合。第四类是表达式与规则引擎一个算术表达式(a b) * c可以被解析成一棵树操作符是容器节点数值是叶子节点。计算表达式的值时只需要递归调用每个节点的evaluate方法即可。这些场景都有两个特征一是层级结构是递归的二是客户端希望对不同的层级采取一致的操作方式。只要同时满足这两点组合模式几乎就是最优解。5.2 什么情况下别用组合模式血泪教训很多人学了组合模式之后容易劲头上来看到树形结构就往上套。但有些情况真的不推荐。如果你的“叶子节点”行为差异极大比如一个叶子要处理图片另一个叶子要处理音频它们的公共接口无法概括彼此的行为硬塞进组合结构会让抽象构件变得臃肿。与其强行统一不如先把业务建模做好再考虑是否值得用组合模式。还有一种情况树的深度和分支数是固定的比如一个只有两层的简单配置结构用两个类直接表达反而更清晰。为此引入抽象类和多态就像杀鸡用牛刀。设计模式最大的成本是抽象层级带来的理解开销小项目里得不偿失。我再分享一个反面案例曾有人把一个“店铺 商品”的两层结构强行套用组合模式抽象构件里定义了几十个方法叶子节点全部抛异常代码库瞬间变得很难维护。后来我们重构后砍掉了抽象层每个类只关心自己的事代码反而清爽了。所以组合模式并不代表“高端”它只有在真正需要统一处理多级树的时候才闪闪发光。6. 从组合模式延伸递归遍历的两种写法与性能思考写组合模式的代码必然绕不开递归。递归虽然是树形结构的天然表达但实际编码时还有很多细节值得聊。6.1 使用递归遍历树的两种实现最简单的递归写法是在节点自身方法里遍历子节点并调用子节点的同名方法就像前面display()那样。这种方式把“递归”隐藏在每个节点内部代码看起来是一层平铺直叙的调用实际上是深度优先遍历。还有一种更明显的递归写法是把遍历动作提取成一个独立工具方法比如public static void traverse(ProductComponent node) { node.display(); if (node instanceof CategoryNode category) { for (ProductComponent child : category.getChildren()) { traverse(child); } } }区别在于前者把递归逻辑分布在各节点类里符合面向对象的分工思想后者把所有遍历逻辑集中在一个外部方法里更方便统一控制遍历顺序和访问逻辑。如果你的树操作很多且各不相同更推荐后者——可以避免在节点类里塞进一堆遍历方法保持节点类的内聚。6.2 当树足够深时栈溢出问题怎么处理递归最大的隐患是栈溢出。组合模式天然鼓励递归当树的层级很深比如超过JVM默认栈深度每次递归调用都会占用调用栈空间最终会抛出StackOverflowError。在实际业务系统里一个树超过几百层的情况非常罕见但如果你是写底层组件就不得不考虑这个问题。解决方式是把递归改成显式的栈或队列public static void displayIterative(ProductComponent root) { DequeProductComponent stack new ArrayDeque(); stack.push(root); while (!stack.isEmpty()) { ProductComponent current stack.pop(); current.display(); if (current instanceof CategoryNode category) { for (ProductComponent child : category.getChildren()) { stack.push(child); } } } }非递归遍历虽然没有了栈溢出风险但代价是代码可读性变差。我在实际项目里的做法是先默认写递归如果明确知道层级可能很深再单独针对这个方法做压测和重构。大多数情况下递归版本的可读性优势远大于理论上的栈溢出风险。最后再分享一个平时可能没注意到的点组合模式跟迭代器模式经常搭配使用。你可以给容器节点写一个迭代器让客户端用for-each的方式遍历整棵树某些场景下比纯粹递归更灵活。但迭代器模式的实现又涉及集合遍历状态管理复杂度会再上一个台阶。如果只是常规的树形数据展示用递归就足够了。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价