编码 · 设计模式
装饰器模式详解与示例
简短回答
装饰器是一种结构型设计模式,它把对象包装在另一个具有相同接口的对象里,从而动态地为该对象增加职责。每个装饰器把调用转发给被包装的对象,并在前后加上自己的行为,例如日志、缓存或压缩。由于装饰器可以按任意顺序叠加,它是为每种功能组合都创建子类的一种灵活替代方案。
意图
动态地给一个对象附加额外的职责。在扩展功能方面,装饰器比子类化更灵活。装饰器是《设计模式》(四人组,GoF)一书中七种结构型模式之一,书中也称其为包装器(Wrapper)。
它要解决的问题
设想一个带有 get(key) 方法的数据存储。有些调用方想要日志,有些想要内存缓存,有些两者都要,还有些希望对慢速的网络后端进行重试。用继承的话,你得写出 LoggingStore、CachingStore、LoggingCachingStore、RetryingCachingStore 等等:每种组合一个子类,而且组合在编译时就固定了。
装饰器把每项功能变成一个小的包装类。你在运行时通过把一个对象包进另一个对象来构建所需的组合,而使用这个存储的代码,无论有多少层,看到的都是同一个接口。
结构与参与者
| 参与者 | 职责 | 示例中的对应 |
|---|---|---|
| 组件(Component) | 真实对象与装饰器共用的接口。 | Store |
| 具体组件(ConcreteComponent) | 完成核心工作的原始对象。 | SlowStore |
| 装饰器(Decorator) | 持有一个组件的引用,并通过转发来实现同一个接口。 | StoreDecorator |
| 具体装饰器(ConcreteDecorator) | 在转发前后添加行为。 | LoggingStore, CachingStore |
TypeScript 实现
interface Store {
get(key: string): string;
}
class SlowStore implements Store {
get(key: string): string { console.log(` (database read: ${key})`); return key.toUpperCase(); }
}
// Base decorator: forwards everything to the wrapped store.
abstract class StoreDecorator implements Store {
constructor(protected inner: Store) {}
get(key: string): string { return this.inner.get(key); }
}
class LoggingStore extends StoreDecorator {
get(key: string): string {
console.log(`get ${key}`);
return super.get(key);
}
}
class CachingStore extends StoreDecorator {
private cache = new Map<string, string>();
get(key: string): string {
if (!this.cache.has(key)) this.cache.set(key, super.get(key));
return this.cache.get(key)!;
}
}
// Stack features in any order at run time.
const store: Store = new LoggingStore(new CachingStore(new SlowStore()));
console.log(store.get("ada"));
console.log(store.get("ada"));
// get ada
// (database read: ada)
// ADA
// get ada
// ADA第二次调用会被记录日志,但答案来自缓存,所以不会再次读取数据库。如果交换顺序,写成 new CachingStore(new LoggingStore(...)),则只有缓存未命中才会被记录。装饰器的顺序会改变行为,这既是一个特性,也是常见的困惑来源。
TypeScript 中的函数装饰器
当组件只是一个函数时,装饰器可以是一个高阶函数:它接收一个函数,返回一个签名相同、带有额外行为的新函数。
function withTiming<A extends unknown[], R>(name: string, fn: (...args: A) => R) {
return (...args: A): R => {
const start = performance.now();
try {
return fn(...args);
} finally {
console.log(`${name} took ${(performance.now() - start).toFixed(1)} ms`);
}
};
}
const slowSquare = (n: number) => { for (let i = 0; i < 1e6; i++); return n * n; };
const timedSquare = withTiming("square", slowSquare);
console.log(timedSquare(12)); // square took ... ms, then 144何时使用,何时不用
以下情况适合使用装饰器:
- 你想为单个对象添加日志、缓存、重试、校验、压缩或访问检查等功能,而不影响同一个类的其他对象。
- 功能需要自由组合,而用子类化会为每种组合产生一个类。
- 你不能或不应该修改原来的类,比如它来自某个库。
以下情况应避免使用:
- 你只需要一种固定的额外行为。直接加上,或者用一个子类,更简单。
- 代码依赖对象的身份或具体类。被装饰的对象是另一个对象,因此
instanceof SlowStore或===比较这类检查会失败。 - 过多的小层级会让调试变得困难。很深的包装栈会产生很长的调用栈,让人搞不清是哪一层做了什么。
Web 框架(如 Express 或 Koa)中的中间件是相关的思路:每一层接收请求,可以对它进行处理,然后传递下去。
实际应用
- Java I/O:
new BufferedInputStream(new FileInputStream("data.bin"))把一个流包装进另一个流,由后者增加缓冲功能,两者都具有InputStream接口。GoF 一书本身就把流当作典型例子来讨论。 - Java 集合:
Collections.unmodifiableList(list)和Collections.synchronizedList(list)返回的包装器,会在现有列表外面加上只读行为或加锁。 - Python:
functools.lru_cache这样的函数装饰器会包装一个函数并加上缓存,而functools.wraps能帮助包装函数保留被包装函数的名称和文档字符串。
相关模式
| 模式 | 与装饰器的区别 |
|---|---|
| 适配器 | 改变对象的接口。装饰器保持接口不变并增加行为。 |
| 代理 | 结构相同但目的不同:它控制访问(懒创建、远程访问、权限),并且通常自己管理真实对象的生命周期。 |
| 组合 | 同样使用递归组合,但组合体持有多个子节点;装饰器恰好只包装一个。 |
| 策略 | 通过替换算法来改变对象的内部。装饰器则是通过包装来改变对象的外部。 |
| 责任链 | 同样让请求经过一系列对象,但每个处理者都可以终止请求;装饰器通常总是会继续转发。 |
常见问题
装饰器与继承有什么区别?
继承在编译时为整个类添加行为。装饰器在运行时为单个对象添加行为,并且可以按任意顺序组合多个装饰器。
装饰器与代理有什么区别?
它们的形状相同。装饰器添加的功能由客户端选择并叠加,而代理控制对真实对象的访问,常常负责创建它,或者决定是否真的要调用它。
TypeScript 的 @decorator 就是装饰器模式吗?
不完全是。@ 装饰器是一种语言特性,在类及其成员被定义时对它们进行修改。它可以用来实现包装,但 GoF 模式讲的是在运行时包装对象。
装饰器的顺序重要吗?
重要。每个装饰器都会先于它所包装的对象看到调用,所以放在日志外层的缓存会向日志隐藏重复的调用,而放在缓存外层的日志则能看到每一次调用。