首页
/ CS-Notes 设计模式精讲:装饰(Decorator)模式——以组合动态扩展对象功能的入门到源码佐证

CS-Notes 设计模式精讲:装饰(Decorator)模式——以组合动态扩展对象功能的入门到源码佐证

2026-09-06 19:11:52作者:翟江哲Frasier

装饰(Decorator)模式是 CS-Notes 设计模式系列(notes/设计模式 - 装饰.md)中结构型模式的核心成员,它允许在不修改现有类代码的前提下,将对象逐层"包裹",从而动态地为对象添加职责与功能。本文以经典的"饮料加配料计费"案例为主线,完整继承原文档的类图、代码与设计原则,并对照 JDK 中 java.iojava.util.zipjava.util.Collections 等真实实现,帮你彻底掌握装饰模式"组合优于继承"的精髓,以及它在实际代码库中(如 Java I/O 体系)的落地方式。

一、模式意图(Intent):为对象动态添加功能

装饰模式的核心意图只有一句话:

为对象动态添加功能。

它强调两个关键点:

  • 动态:功能是否添加、添加哪一层、按什么顺序添加,都在运行时由调用方决定,而不是在编译期用继承写死;
  • 针对对象:装饰发生在"对象"层面,同一个类的不同实例可以拥有完全不同的功能组合,互不影响。

对比来看,传统继承是"静态的、类层面的扩展":一旦使用继承,所有子类实例都会固定获得父类的能力,并且每增加一种能力组合都可能需要新增一个子类,导致类数量爆炸。装饰模式则把扩展点从"类"下沉到"对象",用组合 + 递归调用实现同样甚至更强的扩展能力。这正是本仓库设计模式笔记在 notes/设计模式.md 概述中所强调的"经验复用"思想的典型应用。

二、类图解析:装饰者与被装饰者同源同构

装饰模式的结构可以用下面这张类图概括(该图同时收录在 notes/设计模式 - 装饰.md 与图片资源目录 notes/pics/6b833bc2-517a-4270-8a5e-0a5f6df8cd96.png):

CS-Notes 装饰模式类图:Component、ConcreteComponent、Decorator 与 ConcreteDecorator 的继承与组合关系

类图中包含四类角色,它们在"饮料加配料"案例中一一对应:

角色 抽象层级 职责 案例对应
Component(组件) 抽象 定义业务方法的统一接口 Beverage(含 cost()
ConcreteComponent(具体组件) 实现 真正的基础实现,方法不依赖其它对象,是整个装饰层次的最底层 DarkRoastHouseBlend
Decorator(装饰者) 抽象 实现组件接口,同时组合一个组件引用,作为所有具体装饰者的基类 CondimentDecorator
ConcreteDecorator(具体装饰者) 实现 在调用被装饰者方法的基础上叠加自己的功能 MilkMochaWhip

原文档对这张图的解读值得反复品味:

装饰者(Decorator)和具体组件(ConcreteComponent)都继承自组件(Component),具体组件的方法实现不需要依赖于其它对象,而装饰者组合了一个组件,这样它可以装饰其它装饰者或者具体组件。所谓装饰,就是把这个装饰者套在被装饰者之上,从而动态扩展被装饰者的功能。装饰者的方法有一部分是自己的,这属于它的功能,然后调用被装饰者的方法实现,从而也保留了被装饰者的功能。

从源码结构可以归纳出两个必须记住的要点:

  1. "继承"保证类型统一:装饰者与具体组件实现同一个 Component 接口,因此装饰者本身又可以当作组件被另一层装饰者包裹,形成"装饰器套装饰器"的递归结构;
  2. "组合"保证功能叠加:每个装饰者内部持有 Component 引用,调用被装饰者方法后追加自身逻辑——具体组件是装饰层次的最低层,因为只有它的方法实现完全不依赖其它对象。

三、动态装饰的运作模型:逐层包裹与递归调用

理解了类图,再来看运行时的形态。原文档用 "DarkRoast 上加 Mocha、再叠 Whip" 的示意图说明了这一点(原图位于 notes/pics/c9cfd600-bc91-4f3a-9f99-b42f88a5bb24.jpg):

CS-Notes 装饰模式示例:DarkRoast 被 Mocha 包裹、再被 Whip 包裹的叠加形态与 cost() 链式调用

其本质是一个洋葱式的多层包裹结构

  • 最内层是 DarkRoast 具体组件,直接返回基础价格;
  • 中间层 Mocha 装饰者包住 DarkRoast,其 cost() 先算出被包裹者的价格,再加上自己代表的价格;
  • 最外层 Whip 装饰者再包住 Mocha,同样执行"自己的价格 + 内层价格"。

因此当最外层调用 cost() 时,会发生自外向内、逐层委托的递归调用链:

Whip.cost()
  └─> Mocha.cost()
        └─> DarkRoast.cost()   // 最底层,返回基础价 1.0

每一层装饰者只关心"自己这一层要加什么",并且通过 beverage.cost() 把请求继续向内传递。这样:

  • 添加新配料 = 新增一个装饰者类并套上去,不必改动任何已有类
  • 任意多种配料可以任意组合、任意排序,组合数是指数级扩展的,而类的数量只需线性增加。

这正是"装饰"一词的由来——把装饰者像套壳一样套在被装饰者之上。

四、可运行的完整代码实现

原文档给出了一个可编译可运行的完整例子:设计不同种类的饮料,饮料可以动态添加牛奶、摩卡等配料,每增加一种配料价格就累加,最终计算一杯饮料的总价。下面按角色逐一给出代码并补充说明。

4.1 组件接口 Component

public interface Beverage {
    double cost();
}

Beverage 是组件接口,它只声明一个 cost() 方法用于返回价格。所有具体组件和装饰者都实现该接口,从而保证"同源同构、可相互嵌套"。

4.2 具体组件 ConcreteComponent

public class DarkRoast implements Beverage {
    @Override
    public double cost() {
        return 1;
    }
}
public class HouseBlend implements Beverage {
    @Override
    public double cost() {
        return 1;
    }
}

DarkRoastHouseBlend 是两种基础咖啡(具体组件)。它们的 cost() 直接返回一个固定价格(示例中为 1),不依赖任何其它对象——这也决定了它们只能作为装饰层次中的最内层(底层)存在。

4.3 抽象装饰者 Decorator

public abstract class CondimentDecorator implements Beverage {
    protected Beverage beverage;
}

CondimentDecorator 是抽象装饰者,它的关键动作是组合一个 Beverage 类型的引用 protected Beverage beverage;。这个引用在运行时指向被它包裹的对象(可能是具体组件,也可能是另一层装饰者)。之所以定义为 protected,是为了让子类(具体装饰者)能够在构造时接收并保存被装饰对象。

抽象装饰者自身不实现 cost(),具体加价逻辑由各个具体装饰者自行决定。

4.4 具体装饰者 ConcreteDecorator

public class Milk extends CondimentDecorator {

    public Milk(Beverage beverage) {
        this.beverage = beverage;
    }

    @Override
    public double cost() {
        return 1 + beverage.cost();
    }
}
public class Mocha extends CondimentDecorator {

    public Mocha(Beverage beverage) {
        this.beverage = beverage;
    }

    @Override
    public double cost() {
        return 1 + beverage.cost();
    }
}

Milk 为例,它的工作分两步:

  1. 构造器接收被装饰对象public Milk(Beverage beverage) 把外层传入的对象保存到继承来的 beverage 字段中,完成"包裹"动作;
  2. cost() 先委托后叠加return 1 + beverage.cost(); 中的 1 是牛奶自身加价(示例价格),beverage.cost() 递归求出内层对象的价格,两者相加即为整杯饮料价格。

Mocha(摩卡)的结构与 Milk 完全一致。同理,若再加入 Whip(奶泡)、Soy(豆奶)等新配料,只需照此模式新增装饰者类即可,对已有代码零侵入。这也说明:每种具体装饰者"自己的那部分功能"(这里是自身加价)由自己实现,而"被装饰者的功能"通过委托调用保留了下来。

4.5 客户端组装

public class Client {

    public static void main(String[] args) {
        Beverage beverage = new HouseBlend();
        beverage = new Mocha(beverage);
        beverage = new Milk(beverage);
        System.out.println(beverage.cost());
    }
}
3.0

客户端展示了装饰模式的经典用法——多步包裹式赋值

  1. 先创建最底层组件 HouseBlend(基础价 1.0);
  2. Mocha 包一层(+1.0,累计 2.0);
  3. 再用 Milk 包一层(+1.0,累计 3.0)。

最终输出 3.0。全程变量 beverage 的静态类型始终是 Beverage 接口,但运行时对象却从"一杯美式"逐步演化为"一杯加了摩卡和牛奶的美式"——功能在运行时被动态叠加,而客户端代码无需感知任何内部细节,这正是装饰模式对调用方透明性的体现。

如果希望更贴近真实场景,还可以把所有配料价格做成可配置的(例如把魔法数字 1 提取为构造参数或常量),让 cost() 中的自身加价部分也随实例变化——装饰模式的框架不会因此有任何改动。

五、模式背后的设计原则:开闭原则

装饰模式之所以值得学习,不仅仅因为它的结构精巧,更因为它践行了面向对象设计中最重要的原则之一。原文档明确指出:

类应该对扩展开放,对修改关闭:也就是添加新功能时不需要修改代码。饮料可以动态添加新的配料,而不需要去修改饮料的代码。

把这句话落实到"饮料加配料"案例中就是:

  • 对扩展开放:想要新增一种配料(如奶泡 Whip),只需新写一个 CondimentDecorator 子类,然后在客户端"套一层"即可;
  • 对修改关闭BeverageDarkRoastHouseBlend 以及已存在的所有装饰者类一律不需要改动

同时,原文档也提醒了一个重要的务实观点:

不可能把所有的类设计成都满足这一原则,应当把该原则应用于最有可能发生改变的地方。

也就是说,开闭原则不是"银弹",不应盲目地为每个类都套上装饰层;它应当被用在需求最可能变化、扩展最频繁的边界上(比如这里的配料种类),否则反而会引入不必要的复杂度。

5.1 装饰模式与继承、类爆炸

通过本案例可以直观体会到装饰模式相比"为每种组合写一个子类"的优势。若采用纯继承方案,一杯咖啡加牛奶、摩卡、奶泡……有多少种配料组合,就要定义多少个"固定配方类"(如 HouseBlendWithMochaAndMilk),类数量随配料种类呈组合爆炸式增长,维护成本极高。而装饰模式将"配方组合"从类的定义中解放出来,交由运行时自由组装,只保留少数几个装饰者类即可覆盖全部组合。这也是该模式在现代框架与类库中被广泛采用的根本原因。

六、JDK 中的装饰模式实例

原文档在末尾给出了装饰模式在 JDK 中的典型落地清单,这些是面试与源码阅读中最高频的例子:

  • java.io.BufferedInputStream(InputStream)
  • java.io.DataInputStream(InputStream)
  • java.io.BufferedOutputStream(OutputStream)
  • java.util.zip.ZipOutputStream(OutputStream)
  • java.util.Collections#checked[List|Map|Set|SortedSet|SortedMap]()

下面展开说明其中最具代表性的两组,帮助你把抽象的模式映射到真实的 JDK 源码结构上。

6.1 Java I/O:装饰模式最经典的应用

java.io 包整体就是按照装饰模式组织的。以字节输入流 InputStream 为例,本仓库的 notes/Java IO.md("装饰者模式"小节)对此有专门梳理:

  • FileInputStream 等属于具体组件,提供最基础的从文件等数据源读取字节的能力;
  • FilterInputStream 属于抽象装饰者,它持有被包装的 InputStream 引用,是所有装饰流的共同父类;
  • BufferedInputStreamDataInputStream 等属于具体装饰者,在转发底层读取请求的同时附加各自的功能。

例如,要为文件流加上缓冲功能,只需要"套一层"装饰者即可:

BufferedInputStream bufferedInputStream = new BufferedInputStream(fileInputStream);
  • BufferedInputStreamFileInputStream 提供缓存能力,减少磁盘 I/O 次数;
  • DataInputStream 装饰者则提供了对 intdouble更多数据类型的读取能力。

在 notes/Java IO.md 中还可以看到 Reader 一侧的同样组织方式——BufferedReader 通过组合 Reader 对象来叠加缓冲读取能力。这种"外层加壳"的写法与本文饮料案例中 new Milk(new Mocha(new HouseBlend())) 的组装方式完全同构,是验证装饰模式价值的最佳现实教材。

6.2 集合包装视图:Collections.checked*

java.util.Collections 提供的 checkedListcheckedMapcheckedSetcheckedSortedSetcheckedSortedMap 等静态方法,会返回一个包装了原集合的"类型安全视图"。这个包装集合将原集合"包裹"在内部,在每次写入元素时进行类型校验,一旦发现类型不匹配立即抛出 ClassCastException,从而在调试期尽早暴露泛型擦除带来的隐患。它同样符合"装饰者实现组件接口 + 组合原始组件 + 追加校验功能"的结构特征。

顺带一提,Collections 中的 synchronizedListunmodifiableList 等视图本质上也是同一类"包装—转发—附加功能"思想的不同体现,只是有些实现以静态包装器(相当于简单工厂)的形式对外暴露。

七、应用价值与使用注意

7.1 适用场景

综合原文档的意图、案例与 JDK 实例,装饰模式尤其适合以下场景:

  • 需要透明且动态地给单个对象添加职责,且不希望影响同类的其它对象;
  • 需要撤销某类扩展(不套该层装饰者即可);
  • 当采用继承会产生大量子类、导致"类爆炸"时,用组合替代继承;
  • 需要以可叠加、可排序的方式组合多种扩展能力(如 I/O 流的层层过滤、中间件管道)。

7.2 需要注意的代价

  • 对象数量增多、结构变深:每一层装饰都是一个对象,层层包裹会带来一定内存开销,且调试时调用栈较深、排错成本上升(这也是 Java I/O 流被诟病"冗长"的原因之一);
  • 顺序敏感性:部分装饰者对调用顺序敏感(例如先压缩还是先加密),需要在使用层面约定清楚,模式本身不保证任意顺序都合理;
  • 类型识别的困难:如果代码依赖具体类型(而非接口)做判断,装饰后对象的真实类型会"失真",这一点在使用装饰模式设计时要尽量避免。

八、小结

装饰模式用"继承保类型、组合加功能"的巧妙结构,实现了对单个对象运行时、动态、可叠加的功能扩展,并天然契合开闭原则。通过 CS-Notes 中"饮料 + 配料"的完整案例,你可以一次性掌握它的类图角色、递归调用模型与可运行代码;而 java.iojava.util.Collections 等 JDK 实现则证明了这一模式在工业级代码中的真实价值。建议结合本仓库的 设计模式目录 与 Java IO 装饰者小节 进行横向复习,把"模式词汇"沉淀为自己的设计直觉。

本文依据 notes/设计模式 - 装饰.md 编写,完整模式内容与配套代码亦汇总于 notes/设计模式.md

登录后查看全文
热门项目推荐
相关项目推荐