Chapter 03 · 结构型
结构型:在对象外面「套一层」
第 02 章讲对象怎么来。这一章讲对象造好之后——如何在它外面套一层,去改造、接入、控制或简化它。而这里正是全教程那条主线最致命的考场:四个结构型模式都在「包一层」,类图几乎重合,只能靠意图分。第 01 章的「读意图」从这里开始变成生存技能。
本章你将建立的 schema
- 四个 wrapper(适配器 / 装饰器 / 代理 / 外观)都是「包一层」,靠三个问题区分:改不改接口 · 谁控制组合 · 意图是什么。
- 专家级判别顺序:先问「接口变了吗」→ 变(适配器 / 外观)还是没变(代理 / 装饰器);没变的再问「谁控制 + 控访问还是加功能」。
- 桥接 vs 策略:同结构,一个是结构性的(两条维度独立演化),一个是行为性的(换一个算法)——回扣第 01 章。
- 组合 vs 装饰器:都是递归树,一个是 N 个子节点并聚合结果,一个是 1 个子节点并增加职责。
3.0本章的统一动作:套一层
结构型模式只做一件事:拿一个已经存在的对象,在它外面再放一个对象,让外层去改造内层的行为或访问方式。外层「持有」内层,调用先到外层、再被转给内层。这个动作就是「套一层」(wrapping)。
问题随之而来,而且正是第 01 章预告的那个:既然六个模式都用同一招——面向接口 + 组合——把外层接到内层,它们拼出来的形状必然彼此相似。其中四个(适配器、装饰器、代理、外观)相似到几乎无法靠类图分辨。要分开它们,得退回意图层面,问三个结构图上看不见的问题。
本章的判别钥匙(贯穿全章):
① 接口变了吗? 外层暴露的接口,和内层是同一个,还是另一个?
② 谁控制这一层? 是客户端决定套不套、套几层,还是外层自己说了算?
③ 意图是什么? 适配、加功能、控访问,还是简化?
这三个问题没有一个能从类图读出。这就是第 01 章「读意图,不读类图」在结构型里的字面落地——四个 wrapper 是全教程结构最像的一组,因此也是这条主线回报最高的一组。
3.1适配器(Adapter):把接口翻译成期望的样子
适配器:把一个已有对象的接口,转换成调用方期望的另一个接口,让两个本来不兼容的部分能对接。分离出去的,是「接口形状的差异」。
两段代码都已经写好、也都没法改——调用方期望接口 A(比如内部统一的 PaymentGateway),第三方库只提供接口 B(StripeSDK 的方法名、参数全不一样)。直接改任何一边都不现实(第三方库是别人的,调用方有几十处)。适配器把「B 翻译成 A」这件事单独装进一个对象,差异被关进这一个盒子里。它分离出去的是两个接口之间的形状差异。
底层机制:被动、事后、改变接口
适配器持有那个不兼容的对象,实现调用方期望的接口;每个方法体里,把期望接口的调用翻译成被包对象的调用。三个性质要记住,它们是后面辨析的依据:
- 改变接口:适配器对外暴露的是期望接口 A,内部藏着接口 B。接口变了——这是它和代理、装饰器最硬的分界。
- 被动 / 事后:适配器用在「两个已经存在、却凑不到一起」的代码上。它是事后补救,不是事前设计——记住这点,3.5 的桥接正好相反,是事前就规划好的。
- 代价:多一层翻译,有调用开销和理解成本;翻译若不是一一对应(A 有个方法 B 根本没有),适配器只能模拟或抛异常,翻译就开始失真。
// 调用方期望的接口 A —— 系统里到处都按这个签名调
interface PaymentGateway:
charge(amountInCents): Receipt
// 第三方库只给了接口 B,方法名、参数、单位都不一样,且改不了
class StripeSDK:
createCharge(dollars: float, currency: string): StripeResponse
// 适配器:对外实现 A,内部持有 B,逐方法翻译
class StripeAdapter implements PaymentGateway:
sdk: StripeSDK
constructor(sdk): this.sdk = sdk
charge(amountInCents):
dollars = amountInCents / 100 // 翻译:分 → 美元
resp = this.sdk.createCharge(dollars, "USD")
return new Receipt(resp.id, resp.paidAmount) // 翻译:B 的响应 → A 的 Receipt
// 调用方完全不知道背后是 Stripe,只认 PaymentGateway
gateway: PaymentGateway = new StripeAdapter(new StripeSDK())
gateway.charge(2999) // 按期望接口 A 调
译 charge 方法体里两行翻译:单位换算、响应对象重组。适配器的全部价值就在这种翻译里——它不增加功能,只把一种说法换成另一种说法。
变 对外是 PaymentGateway(接口 A),内部是 StripeSDK(接口 B)。接口被改变了。这一条把适配器和「接口不变」的代理 / 装饰器彻底分开。
何时不该用
当两个接口其实可以直接改成一致时,别套适配器——直接改源头更干净。适配器是为「改不了源头」准备的(第三方库、遗留系统、已发布的公共 API)。还有一种误用:把适配器当成「顺手加点功能」的地方——一旦在翻译里塞进新逻辑,它就不再是适配器,而是在偷偷扮演装饰器,意图被搅浑。
3.2装饰器(Decorator):动态叠加职责
装饰器:在不改原对象、不动其接口的前提下,动态地给它叠加新职责,而且能一层套一层。分离出去的,是「每一项可选的附加职责」。
一个数据流要可选地加上:压缩、加密、缓冲、计量。若用继承穷举所有组合(压缩+加密、加密+缓冲、压缩+加密+缓冲……),子类数量会指数爆炸。装饰器把每一项职责各做成一个独立的「外壳」,运行时由客户端按需叠加。它分离出去的,是一族可自由组合的附加职责。
底层机制(深一层):相同接口 + 持有,所以能透明堆叠
装饰器的两个关键性质,合起来才是它的本质:
- 实现与被包对象相同的接口:装饰后的对象,在调用方眼里和原对象长得一模一样(同一个接口)。所以装饰器对调用方透明。
- 同时持有被包对象:装饰器内部握着一个同接口的引用——要么是原对象,要么是另一个装饰器。因为「装饰器也实现该接口」,它就能被当作「被包对象」喂给下一个装饰器 → 由此层层堆叠。
「相同接口 + 持有同接口对象」这两件事咬合在一起,才造出装饰器最标志性的能力:无限递归地套下去,每一层加一点职责,而调用方始终只看到一个接口。
堆叠的代价直接而隐蔽:外层把内层裹住后,instanceof 检查、对象身份(==)、equals 都穿不透包装。客户端拿到的是最外层装饰器,认不出里面那个原始对象;任何「靠类型或身份判断」的逻辑都会在这里失效。这不是 bug,是「透明叠加」的固有税。
// 被包对象与所有装饰器共享的同一个接口
interface DataSource:
write(data): void
read(): bytes
// 原始对象:朴素地写文件
class FileSource implements DataSource:
write(data): ...把 data 写入文件
read(): ...从文件读出
// 装饰器基类:实现同一接口,且【持有】一个同接口对象
abstract class SourceDecorator implements DataSource:
wrappee: DataSource // ← 可能是原对象,也可能是另一个装饰器
constructor(source): this.wrappee = source
class EncryptionDecorator extends SourceDecorator:
write(data): this.wrappee.write(encrypt(data)) // 先加密,再委托内层
read(): return decrypt(this.wrappee.read())
class CompressionDecorator extends SourceDecorator:
write(data): this.wrappee.write(compress(data))
read(): return decompress(this.wrappee.read())
// ④ 由【客户端】决定叠哪几层、什么顺序
source: DataSource = new EncryptionDecorator(
new CompressionDecorator(
new FileSource())) // 写时:先压缩 → 再加密 → 落盘
source.write(payload) // 调用方只看到 DataSource 接口
同 装饰器和 FileSource 实现同一个 DataSource。所以最外层的 EncryptionDecorator 在调用方眼里,就是个普通 DataSource——接口没变,只是行为被增强。
套 wrappee 可以是另一个装饰器,这就是堆叠的引擎:压缩装饰器把文件源裹住,加密装饰器再把压缩装饰器裹住,任意层数。
④ 谁决定叠哪几层、什么顺序?客户端。这与第 01 章的策略同理——客户端组装。记住「客户端控制组合」这一点,它马上会成为区分装饰器与代理的钥匙。
何时不该用
职责组合很少、且基本固定时,装饰器的灵活性用不上,直接写死或用子类更易读。另外,当下游有大量代码依赖 instanceof / 对象身份,装饰器的「身份穿不透」会反复咬人——这时要么重构掉那些类型检查,要么换别的方案。
3.3代理(Proxy):一个控制访问的替身
代理:给一个对象配一个替身,替身的接口与真实对象完全相同,但在转交调用前后插入「访问控制」——懒加载、远程调用、权限校验、缓存。分离出去的,是「对真实对象的访问控制」。
真实对象的创建或访问有代价或限制:它很重(几百兆的图片,想用时才加载)、它在远端(每次调用要走网络)、它要鉴权(不是谁都能碰)。把这套「访问前的控制逻辑」塞进真实对象本身,会让它背上不属于它的职责。代理把访问控制单独拎出来,真实对象保持纯粹,只管干自己的活。
底层机制(深一层):接口完全相同,区别在意图与生命周期掌控
代理在结构上和装饰器近到危险——两者都实现与目标相同的接口、都持有目标。两条线把它们分开:
- 接口与真实对象完全相同,可互换:代理的目的是「冒充」真实对象,让客户端以为自己直接在用真身。它不增加面向业务的新功能(那是装饰器的活),只在访问通道上加控制。意图是控制访问,不是加功能。
- 代理自己管理被代理对象的生命周期:这是它与装饰器最微妙、也最锋利的分界。装饰器由客户端把「被包对象」交进来;而代理自己决定何时创建、何时连接真实对象——懒加载代理在第一次被调用时才
new出真实对象。换句话说,代理控制这一层,装饰器由客户端控制。
// 代理与真实对象共享的同一个接口
interface Image:
display(): void
// 真实对象:构造代价很大(立刻从磁盘加载几百兆)
class RealImage implements Image:
constructor(path):
this.data = loadFromDisk(path) // 重操作,发生在构造时
display(): render(this.data)
// 代理:同一个接口;真实对象由【代理自己】按需创建
class ImageProxy implements Image:
path: string
real: RealImage = null // 一开始并不存在
constructor(path): this.path = path // 只记住路径,不加载
display():
if this.real == null:
this.real = new RealImage(this.path) // ← 代理自己决定何时创建真身
this.real.display() // 转交调用,不加新业务
// 客户端把它当普通 Image 用;构造时不加载,首次 display 才真正读盘
img: Image = new ImageProxy("huge.png") // 瞬间返回,未读盘
img.display() // 此刻才加载并渲染
同 ImageProxy 与 RealImage 实现同一个 Image,接口完全相同、可互换——这点和装饰器一样,所以光看这里分不出谁是谁。
控 display() 里没有加任何新业务行为,只插了一道「要不要现在加载」的访问控制。意图是控制访问,不是增强。
生 真实对象由 ImageProxy 自己 new 出来——代理掌控真身的生命周期。对照装饰器:wrappee 是客户端从外面塞进来的。这一句就是代理 vs 装饰器的分水岭。
虚拟代理(懒加载,如上例)、远程代理(真身在另一台机器,代理把调用编码成网络请求)、保护代理(转交前检查权限)。三者共享同一句意图:接口不变,只在访问通道上加控制。变体不同,本质同一。
何时不该用
真实对象既不重、不远、也无需鉴权时,代理是凭空多一层间接。还有一个反信号:如果你发现自己想在代理里加业务功能(而不是访问控制),说明你要的其实是装饰器——别把它做成代理。
3.4外观(Facade):给复杂子系统一个简化入口
外观:给一个有很多部件、调用顺序繁琐的子系统,提供一个新的、更简单的入口,把常见用法收成一两个方法。分离出去的,是「与复杂子系统打交道的那套繁琐流程」。
一个子系统由七八个类组成,完成一件常见任务要按特定顺序调用其中五六个、还要处理它们之间的依赖。每个调用方都重复这套繁琐流程,既啰嗦又易错。外观把这套流程收进一个便利方法,调用方一句话就能用。它分离出去的,是「正确使用子系统的繁琐知识」。
底层机制:便利层,不隐藏、不改接口、只是新增一个更简单的
外观和适配器都「在前面放一个对象」,但意图截然不同,机制上有两点要点清:
- 提供一个新的、更简单的接口:外观给的是它自己设计的便利接口,目标是「简化」,不是匹配某个既定的期望接口。对照适配器——适配器是把接口改成调用方早已期望的那一个(接口 A 是给定的),外观的接口是为「省事」新造的。
- 不隐藏子系统:外观只是多开了一扇方便的门,子系统的类仍然公开可用。需要做外观没覆盖的高级操作时,调用方可以绕过外观直接用子系统。它简化访问,但不封锁访问。
外观最常见的失败,是它不断吸纳功能,最后变成一个无所不知、无所不管的上帝对象(god object)——子系统的每个新需求都往外观上挂,它逐渐成了整个子系统的唯一入口和瓶颈,自身也变得无法维护。判断信号:外观的方法数持续膨胀、开始包含业务决策而不只是「编排调用」。
// 子系统:多个部件,各管一段,直接用要懂调用顺序与依赖
class VideoFile: ...
class CodecFactory: extract(file): Codec
class BitrateReader: read(file, codec): bytes; convert(buffer, codec): bytes
class AudioMixer: fix(result): bytes
// 外观:把"转码一个视频"这套繁琐流程,收成一个方法
class VideoConverter:
convert(filename, targetFormat): File
file = new VideoFile(filename)
codec = CodecFactory.extract(file) // ① 取编解码器
buffer = BitrateReader.read(file, codec) // ② 读码率
result = BitrateReader.convert(buffer, codec) // ③ 转换
result = AudioMixer.fix(result) // ④ 修音轨
return new File(result)
// 调用方一句话用完;不必懂上面四步,也不必懂部件间依赖
converter = new VideoConverter()
mp4 = converter.convert("birthday.ogg", "mp4")
// 仍可绕过外观,直接 new BitrateReader() 做高级操作 —— 外观不封锁子系统
简 convert 把四步编排藏进一个方法。调用方看到的是一个新造的、更简单的接口,不是某个早已存在的期望接口——这正是它和适配器的分界。
开 末尾那行注释是关键:子系统的类仍然公开,外观不隐藏它们。需要高级操作就绕过外观直接用——外观只加一扇方便门,不锁其他门。
何时不该用
子系统本就只有一两个类、用法直白时,外观是多余的中间层。另外,当多个调用方需要的「简化方式」各不相同,硬塞进一个外观会逼它膨胀;这时按场景拆成多个小外观,比养一个上帝对象健康。
3.5四个 wrapper 的辨析(本章核心)
四个模式讲完了。把它们摞在一起看:适配器、装饰器、代理、外观,全是「在对象外面套一层、外层持有内层、调用先到外层再转给内层」。类图几乎重合。这正是第 01 章那句话从抽象主张变成生存技能的地方——靠结构,这四个永远分不开;只能靠意图。
一棵决策树:三个问题就能落到唯一答案
专家区分这四个,不是靠记四张图,而是靠一个固定的提问顺序。它把四选一压成两次二分:
| 模式 | 接口变不变? | 意图 | 谁控制组合 |
|---|---|---|---|
| 适配器 | 变:变成调用方早已期望的接口 A | 适配 | —(一次性翻译,无堆叠) |
| 外观 | 变:一个新造的、更简单的接口 | 简化 | —(编排子系统,无堆叠) |
| 代理 | 不变:与真实对象完全相同,可互换 | 控制访问 | 代理自己(掌控真身生命周期) |
| 装饰器 | 不变但增强:同接口,行为更多 | 加功能 | 客户端(决定叠几层、什么顺序) |
专家级判别口诀(本章最值钱的一句):
先问「接口变了吗」 → 变(适配器 / 外观)还是 没变(代理 / 装饰器)。
变的再分:变成调用方期望的那个 → 适配器;变成新造的更简单的 → 外观。
没变的再问「谁控制 + 意图是控访问还是加功能」:代理自己控制、意图是控访问;装饰器由客户端控制、意图是加功能。
代理 vs 装饰器最锋利的那刀:被包对象是谁创建并放进来的?客户端从外面塞进来 → 装饰器;外层自己按需创建 → 代理。
有一个 UserService,接口不动。现在要求:任何方法被调用前,先检查当前用户是否有权限,无权限就拒绝;真实的 UserService 由这一层在确认有权限后才去连数据库。这四个 wrapper 里,该用哪个?为什么不是另外三个?
展开答案(先停 10 秒,走一遍决策树再点)
代理(具体是保护代理)。走决策树:① 接口变了吗?没变——排除适配器和外观。② 没变的再问谁控制 + 意图:这一层做的是「调用前检查权限」=控制访问,不是加业务功能,所以不是装饰器;而且「确认有权限后才去连数据库」意味着这一层自己掌控真实对象的创建时机——这正是代理而非装饰器的标志。
为什么不是另外三个:适配器会改接口(这里接口不变);外观是给复杂子系统造一个新的简单入口(这里没有子系统要简化,接口也没变简单);装饰器意图是加功能、且被包对象由客户端塞进来(这里意图是控访问、真身由这层自己创建)。
3.6桥接(Bridge):把两个维度拆成两套继承体系
桥接:把一个会沿两个独立方向各自变化的类,拆成「抽象」和「实现」两套独立的继承体系,让它们能分别扩展、再组合到一起。分离出去的,是「两条正交的变化维度」。
一个图形类要同时沿两个方向变:形状(圆 / 方 / 三角)和渲染后端(矢量 / 光栅)。若用继承穷举,得写「矢量圆、光栅圆、矢量方、光栅方……」共 形状数 × 后端数 个子类,组合数指数爆炸。桥接把这两个方向各做成一套独立继承体系,运行时把一个形状组合一个渲染后端。它分离出去的,是两条彼此正交、各自演化的维度。
底层机制(深一层):线性增长而非指数爆炸,前提是两维度真的正交
桥接的全部价值来自一个算术对比:
- 不拆(继承穷举):M 种形状 × N 种后端 = M × N 个子类,加一种后端要新增 M 个类。
- 拆(桥接):M 个形状类 + N 个后端类 = M + N 个类,加一种后端只新增 1 个类。组合靠运行时持有完成,是线性增长。
但这条收益有一个硬前提:两条维度必须真正独立(正交)。若「某些形状只能配某些后端」,维度其实纠缠在一起,强行桥接会逼出大量「这个组合非法」的特判,桥接随即失效。还有一点和适配器相反:桥接是事前的结构设计——在动手前就识别出两条维度并据此分体系;适配器是事后补救已经不兼容的代码。
// 维度二:实现体系(渲染后端)—— 独立演化
interface Renderer:
drawCircle(x, y, r): void
class VectorRenderer implements Renderer:
drawCircle(x, y, r): ...画成矢量
class RasterRenderer implements Renderer:
drawCircle(x, y, r): ...画成像素
// 维度一:抽象体系(形状)—— 通过【桥】持有一个 Renderer
abstract class Shape:
renderer: Renderer // ← 这就是"桥":抽象持有实现
constructor(renderer): this.renderer = renderer
abstract draw(): void
class Circle extends Shape:
x, y, radius
draw(): this.renderer.drawCircle(this.x, this.y, this.radius) // 委托给实现维度
// 两个维度自由组合;加一种后端只需新增 1 个 Renderer,不动任何 Shape
c1 = new Circle(new VectorRenderer()) // 矢量圆
c2 = new Circle(new RasterRenderer()) // 光栅圆 —— 同一个 Circle,换实现即可
桥 Shape 持有一个 Renderer——这个「持有」就是名字里的桥:它连接抽象维度和实现维度,让两边能各自扩展、再在运行时拼起来。
线 加一种渲染后端(比如 SvgRenderer)= 新增 1 个类,所有 Shape 自动可用;加一种形状同理。M+N,不是 M×N。
桥接 vs 策略:同结构,一个结构性、一个行为性(回扣第 01 章)
第 01 章用「类图全等、只靠意图分」讲过策略 vs 状态。桥接 vs 策略是同一类陷阱的另一例:桥接的「抽象持有实现」和策略的「Context 持有策略」,类图同样几乎一致——都是一个对象持有一个接口、委托过去。区别又只在意图:
| 维度 | 桥接 | 策略 |
|---|---|---|
| 类图 | 一个对象持有一个接口,委托过去 | 一个对象持有一个接口,委托过去(相同) |
| 意图性质 | 结构性:让两条维度各自独立演化 | 行为性:让一个算法可被另一个替换 |
| 持有的那一端是什么 | 另一条正交维度(实现体系,会沿自己的方向长大) | 同一件事的多种算法(彼此平行、互换) |
| 什么时候确立 | 事前:动手前就识别出两条维度并分体系 | 需求出现多种算法时,把它们抽成可换的策略 |
一句话记:策略是「换一个算法」(行为),桥接是「让两条维度各自长大」(结构)。第 01 章说过——结构是「封装变化」两招拼出来的副产物,会重复;意图才是模式的身份证。桥接 vs 策略再次印证。
一个消息发送类 Notification,要沿两个方向变:消息的紧急级别(普通 / 重要 / 紧急,各自决定标题前缀和重试次数),以及投递渠道(邮件 / 短信 / 推送)。两个方向都还会继续加新值。这该用桥接,还是把「投递渠道」做成可替换的策略?
展开答案(先停 10 秒再点)
桥接。判断信号:这里有两条都会独立长大、且彼此正交的维度——紧急级别(M 种)和投递渠道(N 种),任意紧急级别都能配任意渠道。若用继承穷举要写 M×N 个子类;桥接把两条维度各做一套体系(Notification 抽象持有一个 Channel 实现),只需 M+N 个类,加一种渠道只多 1 个类。
什么时候反而是策略:如果只有投递渠道这一条维度在变(紧急级别固定不动),那就不存在「两条正交维度」,把渠道抽成可替换的算法即可——那是策略。区别始终在意图:两条维度各自演化(结构性)→ 桥接;一个算法可被替换(行为性)→ 策略。这正是第 01 章「读意图不读类图」的又一次应用。
何时不该用
只有一条变化维度时,桥接是过度设计——退回策略(换算法)或简单继承即可。两条维度若不正交(互相约束),桥接会被非法组合的特判压垮,这时该重新审视维度划分,而不是硬上桥接。
3.7组合(Composite):把部分-整体组织成树
组合:把对象组织成「部分-整体」的树,让单个对象(叶子)和对象的容器(枝)对外暴露同一个接口,使客户端能统一对待二者。分离出去的,是「叶子和容器的差别」——对客户端隐藏起来。
文件系统里有文件(叶子)和文件夹(容器,内含文件或更多文件夹)。求「总大小」时,若客户端要不断判断「这是文件还是文件夹?是文件夹就递归、是文件就直接读」,代码到处都是类型分叉。组合让文件和文件夹实现同一个接口,客户端对任何节点都调同一个 size(),递归发生在容器内部。它分离出去、并对客户端隐藏的,是「这个节点是叶子还是容器」的差别。
底层机制(深一层):共享接口支持递归,且明知故犯地违反接口隔离
组合有一个常被当成「缺陷」、实则是已知且被接受的权衡的特征,必须讲清:
- 叶子和容器共享同一接口,从而支持递归:容器的
size()内部遍历它的孩子、对每个孩子再调size()——而孩子本身又可以是容器。正因「叶子和容器是同一个接口」,这种递归才能一路下穿到叶子而无需类型判断。 - 明知故犯地违反接口隔离原则:统一接口上往往有
add(child)/remove(child)这类「管理子节点」的方法。这些方法对叶子毫无意义(文件不能往里加文件),却仍然挂在共享接口上。这违反了「接口隔离」(不该强迫一个类依赖它用不到的方法)。GoF 明确指出:这是为了「让客户端统一对待叶子与容器」而有意付出的代价——是被接受的权衡,不是设计失误。
// 叶子与容器共享的同一个接口
interface Node:
size(): number
// add/remove 也常被放进这个共享接口 —— 对叶子无意义,这是有意的权衡
// 叶子:没有孩子
class FileNode implements Node:
bytes: number
size(): return this.bytes
// add(child): 对文件无意义 —— 抛异常或空实现
// 容器:持有 N 个孩子,聚合它们的结果
class FolderNode implements Node:
children: Node[] = []
add(child): this.children.push(child) // 容器才有意义
size():
total = 0
for child in this.children: // 遍历 N 个孩子
total += child.size() // 递归:孩子可能又是容器
return total // 聚合 N 个结果
// 客户端对任何节点都调同一个 size(),不问它是文件还是文件夹
root = new FolderNode()
root.add(new FileNode(100))
sub = new FolderNode(); sub.add(new FileNode(50)); root.add(sub)
print(root.size()) // 150 —— 递归自动下穿
同 FileNode 和 FolderNode 实现同一个 Node。客户端那行 root.size() 不区分类型——统一对待,正是组合的意图。
N FolderNode.size() 遍历多个孩子并聚合它们的结果。「N 个子节点 + 聚合」是组合的形状——记住这点,下一节它就是组合 vs 装饰器的分界。
隔 add 出现在共享接口上,但对 FileNode 无意义。这是明知故犯违反接口隔离,换来客户端的统一对待——已知的权衡。
组合 vs 装饰器:都是递归树,N 个孩子聚合 vs 1 个孩子加职责
组合和装饰器都是「对象持有同接口对象、可递归嵌套」的树形结构,容易混。但形状不同,意图也不同:
| 维度 | 组合 | 装饰器 |
|---|---|---|
| 子节点数量 | N 个孩子(容器装一组) | 恰好 1 个被包对象(一条链) |
| 对子节点做什么 | 聚合它们的结果(求和 / 收集 / 遍历) | 给那 1 个增加职责(前后插逻辑) |
| 意图 | 表达部分-整体层级,统一对待叶子与容器 | 动态叠加可选职责,接口不变 |
| 树的形状 | 真正的树(枝可多叉) | 退化的树:一条链(每层一个孩子) |
一句话记:看子节点数量——多个孩子并聚合 → 组合;一个孩子并加职责 → 装饰器。
何时不该用
数据本身不是层级结构(没有「部分-整体」关系)时,硬套组合只会凭空造出一棵没人需要的树。另外,若叶子和容器的差异对客户端其实重要(很多逻辑必须区别对待二者),组合「统一对待」的前提就不成立——这时把它们做成不同类型、显式区分,反而更诚实。
§本章 self-check
先合上教程,把答案写在纸上或编辑器里,写完再展开对照。直接展开等于把这一节当再读一遍。
- 区分四个 wrapper 的第一个问题是什么?它把四个砍成哪两组?
- 代理和装饰器的接口都和目标相同、都持有目标。说出两条能把它们分开的判据(一条关于意图,一条关于「被包对象由谁创建放进来」)。
- 桥接和策略的类图几乎相同——这正是第 01 章策略 vs 状态那类陷阱的翻版。看哪一个信号判断眼前是桥接还是策略?(提示:回想第 01 章「结构是副产物,意图是身份证」。)
- 组合在共享接口上放
add/remove,而它们对叶子无意义。这是设计失误,还是有意的权衡?换来了什么?
答案(先做完再展开)
- 第一个问题:「接口变了吗?」。变 → 适配器 / 外观;没变 → 代理 / 装饰器。它把四选一压成第一次二分。
- ① 意图:代理是控制访问(懒加载 / 远程 / 权限),装饰器是加功能(叠职责)。② 被包对象由谁创建放进来:代理自己按需创建并掌控真身生命周期;装饰器的被包对象由客户端从外面塞进来。
- 看意图性质:若持有的那一端是另一条会独立长大的正交维度(结构性) → 桥接;若持有的是同一件事的多种可互换算法(行为性) → 策略。这个信号不在类图上,在意图里——正如第 01 章所说,类图会重复,意图才是身份证。
- 有意的权衡,不是失误。GoF 明确把它当作已知代价。换来的是:客户端能统一对待叶子与容器,对任何节点调同一个方法、靠递归自动下穿,而不必到处写「这是叶子还是容器」的类型分叉。
一个对象同时是代理又是装饰器,怎么办?
真实场景:一个 RemoteUserService,它(a)把本地调用编码成网络请求发到远端服务器拿数据,同时(b)给返回结果加上一层本地缓存,下次同样的请求直接返回缓存、不走网络。问题:按本章的判别口诀,(a)指向代理(远程访问控制),(b)的「加缓存」听起来像在加功能(装饰器)。这一个对象到底是代理还是装饰器?该怎么想清楚?
提示(卡住再展开)
关键不在给它贴一个标签,而在看每一项职责的意图,并且必要时拆开。「编码成网络请求」是纯粹的访问控制(让远端对象看起来像本地)——这是远程代理。「缓存」要分清:如果缓存的目的只是避免重复的昂贵访问(少走一次网络),它本质仍是访问控制(代理)的一种(常称缓存代理 / cache proxy),不是在给业务结果增加新行为。
真正干净的设计:把两件事拆成两层——一个远程代理负责网络,一个缓存代理负责缓存,缓存代理在外、远程代理在内。每一层意图单一。如果哪天需要的不是「避免访问」而是「给结果本身追加业务处理」(比如给每条记录附加审计字段),那一层才升级为装饰器。判断依据始终是第 3.5 节那句:意图是控制访问,还是加功能——而不是「它看起来在做几件事」。