
技术群里经常有人发一段这样的代码给我看处理一棵菜单树的时候用instanceof判断节点类型、强转、再用另一个函数递归处理子节点等哪天菜单里要加一种“外链节点”找人把这段逻辑从里到外翻一遍改两处漏三处代码很快就没人敢动了。我说这类“单个对象和组合对象需要被一样对待”的结构性问题组合模式Composite就是标准解法。这篇东西我就想把这套模式讲透——它到底解决了什么、三类角色怎么分工、完整代码长什么样、哪些坑我实际踩过什么场景别硬用。无论你是刚学设计模式的学生还是写业务系统写到想重构的开发者这文章都能让你少走一段弯路。1. 树形结构为什么难处理组合模式要解决的核心矛盾1.1 到处都在出现树先别急着看类图。你回头看看自己写的业务代码树形结构其实到处都是文件系统一个文件夹里有文件也有子文件夹子文件夹里又能嵌套文件和文件夹公司组织架构一个部门下面有员工还有子部门子部门下还可以继续挂人软件菜单顶级菜单项点开下面有子菜单子菜单还能挂下一级菜单电商商品分类“数码”分类下有“手机”“电脑”“电脑”下又能分“笔记本”“台式机”“笔记本”下才是一个个具体商品前端组件树一个容器组件里放按钮、输入框也能放其他容器组件。这些场景的共同点是结构上的“整体”和“部分”是相对的。一个文件夹相对于它内部的文件是整体但它也是根文件夹的一部分。组合模式这个名字听着抽象本质就是在处理这种“整体-部分”的相对嵌套关系。1.2 没有组合模式时代码是怎么腐烂的假设你在做一个文件管理功能需求是统计任意文件夹的总大小。很多人的第一版代码写出来是这样def get_total_size(item): if isinstance(item, Folder): total 0 for child in item.children: total get_total_size(child) return total elif isinstance(item, File): return item.size else: raise TypeError(未知节点类型)这段代码第一眼看没毛病可一旦系统复杂起来问题就来了每个需要遍历树的功能你都得复制一份这种“类型判断 递归”的逻辑。统计大小要写一套渲染目录树要写一套搜索关键词又要写一套目录里哪天加一种“快捷方式节点”这三套逻辑要各自修改。代码重复不是最疼的最疼的是每次新增节点类型都要回头改所有遍历逻辑很容易漏改漏改就是线上事故。1.3 核心矛盾客户端不该知道“单个”还是“组合”仔细品一品上面的代码你会发现一个关键点调用方其实根本不关心传进来的是文件还是文件夹。get_total_size拿到一个对象它想做的事情只有一件——问“你有多大”剩下的事由对象自己回答。文件回答自己的大小文件夹汇总所有孩子的大小。这才是自然的表达方式。组合模式要解决的正是这个核心矛盾客户端希望用统一的方式对待叶子节点和容器节点但普通写法却逼着客户端到处区分它们。组合模式的方案是让所有节点无论叶子还是容器继承同一个抽象接口客户端只盯着这个接口编程具体的“单个”还是“一套”由节点自己决定。这种感觉你可以类比成俄罗斯套娃每个娃娃不管里头有没有再套一个它都有同样一个“打开”的动作至于打开之后里面是实心的还是又有一个娃娃那是娃娃自己的事。2. 组合模式的骨架Component / Leaf / Composite 三类角色怎么分工2.1 三个角色各自只干一件事组合模式的类结构是设计模式里最清晰的那一批翻来覆去就三个角色Component抽象组件定义叶子节点和容器节点的公共接口。这个接口的典型方法就是业务操作如get_size()以及少量管理子节点的方法如add()、remove()具体放不放后面我会单独讨论透明模式和安全模式。它是整个模式的“公约数”。Leaf叶子节点没有子节点的对象。它实现 Component 定义的真实业务逻辑比如文件返回自己的大小、商品返回自己的价格。它是树底部那些“实心娃娃”。Composite容器节点持有子节点列表子节点类型仍然是 Component——注意这一下就同时容纳了 Leaf 和 Composite。它实现业务操作的方式是“遍历所有子节点把结果聚合起来”比如文件夹的大小等于所有孩子大小之和。它是“肚子里还套着娃娃的娃娃”。2.2 组合起来组装动作和递归动作这三个角色之间的关系支撑起了组合模式的运行机制。组装阶段很好理解创建一个Folder往里add(File)、add(Folder)被加进去的子节点是一个FileSystemItem类型所以容器既能收文件也能收另一个文件夹——嵌套由此而来。真正让组合模式生效的是操作阶段的递归。拿统计大小来说客户端只调用最外层节点的get_size()。如果这个节点是文件它直接返回自己的大小递归结束如果这个节点是文件夹它把自己的每个孩子叫过来要大小孩子如果是文件夹继续往下问自己的孩子。整个树就像电话接力一样一个节点问一层最后结果一层层汇总回传到起点。这里我要说一句经验组合模式的内核不是“三个类”而是“递归”。三个类只是递归得以实现的结构容器。理解这一点你写容器节点的聚合方法时就不会慌——你要做的永远是同一件事“把工作分发给所有孩子然后把他们的答复汇总。”2.3 一个极简的接口设计模板当你设计组合接口时我建议先只放“客户端真正需要统一调用的业务方法”其他方法先不放。下面这个骨架足够你开一个新项目时参考from abc import ABC, abstractmethod from typing import List class FileSystemItem(ABC): abstractmethod def get_size(self) - int: 返回自身占用空间大小 pass这点非常重要。很多文章喜欢在抽象类里放add、remove、get_child这些管理方法但实践中我认为先别急着放——放进去的每个方法意味着叶子类也必须实现它“没有子节点”的叶子每次看到这些方法就只能抛异常或者返回空值不伦不类。管理子节点的方法属于容器放不放要看你对“透明”还是“安全”的取舍这部分我在第 4 节会专门展开。3. 从文件系统到电商分类一个可以直接改着用的实战案例3.1 文件系统两个叶子容器一段递归统计代码我先用一个最经典的文件系统例子完整写一遍实现。假设我们要统计文件夹占用磁盘空间的大小from abc import ABC, abstractmethod from typing import List class FileSystemItem(ABC): 组合模式中的 Component文件和文件夹的公共接口 abstractmethod def get_size(self) - int: pass class File(FileSystemItem): Leaf文件没有子节点 def __init__(self, name: str, size: int): self.name name self.size size def get_size(self) - int: return self.size class Folder(FileSystemItem): Composite文件夹持有一组子节点 def __init__(self, name: str): self.name name self._children: List[FileSystemItem] [] def add(self, item: FileSystemItem) - Folder: 添加子节点子节点可以是文件也可以是文件夹 self._children.append(item) return self def remove(self, item: FileSystemItem) - None: self._children.remove(item) def get_size(self) - int: total 0 for child in self._children: total child.get_size() return total使用起来是这样的root Folder(项目) src Folder(src) root.add(src) src.add(File(main.py, 12)) src.add(File(utils.py, 8)) root.add(File(README.md, 4)) print(root.get_size()) # 输出 24请注意最后一行的调用方式你传进get_size()的是一个根节点它究竟是文件夹还是文件已经不重要了。root.get_size()这个调用和File(README.md, 4).get_size()这个调用在客户端眼里没有任何区别——这就是“对单个对象和组合对象的使用保持一致性”的最直观体现。3.2 电商分类树统计商品总量的变体文件系统会写了业务系统里的场景其实是同一个套路。我拿当时给电商平台做的商品分类树来举例。需求是一个分类下面可能挂具体商品也可能挂子分类子分类下还能再挂商品最终要能统计任意分类下的商品总数。这个时候你把get_size()换成语义更贴切的count()就行了class CatalogItem(ABC): abstractmethod def count(self) - int: 返回当前节点下包含的商品数量 pass class Product(CatalogItem): Leaf具体商品 def __init__(self, name: str, sku: str, quantity: int): self.name name self.sku sku self.quantity quantity def count(self) - int: return self.quantity class Category(CatalogItem): Composite商品分类下面可以挂商品也可以挂子分类 def __init__(self, name: str): self.name name self._children: List[CatalogItem] [] def add(self, item: CatalogItem) - Category: self._children.append(item) return self def count(self) - int: total 0 for child in self._children: total child.count() return total # 组装分类树 electronics Category(数码) phones Category(手机) laptops Category(笔记本) electronics.add(phones).add(laptops) phones.add(Product(iPhone 15, SKU-001, 120)) phones.add(Product(小米14, SKU-002, 200)) laptops.add(Product(MacBook Air, SKU-003, 60)) electronics.add(Product(充电宝, SKU-004, 400)) print(electronics.count()) # 120 200 60 400 780这段代码和文件系统例子几乎一致只是接口名从get_size换成了count叶子节点的数据从文件大小换成了商品数量。这恰好说明组合模式的通用性只要你的业务是“树形结构 对每个节点执行同类操作”代码骨架可以原样搬过去。3.3 为什么写成这样每一步背后的意图这里我把上面代码的设计意图挨个拆给你看接口方法为什么只留一个count()因为客户端需要统一调用的只有这一个操作。真实系统里接口往往还会有display()、search()之类但原则是接口只放叶子与容器的公共业务操作凑不出公共操作的方法就不放进去。子节点列表为什么要命名为_children且私有因为组合模式的封装重点是“容器负责维护自己的结构”。如果外界可以直接操作内部列表很容易绕开类型约束把空值或者非法类型塞进树里。提供一个add方法你还能在方法里做检查比如防止循环引用第 5 节会说。递归方法为什么不会栈溢出你注意看get_size和count都有明确的递归出口叶子节点直接返回数值不再向下调用容器节点调用孩子时孩子要么是叶子结束要么是容器继续分发。只要别自己构造循环引用递归一定会终止。4. 透明模式还是安全模式这个选择会在后续维护时报复你4.1 透明模式所有节点都暴露 add / remove前面我提到“管理子节点的方法放不放 Component”这是组合模式里最经典的争论分别叫透明模式和安全模式。透明模式的思路是在抽象组件Component里直接声明add、remove、get_children等方法叶子和容器都有这些方法。这样一来客户端可以彻底统一地对待所有节点看见一个节点就能当容器处理代码写起来最舒服。代价也很明显叶子对象明明没有子节点却也得实现add方法——用什么实现常见的做法是抛异常、返回空值或者干脆pass结果就是错误被推迟到运行期才暴露。4.2 Java Swing 的经典教训这个坑在 Java 里有过一个教科书般的案例。AWT/Swing 的Component类里就定义了add(Component)方法Button、Label这些叶子组件也继承了这个方法。你可以写出button.add(new Label(xx))这种代码编译期完全合法但是一运行就抛出异常。这就是透明模式的代价类型系统帮不了你add一个不该有孩子的节点编译器只会在旁边看热闹。4.3 安全模式只有容器能 add / remove安全模式的选择恰恰相反add、remove等管理方法只在Composite里声明Component里只放业务方法。这样从类型上就杜绝了“给叶子加孩子”错误在编译期就能拦住。代价是客户端处理节点时得判断“它是容器还是叶子”因为只有容器有add方法。我整理了一下两种模式的取舍维度透明模式安全模式add/remove 定义位置Component抽象类/接口Composite容器类客户端处理是否统一完全统一不需要类型判断容器和叶子需要区分处理误用叶子节点运行期才暴露编译期就能避免接口纯净度叶子被迫实现无意义方法接口更内聚常见使用场景框架设计如 Swing业务系统自研我个人在业务代码里更倾向安全模式接口保持干净容器方法只在容器上客户端即使多写一个isinstance()判断也比运行期突然炸一个异常好得多。透明模式看着很美但“所有节点都会有孩子”这个假设是假的假的假设迟早会咬人。5. 组合模式最坑的三个地方从真实代码里看到的教训5.1 树变图循环引用导致的无穷递归这是我见过最隐蔽的坑。操作系统中目录和文件天然不可能互相包含但业务系统里你很难保证每个节点都遵守“只能往下一层添加”的直觉。举个例子你构造分类树的时候一个用户操作可以把phones这个分类加到electronics下面然后又通过另一个入口把electronics本身加到phones下面。此时调用electronics.count()它统计到phonesphones又统计到自己的子节点子节点里有electronics于是electronics又开始统计……递归永远停不下来最终栈溢出。防御方案很简单在add方法里做循环引用检查def add(self, item: CatalogItem) - Category: if self is item: raise ValueError(不能将节点添加为自身的子节点) # 更严格的场景需要遍历整棵祖先链检查 self._children.append(item) return self检查self is item只能拦住直接自引用如果树更深你需要在添加时沿着当前节点的祖先链向上找一圈确保item没有出现在自己的祖先链里。业务上更通用的做法是给每个节点分配唯一 ID添加时检查“被添加节点的祖先链中是否已有当前节点 ID”。5.2 叶子被当容器调用运行时才炸这个坑我在 4.2 已经用 Swing 的例子说过了但业务中还有变体。有人为了图省事在叶子节点的add方法里写个空实现class File(FileSystemItem): def add(self, item: FileSystemItem): pass # 假装自己的子节点被装进去了这个写法比抛异常更危险——调用方以为加成功了之后发现“文件没有增长”“列表没有变化”排查半天才发现问题。我的建议是如果叶子必须实现add那要么明确抛异常要么返回个特殊值千万别静默吞掉。静默失败是最难查的问题之一。5.3 缓存引起的和组合模式绝配的数据过期问题树结构大了以后递归统计每次都要把整棵树走一遍性能上会心疼。有人就会在容器节点上加缓存class Category(CatalogItem): def count(self): if self._cached_count is not None: return self._cached_count total sum(child.count() for child in self._children) self._cached_count total return total这个思路本身没问题坑在于一旦某个孩子节点的商品数量变化或者树的结构变更父容器的缓存不会自动失效。你往phones下面加了一百件商品electronics._cached_count还停留在旧值。组合模式天然鼓励“孩子变化不影响父节点的接口”这就和缓存失效构成了矛盾。要解决一种做法是给add、remove方法加一个“向上通知父节点缓存失效”的回调另一种做法是每次修改底层数据后显式清理相关路径上的缓存。没有完美的银弹但前提是你得提前想到这个问题。5.4 为了模式而模式只有一个层次的“树”就别硬套最后这点可能最重要。组合模式适合的是多层次、递归嵌套的结构。如果你的业务只有一层父子关系数据用一个列表存起来就行强行上组合模式只会增加类数量、加长调用链。我曾经见过有人为了“让代码有设计感”把两个层级拼成组合模式最后 add、remove 方法只有一处用客户端还得频繁判断类型比普通列表写法麻烦得多。模式是用来降低复杂度的不是用来点缀简历的。6. 组合模式不是万能什么时候不该用它以及和哪些模式搭配6.1 别用的三个信号判断要不要上组合模式我会先看三个信号第一结构深度。树只有两层或者数据量极小普通循环就能讲清楚别用。第二操作是否统一。如果你对每个节点的操作都要区别对待且这种区别业务上合理且频繁组合模式带来的“统一接口”红利就享受不到反而会逼你写一堆isinstance。第三可变性。如果子节点列表经常被并发修改结构稳定性和组合模式默认的“一次性组装、多次遍历”模型不匹配你要么引入更复杂的线程安全机制要么考虑别用。还有一类极端场景性能敏感且节点规模巨大比如百万级节点递归调用栈容易成为瓶颈这种情况下你可能需要显式栈迭代组合模式帮你表达的递归思想仍然成立但直接用递归实现时要谨慎。6.2 组合模式 装饰器Java I/O 流的经典配合组合模式经常和装饰器模式出现在同一个系统里。最典型的例子是 Java 的 I/O 流BufferedReader套在InputStreamReader上InputStreamReader又套在FileInputStream上一层套一层这就是在组合出来的节点上动态附加新功能。组合模式解决的是“整体-部分”结构装饰器解决的是“给单个对象动态附加职责”。两者配合时组合模式构建树装饰器在树上任何一个节点上做功能增强非常自然。6.3 组合模式 迭代器 / 访问者遍历与操作分离当树特别大、操作特别多时你还可以考虑用访问者模式把“遍历树”和“对节点做什么”拆开。编译器处理抽象语法树AST就是典型的组合访问者AST 是组合结构语义分析器是访问者遍历逻辑被组合模式接管每种节点的处理逻辑被访问者收拢到一个类里。如果你只想去重的遍历那迭代器模式可以和组合模式搭配把树的深度优先遍历逻辑封装进一个迭代器客户端用for循环就能平铺地访问所有节点不用自己写递归。说到底组合模式给你的是“结构”其他模式填进去的是“行为”。用组合模式搭好树的骨架再用装饰器、访问者、迭代器各司其职你就能在一棵大树上优雅地实现各种复杂需求。最后再分享一个小技巧写容器节点聚合方法的时候先用一个最土的办法实现比如两层循环展开。跑通、确认树的组装行为正确再考虑用递归、加缓存、上访问者。我在实际项目里见过太多人一上来就写花哨的递归和缓存结果基础数据都组装错了排查起来痛苦不堪。从最土的版本起步组合模式的价值反而更容易被看清。