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 根本没有),适配器只能模拟或抛异常,翻译就开始失真。
adapter-payment · worked example伪代码
// 调用方期望的接口 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,是「透明叠加」的固有税。

decorator-stream · worked example伪代码
// 被包对象与所有装饰器共享的同一个接口
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 出真实对象。换句话说,代理控制这一层,装饰器由客户端控制。
proxy-image · worked example(懒加载代理)伪代码
// 代理与真实对象共享的同一个接口
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)——子系统的每个新需求都往外观上挂,它逐渐成了整个子系统的唯一入口和瓶颈,自身也变得无法维护。判断信号:外观的方法数持续膨胀、开始包含业务决策而不只是「编排调用」。

facade-video · worked example伪代码
// 子系统:多个部件,各管一段,直接用要懂调用顺序与依赖
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 章那句话从抽象主张变成生存技能的地方——靠结构,这四个永远分不开;只能靠意图。

一棵决策树:三个问题就能落到唯一答案

专家区分这四个,不是靠记四张图,而是靠一个固定的提问顺序。它把四选一压成两次二分:

接口变了吗? 外层暴露的接口 变了 没变 变成哪种? 期望的 / 新造的 调用方早已期望的 适配器 意图:适配 新造的更简单的 外观 意图:简化 谁控制 + 意图? 控访问 / 加功能 自己控制 · 控访问 代理 意图:控制访问 客户端控制 · 加功能 装饰器 意图:加职责 四者同源:都是「套一层」,类图几乎相同 异在意图:这棵树问的每一个问题,都不出现在类图上
图 3.1四个 wrapper 的决策树。注意:第一刀切「接口变没变」就把四个砍成两组;变的那组再分「期望的(适配器)/ 新造的(外观)」,没变的那组再分「谁控制 + 控访问还是加功能(代理 / 装饰器)」。整棵树问的全是意图,底部那条带提醒:这些区别一个都不在类图上——这就是「读意图」。
表 3.1 · 四个 wrapper 同源异意:接口 / 意图 / 谁控制组合
模式接口变不变?意图谁控制组合
适配器变:变成调用方早已期望的接口 A适配—(一次性翻译,无堆叠)
外观变:一个新造的、更简单的接口简化—(编排子系统,无堆叠)
代理不变:与真实对象完全相同,可互换控制访问代理自己(掌控真身生命周期)
装饰器不变但增强:同接口,行为更多加功能客户端(决定叠几层、什么顺序)

专家级判别口诀(本章最值钱的一句):

先问「接口变了吗」 → 变(适配器 / 外观)还是 没变(代理 / 装饰器)。

变的再分:变成调用方期望的那个 → 适配器;变成新造的更简单的 → 外观。

没变的再问「谁控制 + 意图是控访问还是加功能」:代理自己控制、意图是控访问;装饰器由客户端控制、意图是加功能。

代理 vs 装饰器最锋利的那刀:被包对象是谁创建并放进来的?客户端从外面塞进来 → 装饰器;外层自己按需创建 → 代理。

想一想 · 给场景判断该用哪个 wrapper

有一个 UserService,接口不动。现在要求:任何方法被调用前,先检查当前用户是否有权限,无权限就拒绝;真实的 UserService 由这一层在确认有权限后才去连数据库。这四个 wrapper 里,该用哪个?为什么不是另外三个?

展开答案(先停 10 秒,走一遍决策树再点)

代理(具体是保护代理)。走决策树:① 接口变了吗?没变——排除适配器和外观。② 没变的再问谁控制 + 意图:这一层做的是「调用前检查权限」=控制访问,不是加业务功能,所以不是装饰器;而且「确认有权限后才去连数据库」意味着这一层自己掌控真实对象的创建时机——这正是代理而非装饰器的标志。

为什么不是另外三个:适配器会改接口(这里接口不变);外观是给复杂子系统造一个新的简单入口(这里没有子系统要简化,接口也没变简单);装饰器意图是加功能、且被包对象由客户端塞进来(这里意图是控访问、真身由这层自己创建)。

3.6桥接(Bridge):把两个维度拆成两套继承体系

桥接:把一个会沿两个独立方向各自变化的类,拆成「抽象」和「实现」两套独立的继承体系,让它们能分别扩展、再组合到一起。分离出去的,是「两条正交的变化维度」。

为什么需要它 / 分离出什么

一个图形类要同时沿两个方向变:形状(圆 / 方 / 三角)和渲染后端(矢量 / 光栅)。若用继承穷举,得写「矢量圆、光栅圆、矢量方、光栅方……」共 形状数 × 后端数 个子类,组合数指数爆炸。桥接把这两个方向各做成一套独立继承体系,运行时把一个形状组合一个渲染后端。它分离出去的,是两条彼此正交、各自演化的维度。

底层机制(深一层):线性增长而非指数爆炸,前提是两维度真的正交

桥接的全部价值来自一个算术对比:

  • 不拆(继承穷举):M 种形状 × N 种后端 = M × N 个子类,加一种后端要新增 M 个类。
  • 拆(桥接):M 个形状类 + N 个后端类 = M + N 个类,加一种后端只新增 1 个类。组合靠运行时持有完成,是线性增长。

但这条收益有一个硬前提:两条维度必须真正独立(正交)。若「某些形状只能配某些后端」,维度其实纠缠在一起,强行桥接会逼出大量「这个组合非法」的特判,桥接随即失效。还有一点和适配器相反:桥接是事前的结构设计——在动手前就识别出两条维度并据此分体系;适配器是事后补救已经不兼容的代码。

bridge-shape · worked example伪代码
// 维度二:实现体系(渲染后端)—— 独立演化
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。

形状 M 个 渲染后端 · N 个 圆 方 三角 矢量 光栅 SVG 矢量圆 光栅圆 SVG圆 矢量方 光栅方 SVG方 ··· ··· ··· 继承穷举 M × N 个类 加 1 种后端 → 多 M 个类 桥接:抽象持有实现 M + N 个类(线性) 加 1 种后端 → 只多 1 个类 · 前提:两维度正交
图 3.2桥接的两条正交维度。注意:网格里那 M×N 个格子是「继承穷举」被迫写出的子类;桥接把它拆成左边一列(M 个形状)+ 顶上一行(N 个后端),靠运行时组合填格子,于是只需 M+N 个类。底部红框写明收益与前提:维度必须正交,否则桥接失效。

桥接 vs 策略:同结构,一个结构性、一个行为性(回扣第 01 章)

第 01 章用「类图全等、只靠意图分」讲过策略 vs 状态。桥接 vs 策略是同一类陷阱的另一例:桥接的「抽象持有实现」和策略的「Context 持有策略」,类图同样几乎一致——都是一个对象持有一个接口、委托过去。区别又只在意图:

表 3.2 · 同结构,靠意图区分:桥接 vs 策略(对照第 01 章的策略 vs 状态)
维度桥接策略
类图一个对象持有一个接口,委托过去一个对象持有一个接口,委托过去(相同)
意图性质结构性:让两条维度各自独立演化行为性:让一个算法可被另一个替换
持有的那一端是什么另一条正交维度(实现体系,会沿自己的方向长大)同一件事的多种算法(彼此平行、互换)
什么时候确立事前:动手前就识别出两条维度并分体系需求出现多种算法时,把它们抽成可换的策略

一句话记:策略是「换一个算法」(行为),桥接是「让两条维度各自长大」(结构)。第 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 明确指出:这是为了「让客户端统一对待叶子与容器」而有意付出的代价——是被接受的权衡,不是设计失误。
composite-fs · worked example伪代码
// 叶子与容器共享的同一个接口
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 个孩子加职责

组合和装饰器都是「对象持有同接口对象、可递归嵌套」的树形结构,容易混。但形状不同,意图也不同:

表 3.3 · 同为递归结构,靠形状与意图区分:组合 vs 装饰器
维度组合装饰器
子节点数量N 个孩子(容器装一组)恰好 1 个被包对象(一条链)
对子节点做什么聚合它们的结果(求和 / 收集 / 遍历)给那 1 个增加职责(前后插逻辑)
意图表达部分-整体层级,统一对待叶子与容器动态叠加可选职责,接口不变
树的形状真正的树(枝可多叉)退化的树:一条链(每层一个孩子)

一句话记:看子节点数量——多个孩子并聚合 → 组合;一个孩子并加职责 → 装饰器。

组合:N 个孩子,聚合 文件夹 文件 文件 子文件夹 文件 多叉树 · 枝可再分枝 装饰器:1 个孩子,加职责 加密装饰器 压缩装饰器 文件源 一条链 · 每层只裹 1 个
图 3.3组合 vs 装饰器的树形对比。注意:左边组合是多叉树——一个节点伸出多个孩子,容器聚合它们;右边装饰器是退化成链的树——每一层只裹住下一层那唯一一个对象,每层加一点职责。数子节点的个数,就能一眼分开两者。

何时不该用

数据本身不是层级结构(没有「部分-整体」关系)时,硬套组合只会凭空造出一棵没人需要的树。另外,若叶子和容器的差异对客户端其实重要(很多逻辑必须区别对待二者),组合「统一对待」的前提就不成立——这时把它们做成不同类型、显式区分,反而更诚实。

§本章 self-check

先合上教程,把答案写在纸上或编辑器里,写完再展开对照。直接展开等于把这一节当再读一遍。

  1. 区分四个 wrapper 的第一个问题是什么?它把四个砍成哪两组?
  2. 代理和装饰器的接口都和目标相同、都持有目标。说出两条能把它们分开的判据(一条关于意图,一条关于「被包对象由谁创建放进来」)。
  3. 桥接和策略的类图几乎相同——这正是第 01 章策略 vs 状态那类陷阱的翻版。看哪一个信号判断眼前是桥接还是策略?(提示:回想第 01 章「结构是副产物,意图是身份证」。)
  4. 组合在共享接口上放 add/remove,而它们对叶子无意义。这是设计失误,还是有意的权衡?换来了什么?
答案(先做完再展开)
  1. 第一个问题:「接口变了吗?」。变 → 适配器 / 外观;没变 → 代理 / 装饰器。它把四选一压成第一次二分。
  2. ① 意图:代理是控制访问(懒加载 / 远程 / 权限),装饰器是加功能(叠职责)。② 被包对象由谁创建放进来:代理自己按需创建并掌控真身生命周期;装饰器的被包对象由客户端从外面塞进来。
  3. 看意图性质:若持有的那一端是另一条会独立长大的正交维度(结构性) → 桥接;若持有的是同一件事的多种可互换算法(行为性) → 策略。这个信号不在类图上,在意图里——正如第 01 章所说,类图会重复,意图才是身份证。
  4. 有意的权衡,不是失误。GoF 明确把它当作已知代价。换来的是:客户端能统一对待叶子与容器,对任何节点调同一个方法、靠递归自动下穿,而不必到处写「这是叶子还是容器」的类型分叉。
进阶挑战 · 刚好够不着

一个对象同时是代理又是装饰器,怎么办?

真实场景:一个 RemoteUserService,它(a)把本地调用编码成网络请求发到远端服务器拿数据,同时(b)给返回结果加上一层本地缓存,下次同样的请求直接返回缓存、不走网络。问题:按本章的判别口诀,(a)指向代理(远程访问控制),(b)的「加缓存」听起来像在加功能(装饰器)。这一个对象到底是代理还是装饰器?该怎么想清楚?

提示(卡住再展开)

关键不在给它贴一个标签,而在看每一项职责的意图,并且必要时拆开。「编码成网络请求」是纯粹的访问控制(让远端对象看起来像本地)——这是远程代理。「缓存」要分清:如果缓存的目的只是避免重复的昂贵访问(少走一次网络),它本质仍是访问控制(代理)的一种(常称缓存代理 / cache proxy),不是在给业务结果增加新行为。

真正干净的设计:把两件事拆成两层——一个远程代理负责网络,一个缓存代理负责缓存,缓存代理在外、远程代理在内。每一层意图单一。如果哪天需要的不是「避免访问」而是「给结果本身追加业务处理」(比如给每条记录附加审计字段),那一层才升级为装饰器。判断依据始终是第 3.5 节那句:意图是控制访问,还是加功能——而不是「它看起来在做几件事」。