cv.cm

编码 · 设计模式

享元模式详解与示例

阅读约 5 分钟

简短回答

享元是一种结构型设计模式,它通过共享相似对象的公共部分,使程序能够支持数量庞大的相似对象。每个对象的数据被拆分为内部状态(共享、不可变,只在享元中存储一份)和外部状态(每次使用时各不相同,由调用方传入)。工厂会缓存享元,因此相同的内部状态绝不会被存储两次。

意图与它要解决的问题

意图:运用共享技术有效地支持大量细粒度的对象。该模式出自《设计模式》(四人组,GoF)一书,属于结构型模式。

设想一张要绘制一百万棵树的地图。每棵树有位置 (x, y) 和类型:物种名称、颜色,以及几 KB 大小的纹理图像。如果每个树对象都持有自己的一份纹理副本,内存用量会随着树的数量增长,程序可能因此耗尽内存。然而物种其实只有寥寥几种。每棵橡树的纹理、名称和颜色都是一样的。

享元把重复的数据移到少数几个共享对象中。一棵树就只需保存它的位置,加上对共享树类型的引用。一百万棵树、五个物种,只需要五份纹理,而不是一百万份。

内部状态与外部状态

关键的一步,是把每个对象的数据分成两类:

状态含义树的示例
内部状态与上下文无关,许多对象都相同,存放在享元内部且永不改变物种名称、颜色、纹理
外部状态取决于上下文,每次使用都不同,由客户端保管并传入享元的方法位置 x 和 y

因为享元是共享的,所以它必须是不可变的。如果某个客户端能改动共享的橡树类型的颜色,地图上的每一棵橡树都会随之改变。

结构与参与者

角色职责
享元(Flyweight)保存内部状态,并提供以外部状态为参数的操作
享元工厂创建并缓存享元;再次请求相同的内部状态时返回已有的享元
上下文(Context)保存外部状态以及对享元的引用(每个现实中的对象对应一个上下文)
客户端(Client)向工厂请求享元,并保管或计算外部状态

TypeScript 实现

工厂使用以内部状态的值为键的 Map。TreeType 是享元,Tree 是上下文,Forest 是客户端。

class TreeType {
  constructor(
    readonly name: string,
    readonly color: string,
    readonly texture: Uint8Array, // large, shared data
  ) {}
  draw(x: number, y: number): string {
    return this.name + " (" + this.color + ") at " + x + "," + y;
  }
}
class TreeTypeFactory {
  private static cache = new Map<string, TreeType>();
  static get(name: string, color: string): TreeType {
    const key = name + "|" + color;
    let type = this.cache.get(key);
    if (!type) {
      type = new TreeType(name, color, new Uint8Array(4096));
      this.cache.set(key, type);
    }
    return type;
  }
}
class Tree {
  constructor(readonly x: number, readonly y: number, readonly type: TreeType) {}
  draw(): string {
    return this.type.draw(this.x, this.y);
  }
}
const forest: Tree[] = [];
for (let i = 0; i < 100_000; i++) {
  const [name, color] = i % 2 === 0 ? ["Oak", "green"] : ["Birch", "white"];
  forest.push(new Tree(i, i * 2, TreeTypeFactory.get(name, color)));
}
const types = new Set(forest.map((t) => t.type));
console.log(forest.length, "trees,", types.size, "tree types");
console.log(forest[3].draw());
// 100000 trees, 2 tree types
// Birch (white) at 3,6

没有工厂的话,程序会分配 100,000 个纹理缓冲区。有了工厂,只有两个。Tree 对象依然存在,但每个只保存两个数字和一个引用。

何时使用,何时不用

当以下条件全部成立时,适合使用享元:

  • 程序创建了极大量的相似对象。
  • 内存占用是经过测量的实际问题,而不是猜测。
  • 每个对象的大部分状态都可以变成外部状态或共享状态。
  • 程序不依赖对象的身份(两个使用同一享元的上下文可以共享它)。

如果对象数量很少、对象需要各自独立修改共享数据,或者传递外部状态带来的复杂性让代码比节省的内存更难读,就应避免使用。很多时候更简单的办法就够了:把数据存放在并行数组或类型化数组中,或者用 Map 对重复的字符串做驻留(intern)。

取舍:享元用 CPU 时间换取内存。每次调用方法时,可能都得重新计算或查找外部状态。

实际应用

  • Java:Integer.valueOf(int) 对 -128 到 127 的值返回缓存的 Integer 对象,因此对小数字装箱时不会每次都分配新对象。
  • Java:String.intern() 从字符串池中返回字符串的规范共享副本。
  • 文本编辑器和浏览器从共享的字体缓存中渲染字形:屏幕上的每个字符保存自己的位置,而字形的形状按每种字体和字号只存一份。
  • 游戏引擎使用实例化渲染:共享同一个网格和材质,每个副本只发送位置、旋转等逐实例数据。

相关模式

模式与享元的区别
单例单例保证一个类只有一个实例;享元对每种不同的内部状态保留一个实例,因此可以有很多个。
工厂方法享元工厂是带缓存的工厂;工厂方法讲的是让子类选择创建哪个类。
组合组合树的叶子节点常常实现为享元,使重复的叶子共享内存。
原型原型通过复制对象来创建新对象;享元通过共享同一个对象来避免复制。

常见问题

内部状态与外部状态有什么区别?

内部状态是共享的,对许多对象都相同,因此存放在享元内部且永不改变。外部状态每次使用都不同,所以由客户端保管,并传入享元的方法。

享元就是缓存吗?

它用到了缓存,但目的不同。缓存是避免重复计算某个结果,而享元是通过让许多使用者共享一个对象,避免多次存储相同的不可变数据。

为什么享元必须是不可变的?

许多上下文都持有对同一个享元的引用。如果其中一个修改了它,这个变化就会出现在所有共享它的其他上下文中。

享元在 JavaScript 和 TypeScript 中有用吗?

有用,当你要创建数十万个对象时,比如粒子、地图瓦片或网格单元。JavaScript 中的每个对象都有额外开销,所以共享重复的数据可以减少内存占用和垃圾回收的工作量。

全部编码教程