python设计模式–享元模式(Flyweight Pattern)

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 控件等“多实例 + 部分状态相同”的场景。

建议先写“树木+工厂”一例,再试“字符+字体”或“粒子”,体会“共享内在、传入外在”,这样对享元模式会掌握得比较扎实。

发表评论