设计模式预计阅读 20 分钟7847 字1003 次浏览

设计模式修炼之路 ⑩ 装饰器模式——不改原类,动态加功能

不改原类动态加功能、"我是组件+我有组件"、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,数量限一长成串——所谓"只准养一个孩子的组合模式",就是从这个字段类型来的。

二、装饰器模式的优缺点

优点

  1. 组合替代继承,彻底躲开子类爆炸:继承是编译期焊死的静态关系,加一种配料就得多一层类、多一种组合就多一个类;装饰是运行期自由组装的动态关系,n 种配料任意组合、任意顺序、甚至重复包装(双份奶),类的数量却纹丝不动——下面看事故现场。
  2. 职责拆得干净:每个装饰器只干一件事,Milk 只管加牛奶,BufferedInputStream 只管缓冲——单一职责原则的模范生。
  3. 对扩展开放、对修改关闭:新增一种配料 = 新写一个装饰器类,SimpleCoffee、Milk、Mocha 全都不用动,已上线的调用链更不用动——开闭原则。
  4. 可拆卸:不包就是裸对象,包几层就有几层功能,同一套类支持"裸奔"到"满配"的全部档位。

缺点

  1. 类数量依然不少:虽然躲开了指数爆炸,但每种装饰还是一个类,配料多了之后"小类满天飞"。
  2. 调用栈深、排障绕:new Sugar(new Milk(new Milk(...))) 出了问题,异常栈和调试断点都要一层层剥洋葱,读代码的人也得在脑子里手动解套娃。
  3. 装饰顺序敏感:本篇算价格,加法交换律掩盖了顺序问题;但换成"压缩+加密",先压缩再加密和先加密再压缩,产出的东西完全不同——顺序的自由是能力,也是坑。
  4. 初始化代码可读性差:不熟悉的读者看到一长串 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元

能跑,但雷已经埋下:

  1. 类数量指数级爆炸:3 种配料全组合是 2³ = 8 个子类,每上新一种配料,子类数量直接翻倍——现实咖啡店菜单上几十种配料,这模型写出来是个天文数字;
  2. 继承链还在误导人:MilkSugarCoffee extends MilkCoffee 看着像"加糖咖啡是一种加奶咖啡",逻辑上根本不成立——继承表达的是 is-a,这里硬凑的是 has-then-add;
  3. 组合写死在类型里:"加奶加糖"和"加糖加奶"是两个不同的类,可它俩本该是一杯咖啡;运行时想换配料?编译期就定死了,换不了。

病根只有一个:把"运行时的配料组合"翻译成了"编译期的类型层级"。

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 个类覆盖全部组合,还白送两项继承永远给不了的能力:

  1. 增强对来源无感:BufferedInputStream 根本不关心手里那杯是文件还是网络,它只管加缓冲——写一个类,所有来源全家受益;
  2. 增强可以叠增强:new DataInputStream(new BufferedInputStream(new FileInputStream(file))),先缓冲再按类型读,套娃完全合法,运行时想怎么搭就怎么搭。

这套设计 1996 年落地,JDK 沿用至今没动过骨架——模式选对了,二十多年不过时。最后补一句它的边界:IO 里的 FileInputStream 是"另一种来源"(新物种,所以是具体组件),BufferedInputStream 是"一种增强"(职责,所以是装饰器)——分清"来源"和"增强",是决定该开新类还是该包一层的第一判据。

写在最后

回顾这一篇:装饰器模式用"我是组件 + 我有组件"四个字给对象动态加职责——抽象装饰器实现接口并持有一个组件,具体装饰器各加一味料,运行时套娃组装;反面教材"一种组合一个子类"的 2ⁿ 爆炸,在装饰器面前只剩下 1 个抽象装饰器 + n 个配料类;Collections 的 unmodifiableList、Servlet 的 Wrapper、MyBatis 的 Cache 装饰器链、Java IO 全家族,都是它的地盘;而"来源开新类、增强包一层",是它留下的第一判据。

下一篇 ⑪ 外观模式,结构型第六位,也是 GoF 里最"厚道"的一个:不整虚的,就是把你不想伺候的一大堆对象收拢成一个门面——子系统该多乱还多乱,客户端只见一个入口、一个方法、一句话办完。设计模式里难得的"降复杂度不涨姿势",程序员和甲方都爱的那一款。

花

花开空白

开发者 · 西安

相关文章

评论(0)

还没有评论,来抢沙发吧

发表评论