Python 设计模式——享元模式(Flyweight Pattern)详解
本文面向零基础新手,从“共享相同部分”到内在/外在状态,从简单示例到工厂缓存,全方位讲解享元模式。
一、什么是享元模式?
1.1 通俗理解
享元模式是一种结构型设计模式,核心思想是:
当程序里存在大量“结构相似、部分状态相同”的对象时,把“相同的部分”抽出来共享(享元),只保留“不同的部分”在每次使用时单独传进去(外在状态),从而大幅减少对象数量和内存占用。
可以这样类比:
- 不用享元:画 1000 棵树,每棵树一个对象,每个对象都存“品种、颜色、纹理、坐标”。其中 800 棵是“松树、绿色、同一张纹理”,只是坐标不同,你却存了 800 份相同的品种/颜色/纹理,重复多、占内存。
- 用享元:“松树、绿色、某纹理”只存一份(享元);画每一棵松树时,只记“用这个享元 + 坐标 (x, y)”。800 棵松树共享同一份“松树享元”,只有 800 个坐标不同。相同部分共享,不同部分(坐标)外部传入,对象数和内存都降下来。
所以,享元模式解决的是:大量相似对象导致内存或创建成本过高时,通过“共享相同状态”来节省资源。
1.2 两个关键概念:内在状态 vs 外在状态
- 内在状态(Intrinsic):可以共享、与“对象身份”绑定的那部分数据,不随使用场景变化。例如:树的品种、颜色、纹理;字符的字体、字号。享元对象里只存内在状态,一份享元可被多处复用。
- 外在状态(Extrinsic):随使用场景变化、不能共享的那部分数据。例如:树的位置 (x, y);字符在文档里的位置。不放在享元里,而是在每次“使用”享元时由客户或上下文传入(如画树时传坐标)。
记忆:享元 = 共享内在状态;外在状态在用时从外部传入。
1.3 为什么需要享元?
场景一:对象数量极大
游戏里成千上万的子弹、粒子、树木;文档里成千上万个字符。若每个都单独存全部属性,内存会爆。很多对象只是“类型/外观”相同、“位置/序号”不同,适合共享类型、外观,单独存位置。
场景二:相同数据重复存储
例如 1000 个“红色圆”只是坐标不同,若每个对象都存一份“红色+圆”的绘制信息,浪费。用享元存“红色圆”一份,1000 次绘制时只传 1000 个坐标即可。
场景三:创建成本高
若每个对象初始化要读文件、解析配置,共享“已创建好的”享元可以避免重复创建和重复加载。
1.4 适用场景(什么时候用?)
| 场景 | 说明 |
|---|---|
| 大量相似对象 | 对象数量大,且很多对象的“部分状态”相同。 |
| 内存或创建成本敏感 | 需要省内存或省创建/加载成本。 |
| 能清晰区分内在/外在状态 | 能把“可共享”和“不可共享”分开,且使用时可传入外在状态。 |
简单记忆:数量大 + 很多相同部分 时,把相同部分做成享元共享,不同部分用时再传。
二、享元模式的结构(三个角色)
| 角色 | 说明 |
|---|---|
| 享元(Flyweight) | 存放并管理内在状态(可共享);提供方法时,通常需要接收外在状态(如坐标)作为参数。 |
| 具体享元(ConcreteFlyweight) | 实现享元接口,保存一份内在状态,供多处复用。 |
| 享元工厂(FlyweightFactory) | 根据“键”(如类型名、颜色+形状)缓存享元;客户通过工厂获取享元,避免重复创建相同内在状态的对象。 |
关系可以理解为:
- 客户不直接 new 享元,而是向工厂要享元(传入“键”,如 “松树”);工厂若已有该键对应的享元就返回,没有则创建并缓存。
- 客户使用享元时,把外在状态(如坐标)作为参数传给享元的方法(如
draw(x, y)),享元用内在状态 + 传入的外在状态完成一次操作。
三、示例一:树木与森林(最经典)
需求:画很多棵树;树有类型(松树、杨树)、颜色;每棵树还有坐标 (x, y)。类型+颜色共享(享元),坐标每次画时传入(外在状态)。
3.1 享元:只存内在状态
class TreeType:
"""享元:树的类型(内在状态:名字、颜色)"""
def __init__(self, name: str, color: str):
self.name = name
self.color = color
def draw(self, x: int, y: int):
"""画树:外在状态 x, y 由调用方传入"""
print(f" 在 ({x},{y}) 画一棵 {self.color} 的 {self.name}")
3.2 享元工厂:按“类型名+颜色”缓存
class TreeFactory:
_pool = {} # 键 -> 享元
@classmethod
def get_tree_type(cls, name: str, color: str) -> TreeType:
key = f"{name}_{color}"
if key not in cls._pool:
cls._pool[key] = TreeType(name, color)
return cls._pool[key]
3.3 使用:多棵树共享享元,只存坐标
class Tree:
"""带坐标的“树”:只存享元 + 外在状态 (x, y)"""
def __init__(self, x: int, y: int, tree_type: TreeType):
self.x = x
self.y = y
self.type = tree_type
def draw(self):
self.type.draw(self.x, self.y)
# 画 5 棵松树、3 棵杨树:只创建 2 个享元,其余都是坐标
pine = TreeFactory.get_tree_type("松树", "绿")
poplar = TreeFactory.get_tree_type("杨树", "绿")
trees = [
Tree(1, 1, pine), Tree(2, 2, pine), Tree(3, 3, pine), Tree(4, 4, pine), Tree(5, 5, pine),
Tree(10, 10, poplar), Tree(11, 11, poplar), Tree(12, 12, poplar),
]
for t in trees:
t.draw()
# 松树享元只 1 份,被 5 棵树共享;杨树享元 1 份,被 3 棵共享
要点:Tree 只存 (x, y) + 对享元的引用;享元里只有 (name, color)。大量树时,享元数量 = 类型数,树的数量可以很多,内存主要花在坐标和引用上。
四、示例二:文档中的字符(共享字体信息)
需求:文档里有大量字符,每个字符有“字体、字号、颜色”(可共享)和“字符、位置”(不共享)。把字体信息做成享元。
4.1 享元:字体
class Font:
"""享元:字体(内在状态)"""
def __init__(self, family: str, size: int, color: str):
self.family = family
self.size = size
self.color = color
def render(self, char: str, x: int, y: int):
"""渲染字符:char、x、y 为外在状态"""
print(f" 在 ({x},{y}) 用 {self.family}{self.size}号{self.color} 渲染 '{char}'")
4.2 工厂 + 带外在状态的“字符”
class FontFactory:
_pool = {}
@classmethod
def get_font(cls, family: str, size: int, color: str) -> Font:
key = f"{family}_{size}_{color}"
if key not in cls._pool:
cls._pool[key] = Font(family, size, color)
return cls._pool[key]
class Character:
"""字符:享元(字体)+ 外在状态(字符、位置)"""
def __init__(self, char: str, x: int, y: int, font: Font):
self.char = char
self.x = x
self.y = y
self.font = font
def render(self):
self.font.render(self.char, self.x, self.y)
4.3 使用
font1 = FontFactory.get_font("宋体", 12, "黑")
font2 = FontFactory.get_font("宋体", 12, "黑") # 同一份
chars = [
Character("你", 0, 0, font1),
Character("好", 10, 0, font1),
Character("世", 20, 0, font2),
]
for c in chars:
c.render()
# font1 和 font2 是同一个享元,只创建了一次
五、示例三:简单游戏粒子(共享外观,位置不同)
需求:大量粒子,有“类型”(火焰、烟雾)和“坐标”。类型做成享元,坐标外在传入。
class ParticleType:
def __init__(self, name: str, sprite_id: str):
self.name = name
self.sprite_id = sprite_id
def draw(self, x: float, y: float):
print(f" 在 ({x:.0f},{y:.0f}) 绘制 {self.name} (sprite={self.sprite_id})")
class ParticleTypeFactory:
_pool = {}
@classmethod
def get(cls, name: str, sprite_id: str) -> ParticleType:
key = f"{name}_{sprite_id}"
if key not in cls._pool:
cls._pool[key] = ParticleType(name, sprite_id)
return cls._pool[key]
# 100 个火焰粒子共享 1 个享元
fire = ParticleTypeFactory.get("火焰", "fire_01")
particles = [{"x": i * 2, "y": i} for i in range(100)]
for p in particles:
fire.draw(p["x"], p["y"])
六、享元模式 vs 其他
| 对比 | 享元 | 单例 | 对象池 |
|---|---|---|---|
| 目的 | 共享“相同状态”、省内存/创建 | 全局唯一实例 | 复用对象、减少创建/销毁 |
| 数量 | 享元按“种类”有多种(多种类型多种享元) | 每类只有一个实例 | 池里通常同一种类多个实例 |
| 状态 | 区分内在(共享)/外在(传入) | 单例状态全局一份 | 池中对象用完后可能重置状态再复用 |
简单记忆:享元是“按键共享”,键相同则用同一份对象,且强调内在状态共享、外在状态传入。
七、常见问题与注意点
7.1 内在状态必须不可变
享元被多处共享,若内在状态可变,改一处会影响到所有使用该享元的地方。所以享元里的内在状态应设为只读或不可变(如只读属性、不提供 setter)。
7.2 外在状态由谁存?
可以由“使用享元的对象”存(如 Tree 存 x, y),在调用享元方法时传入;也可以由客户在每次调用时传入(如直接 flyweight.draw(x, y))。只要保证“不把本应不同的数据塞进享元里”即可。
7.3 何时不值得用享元?
若对象数量不大、或相同部分很少、或逻辑会变复杂,引入享元可能不值得;享元适合数量大、相同部分多、能清晰分离内在/外在的场景。
八、小结
- 享元模式:当存在大量相似对象时,把相同部分(内在状态)抽成享元共享,不同部分(外在状态)在使用时传入,从而减少对象数量和内存占用。
- 内在状态:可共享、存在享元里;外在状态:不可共享、由调用方传入。
- 三个角色:享元(存内在状态)、具体享元、享元工厂(按键缓存,避免重复创建)。
- 典型用途:树木/森林、文档字符、游戏粒子、UI 控件等“多实例 + 部分状态相同”的场景。
建议先写“树木+工厂”一例,再试“字符+字体”或“粒子”,体会“共享内在、传入外在”,这样对享元模式会掌握得比较扎实。