跳转到内容

第 15 章 · 享元(Flyweight)

一句话:大量对象里重复的那部分抽出来做成只读对象,每种只存一份,所有对象共用。 解决的痛:1 万张商品卡片各抄一份同样的分类图标和说明,图标一变大,内存就跟着乘上卡片数,大促时进程被系统杀掉。

多练网开了周边商城,商品分 12 个分类。你负责商品列表页的后端:把查到的商品做成卡片对象,交给模板渲染。

每张卡片有两类信息。一类是商品自己的:商品名、价格、在列表里的位置,张张不同。另一类是分类的:分类名、分类图标(约 2 KB 的小图)、分类说明,同一分类的卡片完全一样。

查询是商品表 join 分类表,每一行都带着分类字段。平时一页几十张;大促会场的列表要一次建好上万张,最多 10 万张。

朴素写法:每张卡片各带一份完整的分类信息

图里 4 张卡片只属于两个分类,却存了 4 份分类信息。

每行查询结果直接变成一个卡片对象,分类字段照抄。代码建 1 万张卡片,打印两样东西:内存里有几份分类信息(数不同的图标对象有几个),以及按“图标字节数 + 说明的 UTF-8 字节数”估算它们占多少。不算对象头和卡片自己的字段,所以两种语言打印的数一样:

from dataclasses import dataclass
ICON_SIZE = 2 * 1024 # 分类图标 2 KB
CATEGORIES = ["编程书", "设计书", "外语书", "键盘", "鼠标", "耳机",
"显示器", "文具", "背包", "水杯", "服饰", "礼品卡"]
@dataclass
class ProductCard:
name: str
price: int
position: int
category_name: str # 下面三个字段是分类信息,同一分类的卡片完全一样
category_icon: bytes
category_desc: str
def render(self) -> str:
return f"#{self.position} {self.name} {self.price} 元 [{self.category_name}] {self.category_desc}"
def load_category(category: str) -> tuple[str, bytes, str]:
# 模拟:商品表 join 分类表,每一行都带着一份分类字段,每次都是新对象
icon = b"\x89PNG" + bytes(ICON_SIZE - 4)
return category, icon, f"{category}:多练网精选,七天无理由退货"
def build_cards(n: int) -> list[ProductCard]:
cards = []
for i in range(n):
name, icon, desc = load_category(CATEGORIES[i % len(CATEGORIES)])
cards.append(ProductCard(f"商品{i}", 39 + i * 7 % 200, i, name, icon, desc))
return cards
def category_bytes(card: ProductCard) -> int: # 估算:图标字节数 + 说明的 UTF-8 字节数
return len(card.category_icon) + len(card.category_desc.encode())
if __name__ == "__main__":
cards = build_cards(10_000)
print(cards[0].render())
print(cards[13].render())
copies = len({id(c.category_icon) for c in cards}) # 数一数有多少个不同的图标对象
total = sum(category_bytes(c) for c in cards)
print(f"卡片 {len(cards):,} 张,分类信息 {copies:,} 份")
print(f"分类信息估算:{total:,} 字节(约 {total / 1024 / 1024:.1f} MB)")
# 输出:
# #0 商品0 39 元 [编程书] 编程书:多练网精选,七天无理由退货
# #13 商品13 130 元 [设计书] 设计书:多练网精选,七天无理由退货
# 卡片 10,000 张,分类信息 10,000 份
# 分类信息估算:20,972,504 字节(约 20.0 MB)

TS 版一样:

const ICON_SIZE = 2 * 1024; // 分类图标 2 KB
const CATEGORIES = ["编程书", "设计书", "外语书", "键盘", "鼠标", "耳机",
"显示器", "文具", "背包", "水杯", "服饰", "礼品卡"];
class ProductCard {
constructor(
readonly name: string,
readonly price: number,
readonly position: number,
readonly categoryName: string, // 下面三个字段是分类信息,同一分类的卡片完全一样
readonly categoryIcon: Uint8Array,
readonly categoryDesc: string,
) {}
render(): string {
return `#${this.position} ${this.name} ${this.price} 元 [${this.categoryName}] ${this.categoryDesc}`;
}
}
function loadCategory(category: string): [string, Uint8Array, string] {
// 模拟:商品表 join 分类表,每一行都带着一份分类字段,每次都是新对象
const icon = new Uint8Array(ICON_SIZE);
icon.set([0x89, 0x50, 0x4e, 0x47]);
return [category, icon, `${category}:多练网精选,七天无理由退货`];
}
function buildCards(n: number): ProductCard[] {
const cards: ProductCard[] = [];
for (let i = 0; i < n; i++) {
const [name, icon, desc] = loadCategory(CATEGORIES[i % CATEGORIES.length]);
cards.push(new ProductCard(`商品${i}`, 39 + (i * 7) % 200, i, name, icon, desc));
}
return cards;
}
function categoryBytes(card: ProductCard): number { // 估算:图标字节数 + 说明的 UTF-8 字节数
return card.categoryIcon.length + Buffer.byteLength(card.categoryDesc);
}
const cards = buildCards(10_000);
console.log(cards[0].render());
console.log(cards[13].render());
const copies = new Set(cards.map((c) => c.categoryIcon)).size; // 数一数有多少个不同的图标对象
const total = cards.reduce((sum, c) => sum + categoryBytes(c), 0);
console.log(`卡片 ${cards.length.toLocaleString("en-US")} 张,分类信息 ${copies.toLocaleString("en-US")} 份`);
console.log(`分类信息估算:${total.toLocaleString("en-US")} 字节(约 ${(total / 1024 / 1024).toFixed(1)} MB)`);
// 输出:
// #0 商品0 39 元 [编程书] 编程书:多练网精选,七天无理由退货
// #13 商品13 130 元 [设计书] 设计书:多练网精选,七天无理由退货
// 卡片 10,000 张,分类信息 10,000 份
// 分类信息估算:20,972,504 字节(约 20.0 MB)

1 万份分类信息约 20 MB,服务器内存够,页面也不慢。大多数人第一版都这么写。

新需求是:分类图标换成 20 KB 的高清图;分类说明要支持中文、英文、日文三种语言。

要改的地方:

  1. load_category:图标从 2 KB 变 20 KB,说明从一段变成三段。这一处躲不掉。
ICON_SIZE = 20 * 1024 # 原来是 2 * 1024
descs = {"zh": ..., "en": ..., "ja": ...} # 原来是一个字符串
  1. ProductCard:category_desc: str 改成 category_descs: dict[str, str],render 多一个 lang 参数,模板里每个调 render() 的地方都要传语言。
  2. build_cards:一行没改,可每张卡片还是各抄一份分类信息,而这一份从 2 KB 多涨到了 20 KB 多。

第 3 处出了事故。把 before.py 按第 1、2 处改完再跑,1 万张卡片的分类信息估算 206,227,512 字节,约 197 MB,约是原来的 10 倍(15.4 的输出最后一行就是这个数)。测试环境商品少,没人注意。上线后第一次大促,会场列表一次建 10 万张,是 1 万张的 10 倍,光分类信息就约 2 GB,加上卡片本身,超过了容器 2 GB 的内存上限。进程被系统 OOM 杀掉,会场页近一个小时打不开。

问题的形状是:同一分类的信息,有多少张卡片就存多少份;分类信息每胖一点,都要乘上卡片数。

朴素写法:分类信息长在每张卡片上

标红的三处对应上面三条:①② 让每份分类信息变胖,③ 让它被复制 1 万份。

把卡片的字段分成两类。分类名、图标、说明,同一分类都一样,抽出来做成 CategoryStyle,冻结成只读;商品名、价格、位置每张不同,留在卡片上。StyleFactory.get(category) 保证同一分类只造一个 CategoryStyle,卡片只存一个指向它的引用:

from collections.abc import Mapping
from dataclasses import dataclass
from types import MappingProxyType
ICON_SIZE = 20 * 1024 # 新需求:高清图标 20 KB
CATEGORIES = ["编程书", "设计书", "外语书", "键盘", "鼠标", "耳机",
"显示器", "文具", "背包", "水杯", "服饰", "礼品卡"]
@dataclass(frozen=True) # 内在状态:同一分类的卡片都一样;要共享,就不许改
class CategoryStyle:
name: str
icon: bytes
descs: Mapping[str, str] # 新需求:三种语言的说明
def size(self) -> int: # 估算:图标字节数 + 各语言说明的 UTF-8 字节数
return len(self.icon) + sum(len(d.encode()) for d in self.descs.values())
def load_style(category: str) -> CategoryStyle: # 模拟:查分类表、下载图标,每调一次造一个新对象
icon = b"\x89PNG" + bytes(ICON_SIZE - 4)
descs = {"zh": f"{category}:多练网精选,七天无理由退货",
"en": f"Duolian picks ({category}), 7-day free returns",
"ja": f"{category}:多練ネット厳選、7日間返品可"}
return CategoryStyle(category, icon, MappingProxyType(descs)) # 只读视图,外面改不了
class StyleFactory: # 享元工厂:同一分类只造一次
def __init__(self) -> None:
self._styles: dict[str, CategoryStyle] = {}
def get(self, category: str) -> CategoryStyle:
if category not in self._styles:
self._styles[category] = load_style(category)
return self._styles[category]
def styles(self) -> list[CategoryStyle]:
return list(self._styles.values())
@dataclass
class ProductCard: # 只留外在状态,加一个指向共享对象的引用
name: str
price: int
position: int
style: CategoryStyle
def render(self, lang: str) -> str:
return f"#{self.position} {self.name} {self.price} 元 [{self.style.name}] {self.style.descs[lang]}"
def build_cards(n: int, factory: StyleFactory) -> list[ProductCard]:
return [ProductCard(f"商品{i}", 39 + i * 7 % 200, i, factory.get(CATEGORIES[i % len(CATEGORIES)]))
for i in range(n)]
if __name__ == "__main__":
factory = StyleFactory()
cards = build_cards(10_000, factory)
print(cards[0].render("zh"))
print(cards[13].render("ja"))
shared = len({id(c.style) for c in cards}) # 数一数有多少个不同的 CategoryStyle
used = sum(s.size() for s in factory.styles())
copied = sum(c.style.size() for c in cards) # 假如每张卡片各存一份
print(f"卡片 {len(cards):,} 张,CategoryStyle 对象 {shared} 个")
print(f"分类信息估算:共享后 {used:,} 字节(约 {used / 1024:.0f} KB)")
print(f"每张卡片各存一份则要 {copied:,} 字节(约 {copied / 1024 / 1024:.0f} MB)")
# 输出:
# #0 商品0 39 元 [编程书] 编程书:多练网精选,七天无理由退货
# #13 商品13 130 元 [设计书] 设计书:多練ネット厳選、7日間返品可
# 卡片 10,000 张,CategoryStyle 对象 12 个
# 分类信息估算:共享后 247,473 字节(约 242 KB)
# 每张卡片各存一份则要 206,227,512 字节(约 197 MB)

TS 版一样:

const ICON_SIZE = 20 * 1024; // 新需求:高清图标 20 KB
const CATEGORIES = ["编程书", "设计书", "外语书", "键盘", "鼠标", "耳机",
"显示器", "文具", "背包", "水杯", "服饰", "礼品卡"];
type Lang = "zh" | "en" | "ja";
class CategoryStyle { // 内在状态:同一分类的卡片都一样;要共享,就不许改
constructor(
readonly name: string,
readonly icon: Uint8Array, // readonly 只挡住换掉整个数组,挡不住改里面的字节,靠约定
readonly descs: Readonly<Record<Lang, string>>, // 新需求:三种语言的说明
) {
Object.freeze(this); // 冻结字段,运行时也改不了
}
size(): number { // 估算:图标字节数 + 各语言说明的 UTF-8 字节数
return this.icon.length + Object.values(this.descs).reduce((sum, d) => sum + Buffer.byteLength(d), 0);
}
}
function loadStyle(category: string): CategoryStyle { // 模拟:查分类表、下载图标,每调一次造一个新对象
const icon = new Uint8Array(ICON_SIZE);
icon.set([0x89, 0x50, 0x4e, 0x47]);
const descs = {
zh: `${category}:多练网精选,七天无理由退货`,
en: `Duolian picks (${category}), 7-day free returns`,
ja: `${category}:多練ネット厳選、7日間返品可`,
};
return new CategoryStyle(category, icon, Object.freeze(descs));
}
class StyleFactory { // 享元工厂:同一分类只造一次
private readonly cache = new Map<string, CategoryStyle>();
get(category: string): CategoryStyle {
let style = this.cache.get(category);
if (style === undefined) {
style = loadStyle(category);
this.cache.set(category, style);
}
return style;
}
styles(): CategoryStyle[] {
return [...this.cache.values()];
}
}
class ProductCard { // 只留外在状态,加一个指向共享对象的引用
constructor(
readonly name: string,
readonly price: number,
readonly position: number,
readonly style: CategoryStyle,
) {}
render(lang: Lang): string {
return `#${this.position} ${this.name} ${this.price} 元 [${this.style.name}] ${this.style.descs[lang]}`;
}
}
function buildCards(n: number, factory: StyleFactory): ProductCard[] {
return Array.from({ length: n }, (_, i) =>
new ProductCard(`商品${i}`, 39 + (i * 7) % 200, i, factory.get(CATEGORIES[i % CATEGORIES.length])));
}
const factory = new StyleFactory();
const cards = buildCards(10_000, factory);
console.log(cards[0].render("zh"));
console.log(cards[13].render("ja"));
const shared = new Set(cards.map((c) => c.style)).size; // 数一数有多少个不同的 CategoryStyle
const used = factory.styles().reduce((sum, s) => sum + s.size(), 0);
const copied = cards.reduce((sum, c) => sum + c.style.size(), 0); // 假如每张卡片各存一份
console.log(`卡片 ${cards.length.toLocaleString("en-US")} 张,CategoryStyle 对象 ${shared} 个`);
console.log(`分类信息估算:共享后 ${used.toLocaleString("en-US")} 字节(约 ${(used / 1024).toFixed(0)} KB)`);
console.log(`每张卡片各存一份则要 ${copied.toLocaleString("en-US")} 字节(约 ${(copied / 1024 / 1024).toFixed(0)} MB)`);
// 输出:
// #0 商品0 39 元 [编程书] 编程书:多练网精选,七天无理由退货
// #13 商品13 130 元 [设计书] 设计书:多練ネット厳選、7日間返品可
// 卡片 10,000 张,CategoryStyle 对象 12 个
// 分类信息估算:共享后 247,473 字节(约 242 KB)
// 每张卡片各存一份则要 206,227,512 字节(约 197 MB)

享元版:卡片只存外在状态,同一分类的卡片指向同一个 CategoryStyle

标红的两个类是新加的。4 张卡片只指向 2 个分类对象,1 万张也只指向 12 个。

对照 15.3:

  • 第 1 处,高清图标和三种语言:还是在加载分类的函数里改,只是它现在只执行 12 次。
  • 第 2 处,卡片的字段:卡片上不再有分类字段,三种语言加在 CategoryStyle 上;render 多一个 lang 参数躲不掉。
  • 第 3 处,复制:共享后分类信息约 242 KB,各存一份要约 197 MB。这部分内存只和分类数有关,不再随卡片数增长。

要说清楚:卡片对象还是 1 万个,商品名、价格、位置一个字节没少,省的只是重复的部分。另外 CategoryStyle 必须不可变:Python 用 frozen=True 加只读视图 MappingProxyType;TS 的 readonly 只在编译时管用,Uint8Array 里的字节照样能改,只能靠约定。不这样会怎样,见 15.6 例二。

GoF 对享元的定义是:运用共享技术有效地支持大量细粒度的对象。

关键是把状态分成两种。内在状态(intrinsic state)不随使用场合变化,可以共享,存在享元对象里,比如分类图标。外在状态(extrinsic state)随场合变化,由使用者自己存着,用的时候传给享元,比如商品名和位置。工厂按键缓存享元,保证每种只有一个。

享元的结构:FlyweightFactory 按键缓存 Flyweight,Client 自己保存外在状态

对照案例图:StyleFactory 是 FlyweightFactory,CategoryStyle 是 ConcreteFlyweight,ProductCard 是 Client。GoF 让享元的操作接收外在状态(operation(extrinsicState)),本例简化了,ProductCard.render 直接读 style 的字段。

角色 在本章案例里是谁 职责
享元(Flyweight) 本例没单独抽接口,就是 CategoryStyle 声明享元对外的操作;要用外在状态时由调用方传进来
具体享元(ConcreteFlyweight) 每个 CategoryStyle 实例 保存内在状态,只读,被许多使用者同时引用
享元工厂(FlyweightFactory) StyleFactory 按键缓存享元:有就返回已有的,没有才创建
客户(Client) ProductCard、build_cards 保存外在状态,向工厂要享元,持有它的引用

常见场景:

  • 文本编辑器:字体、字号、颜色的组合做成享元,几十万个字符只引用几十种格式;字符在第几行第几列是外在状态。
  • 游戏:粒子、子弹、树木共用贴图和模型,位置、速度各存各的,见 15.6 例二。
  • 地图:成千上万个标注点共用几十种图标。

例一:CPython 的小整数缓存和字符串驻留(Python)

Section titled “例一:CPython 的小整数缓存和字符串驻留(Python)”

CPython 解释器自己就在用享元。−5 到 256 的整数是预先建好的,运行时得到这个范围内的整数,拿到的都是同一个对象,所以 int("100") is int("100") 是 True,换成 1000 就是 False。字符串驻留(interning)是同一回事:sys.intern(s) 返回驻留表里和 s 相等的那个规范字符串,没有就把 s 放进去再返回。标识符和一部分字符串常量会被自动驻留。这些都是 CPython 的实现细节,语言并不保证,比较值永远用 ==,不要用 is。下面先看真实行为,再用字典模拟 intern,给 1 万行日志的级别字段去重:

import sys
# 一、CPython 自带的享元(实现细节,不是语言规定)
a, b = int("100"), int("100") # 运行时算出来的整数,避开编译时的常量合并
c, d = int("1000"), int("1000")
print(f"100 是同一个对象:{a is b};1000 是同一个对象:{c is d}")
s1, s2 = "".join(["多练", "网"]), "".join(["多练", "网"]) # 运行时拼出来的字符串不会自动驻留
print(f"拼出来的两个字符串:{s1 is s2};sys.intern 之后:{sys.intern(s1) is sys.intern(s2)}")
# 二、自己写一个 intern:字典里存规范的那一份(sys.intern 背后是解释器里的一张驻留表,道理相同)
_pool: dict[str, str] = {}
def intern(s: str) -> str:
return _pool.setdefault(s, s) # 第一次见到就存进去,以后都返回存着的那一份
levels = ("INFO", "WARN", "ERROR", "DEBUG")
lines = [f"10:00:{i % 60:02d},{levels[i % 4]},用户 {i} 登录" for i in range(10_000)]
raw = [line.split(",")[1] for line in lines] # split 每次都切出新的字符串对象
shared = [intern(line.split(",")[1]) for line in lines] # 切出来的新对象用完就扔,留下的是池里那份
print(f"不驻留:{len(raw):,} 个级别字段,{len({id(x) for x in raw}):,} 个字符串对象")
print(f"驻留后:{len(shared):,} 个级别字段,{len({id(x) for x in shared})} 个字符串对象")
# 输出:
# 100 是同一个对象:True;1000 是同一个对象:False
# 拼出来的两个字符串:False;sys.intern 之后:True
# 不驻留:10,000 个级别字段,10,000 个字符串对象
# 驻留后:10,000 个级别字段,4 个字符串对象

_pool 就是 StyleFactory 的缓存,intern 就是 get。字符串本身不可变,天生适合共享。

例二:游戏引擎的贴图共享(TypeScript)

Section titled “例二:游戏引擎的贴图共享(TypeScript)”

2D 游戏里一屏可能有几百个敌人、上千颗子弹,长相都一样。Phaser 这类引擎是这样做的:贴图由纹理管理器按 key 加载一次,精灵只引用那张贴图,自己存位置、缩放、着色这些外在状态(大致如此,具体 API 不展开)。下面数一数贴图加载了几次,再演示贴图可改时会怎样:

// 简化模型:Phaser 这类引擎的纹理管理器(Phaser 里是场景的 this.textures)按 key 存贴图,
// this.add.sprite(x, y, key) 造出的精灵只引用贴图;真实引擎还有帧、图集、显存上传等,这里都省了
class Texture {
tint = "原色"; // 故意留一个可改的字段,演示共享对象被改的后果;正确的做法是贴图全部只读
constructor(readonly key: string) {}
}
class TextureCache { // 对应纹理管理器:同一个 key 只加载一次
private readonly textures = new Map<string, Texture>();
loads = 0;
get(key: string): Texture {
let texture = this.textures.get(key);
if (texture === undefined) {
this.loads++; // 真实引擎在这里读图片文件、解码、上传显卡
texture = new Texture(key);
this.textures.set(key, texture);
}
return texture;
}
}
class Sprite { // 外在状态:位置,以及这个精灵自己的着色
tint: string | null = null;
constructor(public x: number, public y: number, readonly texture: Texture) {}
color(): string {
return this.tint ?? this.texture.tint;
}
}
const cache = new TextureCache();
const enemies = Array.from({ length: 500 }, (_, i) => new Sprite((i % 50) * 16, Math.floor(i / 50) * 16, cache.get("enemy.png")));
const bullets = Array.from({ length: 1000 }, (_, i) => new Sprite(i % 800, 400, cache.get("bullet.png")));
console.log(`精灵 ${enemies.length + bullets.length} 个,贴图加载 ${cache.loads} 次`);
const red = () => enemies.filter((e) => e.color() === "红").length;
enemies[0].tint = "红"; // 对:被击中的敌人闪红,记在它自己身上(Phaser 的 setTint 大致也是记在精灵上)
console.log(`给一个敌人着色,变红的敌人:${red()} 个`);
enemies[0].tint = null;
enemies[0].texture.tint = "红"; // 错:改的是 500 个敌人共用的那张贴图
console.log(`改了共享贴图,变红的敌人:${red()} 个`);
// 输出:
// 精灵 1500 个,贴图加载 2 次
// 给一个敌人着色,变红的敌人:1 个
// 改了共享贴图,变红的敌人:500 个

TextureCache.get 对应 StyleFactory.get,Texture 是享元,Sprite 存外在状态。最后一行就是共享对象被改的后果:本意只让被击中的那个敌人闪红,改到了共享贴图上,全屏 500 个敌人一起变红。

  • 对象不多。一页几十张、最多几百张卡片,每份分类信息约 20 KB,复制几百份也就几 MB。加工厂只是多一层代码、多一条“不能改”的约定。先打印对象数和内存估算,确有大头再拆。
  • 内在、外在状态分不清。某个商品想在分类说明后加一句“限时包邮”,要是 CategoryStyle 没冻结,有人顺手改了 card.style 上的说明,同分类的卡片全跟着变。某个使用者要的不一样,那部分就是外在状态,存在它自己身上。
  • 键的种类和对象一样多。按商品 ID 缓存,10 万件商品就是 10 万个享元,什么都没省;工厂的缓存还从不释放,商品下架了也留在内存里。
模式 意图 区别
单例(Singleton) 保证一个类只有一个实例 单例是整个类只有一个;享元是每种一个、按需共享,本章同一个类有 12 个实例。第 6 章 logging.getLogger 那种“每个名字一个”的注册表,和享元工厂写法一样,用途不同:logger 是可变的,要的是各模块改到同一个对象;享元必须不可变,要的是省内存
对象池(Object Pool) 复用创建代价大的对象,比如数据库连接 池里的对象可变,同一时间只借给一个使用者,用完归还;享元同时被许多使用者引用,不借不还,所以必须不可变
缓存(Cache) 存下算过的结果,下次不再算 形式相近,都是按键查的字典;缓存为了省时间,可以淘汰,淘汰了重算就是;享元为了省内存,要的是“大家用同一份”

国际象棋开局,64 格的棋盘上摆着 32 个棋子。名称、走法说明、图标(K、Q、R、B、N、P)只和棋子种类有关,做成享元 PieceType;颜色和所在格子是外在状态,留在 Piece 上。写 PieceTypeFactory.get(name),摆好开局,验证 32 个棋子只创建了 6 个 PieceType,8 个白兵引用的是同一个对象。Python 和 TS 各写一版。

提示:想一想颜色为什么不放进 PieceType:放进去,类型对象会变成几个?和例二的着色比一比。

  • 享元把大量对象里重复的部分(内在状态)抽成共享对象,由工厂按键各存一份;每个对象只留外在状态和一个引用。
  • 省下的是重复部分乘以对象数,对象本身并不减少;先数清对象、估好内存再用。
  • 共享对象必须不可变;某个使用者想不一样,那部分就是外在状态。
  • 单例只有一个,享元每种一个;对象池借出可变对象、用完归还,享元同时共享、不归还。