设计模式修炼之路 ⑩ 装饰器模式——不改原类,动态加功能
不改原类动态加功能、"我是组件+我有组件"、Java IO 流的套娃设计
目录
上一篇组合模式的结尾给它留了一句判词:"UML 画出来,几乎是只准养一个孩子的组合模式"。今天这个"独生子"正式登场:装饰器模式(Decorator Pattern),GoF 23 人名单正牌选手、结构型第五位,有的书也叫它"包装模式(Wrapper)"。组合模式的容器里装的是一整个 List<FileComponent>,孩子越多树越大;装饰器的"容器"里只准装一个对象,但它自己同时还是组件——于是这棵树不许分叉,只许长个儿:一杯咖啡包一层、再加一层,功能就这么一层层叠上去。它回答的问题直白又高频:想给一个类加功能,但不想改它的代码、也不想为每种组合造一个子类,怎么办?
一、什么是装饰器模式?
先看定义:
动态地给一个对象添加一些额外的职责。就增加功能来说,装饰器模式比生成子类更为灵活。
翻译成人话:不改原类、不碰继承,把"要加的功能"做成一个个小包装类——包装类和原对象实现同一个接口,手里攥着原对象,调用时先干自己的活、再把剩下的活转交给手里那位。要加什么料,运行时套几层、按什么顺序套,全在 new 的时候现场决定。
生活类比是给手机贴膜:裸机是对象,钢化膜、防窥膜、磨砂膜是装饰器——贴完膜它还是一部手机(接口没变),但抗摔、防窥、不沾指纹这些职责已经加上去了;贴三层就是三层的效果,撕掉一层就少一层功能,手机本身一颗螺丝没动。
它有四个角色:
- Component(组件接口):本篇里是 Coffee,规定"一杯咖啡"能干什么;
- ConcreteComponent(具体组件):被装饰的原始对象,本篇里是 SimpleCoffee——一杯什么都不加的裸咖啡;
- Decorator(抽象装饰器):实现组件接口,同时持有一个组件类型的字段,本篇里是 CondimentDecorator;
- ConcreteDecorator(具体装饰器):每种"配料"一个类,本篇里是 Milk、Sugar、Mocha。
精髓全在抽象装饰器身上,一句话说透:它自己"是"组件,手里又"有"组件。是组件,所以它能出现在任何需要组件的地方——包括另一个装饰器的构造器里,于是装饰器可以套装饰器;有组件,所以它的方法实现基本是"干自己的事 + 转发给手里那位"。上一篇 Folder 的孩子是 List<FileComponent>,数量不限长成树;这一篇 CondimentDecorator 的孩子是一个 Coffee,数量限一长成串——所谓"只准养一个孩子的组合模式",就是从这个字段类型来的。
二、装饰器模式的优缺点
优点
- 组合替代继承,彻底躲开子类爆炸:继承是编译期焊死的静态关系,加一种配料就得多一层类、多一种组合就多一个类;装饰是运行期自由组装的动态关系,n 种配料任意组合、任意顺序、甚至重复包装(双份奶),类的数量却纹丝不动——下面看事故现场。
- 职责拆得干净:每个装饰器只干一件事,Milk 只管加牛奶,BufferedInputStream 只管缓冲——单一职责原则的模范生。
- 对扩展开放、对修改关闭:新增一种配料 = 新写一个装饰器类,SimpleCoffee、Milk、Mocha 全都不用动,已上线的调用链更不用动——开闭原则。
- 可拆卸:不包就是裸对象,包几层就有几层功能,同一套类支持"裸奔"到"满配"的全部档位。
缺点
- 类数量依然不少:虽然躲开了指数爆炸,但每种装饰还是一个类,配料多了之后"小类满天飞"。
- 调用栈深、排障绕:
new Sugar(new Milk(new Milk(...)))出了问题,异常栈和调试断点都要一层层剥洋葱,读代码的人也得在脑子里手动解套娃。 - 装饰顺序敏感:本篇算价格,加法交换律掩盖了顺序问题;但换成"压缩+加密",先压缩再加密和先加密再压缩,产出的东西完全不同——顺序的自由是能力,也是坑。
- 初始化代码可读性差:不熟悉的读者看到一长串
new X(new Y(new Z(...))),第一反应多半是"这是在干嘛"。
三、装饰器模式的应用场景
- Java IO 流(JDK):全宇宙最著名的装饰器家族,InputStream/OutputStream/Reader/Writer 四大家族全按这个模式搭的,第五节专门解剖。
- Collections 工具方法(JDK):
Collections.unmodifiableList(list)返回的其实是一个包着原 list 的装饰器——"只读"是它加的职责,原 list 一颗螺丝没动;synchronizedList()、checkedList()同理,一个方法一种职责,想叠加就再包一层。 - Servlet 的 Request/ResponseWrapper:HttpServletRequestWrapper 包着原生 request,想改写参数、伪造头部、透传额外属性时,包一层 Wrapper 扔给下游,下游毫不知情——Spring MVC、Spring Security 内部大量使用。
- MyBatis 的 Cache:Cache 接口下,PerpetualCache 是裸缓存(具体组件),LruCache、FifoCache、LoggingCache、SynchronizedCache、TransactionalCache……全是装饰器。CachingExecutor 组装缓存时按配置把装饰器一层层套上去,"容量淘汰 + 日志 + 事务感知"全靠运行时点单——本系列第四次在 MyBatis 里撞见老朋友了。
- Spring 的 TransactionAwareCacheDecorator:类名直接把模式写脸上了,给任意 Cache 实现"感知 Spring 事务"的职责。
什么时候该用:功能是"可叠加的增强"而不是"新物种"(缓冲之于字节流,是增强;文件之于字节流,是来源);增强方式存在组合的可能;或者原类是别人的、你压根无权改——三条中两条,就该想到它。
四、装饰器模式的实现方式
示例代码在 design-pattern 项目的 decorator 包里:
design-pattern/src/main/java
└── decorator // 装饰器模式
├── PlainCoffee.java // 反面教材:裸咖啡,等着被子类爆炸
├── MilkCoffee.java // 反面教材:加奶 = 继承一个子类
├── MilkSugarCoffee.java // 反面教材:再加糖 = 再继承一个子类
├── Coffee.java // 组件接口:一杯咖啡能干什么
├── SimpleCoffee.java // 具体组件:什么都不加的裸咖啡
├── CondimentDecorator.java // 抽象装饰器:持有一杯咖啡,自己也还是一杯咖啡
├── Milk.java // 具体装饰器:牛奶
├── Sugar.java // 具体装饰器:糖
├── Mocha.java // 具体装饰器:摩卡
└── DecoratorTest.java // 测试入口
场景:咖啡店点单。一杯裸咖啡 15 元,加牛奶 3 元、加糖 2 元、加摩卡 5 元,描述和价格都要随配料变。先看"一种组合一个子类"的下场,再看装饰器怎么救。
1. 反面教材:继承爆炸
最直觉的写法:要什么组合,就继承出什么子类:
// 反面教材:普通咖啡,等着被子类爆炸
public class PlainCoffee {
public String getDescription() {
return "普通咖啡";
}
public double cost() {
return 15.0;
}
}
// 加奶 = 继承一个子类
public class MilkCoffee extends PlainCoffee {
@Override
public String getDescription() {
return super.getDescription() + "+牛奶";
}
@Override
public double cost() {
return super.cost() + 3.0;
}
}
// 再加糖 = 再继承一个子类
public class MilkSugarCoffee extends MilkCoffee {
@Override
public String getDescription() {
return super.getDescription() + "+加糖";
}
@Override
public double cost() {
return super.cost() + 2.0;
}
}
测试代码,注释里就是判决书:
PlainCoffee plain = new PlainCoffee();
System.out.println(plain.getDescription() + ":" + plain.cost() + "元");
MilkCoffee milk = new MilkCoffee();
System.out.println(milk.getDescription() + ":" + milk.cost() + "元");
MilkSugarCoffee milkSugar = new MilkSugarCoffee();
System.out.println(milkSugar.getDescription() + ":" + milkSugar.cost() + "元");
// 只想加糖不加奶?得再造 SugarCoffee
// 想要双份奶?得再造 DoubleMilkCoffee
// 三种配料任意组合就是 2 的 3 次方 = 8 个类,每多一种配料类数量翻一倍
运行结果:
===== 反面教材:继承爆炸 =====
普通咖啡:15.0元
普通咖啡+牛奶:18.0元
普通咖啡+牛奶+加糖:20.0元
能跑,但雷已经埋下:
- 类数量指数级爆炸:3 种配料全组合是 2³ = 8 个子类,每上新一种配料,子类数量直接翻倍——现实咖啡店菜单上几十种配料,这模型写出来是个天文数字;
- 继承链还在误导人:MilkSugarCoffee extends MilkCoffee 看着像"加糖咖啡是一种加奶咖啡",逻辑上根本不成立——继承表达的是 is-a,这里硬凑的是 has-then-add;
- 组合写死在类型里:"加奶加糖"和"加糖加奶"是两个不同的类,可它俩本该是一杯咖啡;运行时想换配料?编译期就定死了,换不了。
病根只有一个:把"运行时的配料组合"翻译成了"编译期的类型层级"。
2. 装饰器版:一个接口,一层包装
先立公共抽象——所有角色唯一的共同身份:
// 组件接口:裸咖啡和所有"加了料的咖啡"共同的身份
public interface Coffee {
String getDescription();
double cost();
}
具体组件,一杯裸咖啡:
// 具体组件:什么都不加的裸咖啡
public class SimpleCoffee implements Coffee {
@Override
public String getDescription() {
return "普通咖啡";
}
@Override
public double cost() {
return 15.0;
}
}
重头戏是抽象装饰器,全模式的心脏:
// 抽象装饰器:持有一杯咖啡,自己也还是一杯咖啡——容量被限制为 1 的组合模式
public abstract class CondimentDecorator implements Coffee {
protected final Coffee coffee;
protected CondimentDecorator(Coffee coffee) {
this.coffee = coffee;
}
}
四行代码,两个字都占全了:implements Coffee 是"我是",protected final Coffee coffee 是"我有"。每个具体装饰器只干一件事——"在手里那杯的基础上,加上自己这味料":
// 具体装饰器:牛奶,在自己包裹的那杯基础上加价
public class Milk extends CondimentDecorator {
public Milk(Coffee coffee) {
super(coffee);
}
@Override
public String getDescription() {
return coffee.getDescription() + "+牛奶";
}
@Override
public double cost() {
return coffee.cost() + 3.0;
}
}
// Mocha 同理:getDescription() 返回 coffee.getDescription() + "+摩卡",cost() 返回 coffee.cost() + 5.0
// Sugar 同理:+加糖,+2.0,完整代码见项目
然后是本篇的招牌镜头——点单全在运行时组装,配方要几层套几层:
Coffee order1 = new SimpleCoffee(); // 什么都不加
System.out.println(order1.getDescription() + ":" + order1.cost() + "元");
Coffee order2 = new Milk(new SimpleCoffee()); // 加奶
System.out.println(order2.getDescription() + ":" + order2.cost() + "元");
Coffee order3 = new Mocha(new Milk(new SimpleCoffee())); // 加奶加摩卡
System.out.println(order3.getDescription() + ":" + order3.cost() + "元");
// 双份奶不用新类,再包一层就行;配料全在运行时按单组装
Coffee order4 = new Sugar(new Milk(new Milk(new SimpleCoffee()))); // 双份奶加糖
System.out.println(order4.getDescription() + ":" + order4.cost() + "元");
运行结果:
===== 正解:装饰器模式 =====
普通咖啡:15.0元
普通咖啡+牛奶:18.0元
普通咖啡+牛奶+摩卡:23.0元
普通咖啡+牛奶+牛奶+加糖:23.0元
对照反面教材逐条验收:
- 反面教材 2³ = 8 个子类才能覆盖的组合,正解 4 个装饰器类(含抽象装饰器)覆盖了全部组合、全部顺序、外加任意重复包装——order4 的双份奶,继承方案里还得再造一个 DoubleMilkCoffee;
- MilkCoffee 里"is-a 硬凑 has-then-add"的别扭没了:Milk 不是一种咖啡,是一层"往咖啡里加牛奶"的包装;
- order4 调 cost() 时的链路值得盯一眼:Sugar 问手里的 Milk,Milk 问手里的 Milk,内层 Milk 问手里的 SimpleCoffee——15 一路加 3、加 3、加 2 报回 23,描述同理一层层拼回来。每个对象只干自己那一小段,剩下的转发,这正是"装饰"二字的工作方式,也是上一篇"递归不用发明"的翻版——串是类型的自然结果;
- SimpleCoffee 一行没改,Milk、Mocha 也一行没改,明天上新"香草糖浆"?写一个 Vanilla 类就完事——开闭原则;
- 一个细节:order1 到 order4 全部声明为
Coffee类型——客户端从头到尾只认组件接口,它根本不知道(也不需要知道)手里这杯包了几层。
五、Java IO 流:装饰器的封神之作
理论讲完,去看这个模式在 JDK 里最著名的落地——Java IO 流。当年多少人对着 new BufferedInputStream(new FileInputStream(file)) 这行代码发懵,学完本篇再看,每个角色都能对号入座:
// 抽象组件:所有字节输入流的共同身份
public abstract class InputStream implements Closeable {
public abstract int read() throws IOException;
// 其余方法略
}
// 抽象装饰器:和 CondimentDecorator 一个模子刻出来的
public class FilterInputStream extends InputStream {
protected volatile InputStream in; // 手里攥着的那个"原咖啡"
protected FilterInputStream(InputStream in) {
this.in = in;
}
public int read() throws IOException {
return in.read(); // 默认实现:纯转发
}
}
// 具体装饰器:缓冲,就是 IO 界的"牛奶"
public class BufferedInputStream extends FilterInputStream {
// 内部一个缓冲数组,read 时先看缓冲区,空了才让 in 去真正读盘
public synchronized int read() throws IOException { /* 略 */ }
}
对照表直接抄:
| 咖啡店 | Java IO |
|---|---|
| Coffee | InputStream |
| SimpleCoffee | FileInputStream、ByteArrayInputStream(裸流) |
| CondimentDecorator | FilterInputStream |
| Milk、Mocha、Sugar | BufferedInputStream、DataInputStream、PushbackInputStream |
| new Milk(new SimpleCoffee()) | new BufferedInputStream(new FileInputStream(file)) |
为什么 IO 非这么设计不可?算笔账就明白。字节流的来源有三种:文件、字节数组、网络;增强也有三种:缓冲、按基本类型读、支持回退。如果靠继承组合,3 × 3 = 9 个类,每加一种增强,类数量翻一倍——和咖啡店的配料是同一道数学题。而装饰器方案:3 个来源类 + 3 个增强类 + 1 个抽象装饰器,7 个类覆盖全部组合,还白送两项继承永远给不了的能力:
- 增强对来源无感:BufferedInputStream 根本不关心手里那杯是文件还是网络,它只管加缓冲——写一个类,所有来源全家受益;
- 增强可以叠增强:
new DataInputStream(new BufferedInputStream(new FileInputStream(file))),先缓冲再按类型读,套娃完全合法,运行时想怎么搭就怎么搭。
这套设计 1996 年落地,JDK 沿用至今没动过骨架——模式选对了,二十多年不过时。最后补一句它的边界:IO 里的 FileInputStream 是"另一种来源"(新物种,所以是具体组件),BufferedInputStream 是"一种增强"(职责,所以是装饰器)——分清"来源"和"增强",是决定该开新类还是该包一层的第一判据。
写在最后
回顾这一篇:装饰器模式用"我是组件 + 我有组件"四个字给对象动态加职责——抽象装饰器实现接口并持有一个组件,具体装饰器各加一味料,运行时套娃组装;反面教材"一种组合一个子类"的 2ⁿ 爆炸,在装饰器面前只剩下 1 个抽象装饰器 + n 个配料类;Collections 的 unmodifiableList、Servlet 的 Wrapper、MyBatis 的 Cache 装饰器链、Java IO 全家族,都是它的地盘;而"来源开新类、增强包一层",是它留下的第一判据。
下一篇 ⑪ 外观模式,结构型第六位,也是 GoF 里最"厚道"的一个:不整虚的,就是把你不想伺候的一大堆对象收拢成一个门面——子系统该多乱还多乱,客户端只见一个入口、一个方法、一句话办完。设计模式里难得的"降复杂度不涨姿势",程序员和甲方都爱的那一款。