模式补全
这一节按 八面剖析以理解一个概念 / ljg-learn 的思路整理为 Markdown 版本:先定锚,再用八个方向切开概念,最后压缩成公式、例子、类图和检验题。
定锚
组合模式(结构型)的通行定义:把对象组织成树形结构,让客户端以一致方式处理单个对象和对象组合。
常见误解:组合模式不是普通数组嵌套;关键是叶子和容器共享同一个组件接口。
核心词素:Composite 来自 compose,意思是组合成整体。它的隐喻是一棵树,而不是一堆列表。
八刀
历史
它常见于 GUI、文件系统、菜单树。GoF 将“部分-整体一致处理”提炼为模式。
辩证
反面是客户端分别判断叶子和容器。更高理解是:让树节点自己承担递归结构。
现象
水果盘里既可以直接放苹果、香蕉、梨子,也可以继续放一个小水果盘。如果我们对“大水果盘”执行一次“吃”,本质上就是递归地把里面所有水果都吃掉,而不必区分当前拿到的是单个水果还是另一个水果盘。
语言
Composite 来自 compose,意思是组合成整体。它的隐喻是一棵树,而不是一堆列表。
形式
Component.operation(); Composite.children.forEach(operation)。当叶子和容器行为差异过大,共用接口会变别扭。
存在
它让人用递归眼光看对象:整体和部分共享同一种语言。
美感
它美在分形:一片叶子和一棵树都回应同一个动作。
元反思
树的隐喻很强,但会遮住权限、父引用、环检测等工程问题。换成“组织结构”隐喻,会更注意边界。
内观
我是“水果组件”的统一接口。苹果、香蕉、梨子用我表达“自己怎么被吃”,水果盘也用我表达“我如何把里面的水果一个个吃掉”。客户端看到的始终都是同一个 eat() 动作。
八刀共同指向的深层结构:组合模式不是为了“炫技”,而是在某个变化点上建立边界,让稳定部分继续稳定,让变化部分有自己的位置。
压缩
公式:组合 = 统一组件接口 + 叶子 + 容器递归
一句话:组合模式让客户端像处理一个对象一样处理一整棵对象树。
结构图:
Component
|- Leaf
`- Composite -> Component*动机/意图
当业务对象天然呈树形结构时,客户端如果分别处理单个对象和容器对象,代码会充满类型判断。组合模式的意图是用统一的组件接口表示叶子和容器,让客户端以一致方式处理局部和整体。
结构/角色
Component:抽象组件,定义叶子和组合对象的公共接口。Leaf:叶子节点,没有子节点,完成真正的基本行为。Composite:组合节点,保存子组件集合,并把请求递归委托给子节点。Client:客户端面向Component编程,不关心拿到的是叶子还是树。
典型 UML
classDiagram class Component { <<interface>> +operation(): void } class Leaf { +operation(): void } class Composite { -children: Component[] +add(child: Component): void +remove(child: Component): void +operation(): void } Component <|.. Leaf Component <|.. Composite Composite o-- Component
使用场景
- 对象结构是树,例如文件目录、组织架构、菜单、UI 组件树。
- 客户端希望统一处理单个对象和对象集合。
- 对整体执行操作时,需要递归传播到子节点。
- 需要在运行时动态添加、删除、组合节点。
正例:TypeScript
在这个例子里,FruitComponent 是统一抽象,Apple、Banana、Pear 是叶子对象,Plate 是组合对象。对 Plate 调用 eat() 时,它会把请求递归地下发给内部的水果或小水果盘。
interface FruitComponent {
eat(): void
}
class Apple implements FruitComponent {
eat() {
console.log("eat apple")
}
}
class Banana implements FruitComponent {
eat() {
console.log("eat banana")
}
}
class Pear implements FruitComponent {
eat() {
console.log("eat pear")
}
}
class Plate implements FruitComponent {
private items: FruitComponent[] = []
constructor(private name: string) {}
add(item: FruitComponent) {
this.items.push(item)
}
eat() {
console.log(`start eating from ${this.name}`)
this.items.forEach((item) => item.eat())
}
}
const smallPlate = new Plate("small plate")
smallPlate.add(new Apple())
smallPlate.add(new Pear())
const bigPlate = new Plate("big plate")
bigPlate.add(new Banana())
bigPlate.add(smallPlate)
bigPlate.eat()正例:UML 类图
classDiagram class FruitComponent { <<interface>> +eat(): void } class Apple { +eat(): void } class Banana { +eat(): void } class Pear { +eat(): void } class Plate { -items: FruitComponent[] +add(item: FruitComponent): void +eat(): void } FruitComponent <|.. Apple FruitComponent <|.. Banana FruitComponent <|.. Pear FruitComponent <|.. Plate Plate o-- FruitComponent
反例:TypeScript
反例里客户端自己区分“这是水果还是水果盘”,一旦层级变深,遍历逻辑就会不断膨胀。树结构的递归责任没有落在对象本身,而是泄漏到了外部调用方。
class Apple {
eat() {
console.log("eat apple")
}
}
class Banana {
eat() {
console.log("eat banana")
}
}
class Plate {
items: Array<Apple | Banana | Plate> = []
}
function eatPlate(plate: Plate) {
plate.items.forEach((item) => {
if (item instanceof Plate) {
eatPlate(item)
} else {
item.eat()
}
})
}反例:UML 类图
classDiagram class Client { +eatPlate(plate: Plate): void } class Apple { +eat(): void } class Banana { +eat(): void } class Plate { +items: Array } Client ..> Apple : type checks Client ..> Banana : type checks Client ..> Plate : recursive checks
适用场景
- 需要表示一个对象整体或部分层次,在具有整体和部分的层次结构中,希望通过一种方式忽略整体与部分的差异,可以一致地对待它们。
- 让客户能够忽略不同对象层次的变化,客户端可以针对抽象构件编程,无须关心对象层次结构的细节。
- 对象的结构是动态的并且复杂程度不一样,但客户需要一致地处理它们。
案例
在水果盘(Plate)中有一些水果,如苹果(Apple)、香蕉(Banana)、梨子(Pear),当然大水果盘中还可以有小水果盘,现需要对盘中的水果进行遍历(吃),当然如果对一个水果盘执行“吃”方法,实际上就是吃其中的水果。使用组合模式模拟该场景
文件有不同类型,不同类型的文件其浏览方式有所区别,如文本文件和图片文件的浏览方式就不相同。对文件夹的浏览实际上就是对其中所包含文件的浏览,而客户端可以一致地对文件和文件夹进行操作,无须关心它们的区别。使用组合模式来模拟文件的浏览操作。
掌握检验
- 组合模式为什么适合树形结构?
- 叶子节点和组合节点共享接口会带来什么好处和代价?
- 透明式组合和安全式组合有什么区别?