编码 · 设计模式
享元模式详解与示例
简短回答
享元是一种结构型设计模式,它通过共享相似对象的公共部分,使程序能够支持数量庞大的相似对象。每个对象的数据被拆分为内部状态(共享、不可变,只在享元中存储一份)和外部状态(每次使用时各不相同,由调用方传入)。工厂会缓存享元,因此相同的内部状态绝不会被存储两次。
意图与它要解决的问题
意图:运用共享技术有效地支持大量细粒度的对象。该模式出自《设计模式》(四人组,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 中的每个对象都有额外开销,所以共享重复的数据可以减少内存占用和垃圾回收的工作量。