cv.cm

编码 · 设计模式

装饰器模式详解与示例

阅读约 6 分钟

简短回答

装饰器是一种结构型设计模式,它把对象包装在另一个具有相同接口的对象里,从而动态地为该对象增加职责。每个装饰器把调用转发给被包装的对象,并在前后加上自己的行为,例如日志、缓存或压缩。由于装饰器可以按任意顺序叠加,它是为每种功能组合都创建子类的一种灵活替代方案。

意图

动态地给一个对象附加额外的职责。在扩展功能方面,装饰器比子类化更灵活。装饰器是《设计模式》(四人组,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 模式讲的是在运行时包装对象。

装饰器的顺序重要吗?

重要。每个装饰器都会先于它所包装的对象看到调用,所以放在日志外层的缓存会向日志隐藏重复的调用,而放在缓存外层的日志则能看到每一次调用。

全部编码教程