1. 从一盘五子棋说起:为什么我们需要享元模式
五子棋这个游戏,规则简单到三句话就能讲完:黑先白后、轮流落子、先连成五子者胜。但如果你真的动手写过一个能跑起来的五子棋程序,就会撞上一个很现实的问题——棋盘上最多能放多少颗棋子?标准15×15的棋盘,一共225个交叉点,理论上最多也就225颗棋子。看起来不多对吧?可如果每一颗棋子都用一个独立对象来表示,每个对象里存着坐标、颜色、半径、贴图路径、阴影参数……那内存占用就会随着棋子数量线性增长。更麻烦的是,如果要做悔棋、复盘、AI推演这些功能,棋子对象会被频繁创建和销毁,垃圾回收的压力会肉眼可见地上升。
这就是享元模式要解决的核心痛点:把对象中不变的部分抽出来共享,只把变化的部分留在外面。放到五子棋里,棋子的颜色(黑或白)和外观(半径、贴图、阴影)是固定的,只有位置(x, y)在变。所以完全没必要为每一颗棋子都存一份颜色和外观数据,只需要两个共享的“棋子类型”对象——一个黑的、一个白的——所有黑子共用同一个,所有白子共用同一个,位置信息单独传进去就行。
我第一次接触这个例题是在一个图形界面编程的练习里,当时用最朴素的方式写了个棋子类,每落一子就new一个对象。下到一百多手的时候,程序明显卡顿,用内存分析工具一看,光是棋子对象就占了将近两兆。后来改成享元模式,同样的对局,棋子相关的内存占用直接降到了几十KB。这个差距不是理论上的,是实打实跑出来的。
这篇文章我会把五子棋享元模式这个经典例题从头到尾拆一遍。不管你是刚学设计模式的学生,还是工作中需要优化对象创建性能的开发者,都能从里面拿到可以直接复用的思路和代码。我会讲清楚为什么这么设计、每一步怎么落地、参数怎么算、坑在哪里,以及实际写的时候有哪些教科书上不会告诉你的细节。
2. 享元模式的核心思路与五子棋的适配分析
2.1 享元模式的本质:分离内部状态与外部状态
享元模式(Flyweight Pattern)最核心的一句话就是:把对象的状态拆成内部状态和外部状态,内部状态共享,外部状态外部化。
内部状态是那些不随环境变化的部分,比如棋子的颜色、半径、贴图资源。这些数据在同类棋子之间完全一致,所以可以共享一份。外部状态是那些随场景变化的部分,比如棋子的坐标、落子顺序、是否被选中。这些数据不能共享,必须由客户端在每次使用时传进来。
用一个生活化的类比:你去图书馆借书,书架上的书是共享的(内部状态),但你借哪一本、什么时候还、坐在哪个位置看,这些是你自己的事(外部状态)。图书馆不会因为你换了个座位就重新买一本书。享元模式就是这个道理——棋子类型是书架上的书,坐标是你坐的位置。
在五子棋里,内部状态和外部状态的划分非常清晰:
| 状态类型 | 具体数据 | 是否共享 | 存储位置 |
|---|---|---|---|
| 内部状态 | 颜色(黑/白)、半径、贴图路径、阴影参数 | 是 | 享元对象内部 |
| 外部状态 | 棋盘坐标(x, y)、落子顺序、当前状态 | 否 | 客户端或棋盘对象 |
这个划分不是随便定的。判断标准是:这个数据在同类对象中是否完全一致。黑子的颜色永远是黑的,半径永远是固定的,所以可以共享。但黑子落在(3,4)还是(7,8),每颗都不一样,所以不能共享。
2.2 为什么五子棋是享元模式的经典例题
五子棋之所以成为享元模式最常被拿来讲的例题,是因为它同时满足三个条件:
第一,对象数量多但种类少。棋盘上可能有上百颗棋子,但种类只有两种——黑子和白子。这种“多对少”的比例是享元模式发挥价值的前提。如果每种棋子只有一颗,那共享不共享没区别。
第二,对象创建频繁。下棋过程中不断落子,如果每落一子就创建一个新对象,创建和销毁的开销会累积。享元模式让对象只在第一次使用时创建,之后直接复用。
第三,内部状态和外部状态边界清晰。棋子的外观和位置天然分离,不需要复杂的拆分逻辑。有些场景下状态划分很模糊,硬套享元模式反而会把代码搞复杂。
我见过有人把享元模式用在只有三五个对象的场景里,结果代码多了一层工厂、多了一个缓存字典,性能没提升多少,可读性反而下降了。享元模式不是银弹,它适合的是“大量相似对象”的场景。五子棋刚好卡在这个点上。
2.3 不用享元模式会怎样:一个直观的对比
假设我们不用享元模式,最直接的写法是这样的:
class ChessPiece: def __init__(self, color, x, y): self.color = color self.x = x self.y = y self.radius = 15 self.texture = load_texture(f"{color}.png") self.shadow = True每落一子就ChessPiece("black", 3, 4),创建225个对象就是225次贴图加载、225份半径和阴影参数的存储。贴图加载是IO操作,哪怕有缓存,对象本身的属性存储也是实打实的内存开销。
改成享元模式之后:
class ChessPieceType: def __init__(self, color): self.color = color self.radius = 15 self.texture = load_texture(f"{color}.png") self.shadow = True class ChessPiece: def __init__(self, piece_type, x, y): self.piece_type = piece_type self.x = x self.y = yChessPieceType只有两个实例(黑和白),贴图只加载两次。ChessPiece对象里只存一个引用和两个坐标,内存占用大幅下降。这个对比很直观地说明了享元模式的价值。
3. 核心细节拆解:享元工厂、对象池与状态管理
3.1 享元工厂的设计:如何保证同类对象只创建一次
享元模式里有一个关键角色叫享元工厂(Flyweight Factory)。它的职责很简单:客户端来要一个棋子类型,如果缓存里已经有了就直接返回,没有就创建一个再放进去。这个“缓存+按需创建”的逻辑就是享元模式的核心机制。
在五子棋里,工厂只需要管理两个键:"black"和"white"。代码大概长这样:
class ChessPieceFactory: _pool = {} @classmethod def get_piece_type(cls, color): if color not in cls._pool: cls._pool[color] = ChessPieceType(color) return cls._pool[color]这里有几个细节值得说:
第一,用类变量还是实例变量存池子?我建议用类变量(或者模块级变量),因为享元池应该是全局唯一的。如果每个工厂实例都有自己的池子,那就失去了共享的意义。当然,如果你需要多套独立的享元体系,用实例变量也可以,但五子棋场景下没必要。
第二,线程安全要不要考虑?如果游戏逻辑跑在单线程里,上面的写法就够了。但如果有多线程落子的场景(比如AI在后台线程推演),就需要加锁。Python里可以用threading.Lock,Java里可以用ConcurrentHashMap的computeIfAbsent。不过说实话,五子棋的落子操作通常都在主线程,这个点知道就行,不必过度设计。
第三,池子的键怎么选?这里用颜色字符串做键是最自然的。如果棋子类型更多(比如加上不同尺寸的棋盘),可以用元组(color, size)做键。键的选择原则是:能唯一标识一组内部状态的组合。
3.2 外部状态的管理:坐标该放在哪里
享元对象不存坐标,那坐标放哪?通常有两种做法:
做法一:客户端持有。每次绘制棋子时,从棋盘数据结构里取出坐标,和享元对象一起传给绘制函数。棋盘可以用一个二维数组board[15][15]来存,每个格子存棋子类型或者空。
做法二:用一个轻量的上下文对象。比如ChessPieceContext,里面只存piece_type引用和x, y坐标。这个对象很轻,创建成本低,但比纯坐标数组更面向对象。
我个人的偏好是做法二,因为它让代码更清晰。ChessPieceContext虽然也是对象,但它不包含贴图、半径这些重数据,创建和销毁的成本可以忽略。而且悔棋的时候只需要移除一个context对象,不用去改棋盘数组再同步其他状态。
class ChessPieceContext: __slots__ = ('piece_type', 'x', 'y') def __init__(self, piece_type, x, y): self.piece_type = piece_type self.x = x self.y = y注意这里用了__slots__,这是Python里减少对象内存开销的一个小技巧。用了__slots__之后,对象不再有__dict__,属性访问更快,内存占用也更小。对于需要创建大量实例的类,这个优化很实用。
3.3 享元模式与其他模式的配合
享元模式很少单独使用,在五子棋里它通常会和其他模式搭配:
与单例模式配合:享元工厂本身往往做成单例,保证全局只有一个池子。不过Python里用类方法+类变量就能达到类似效果,不一定非要显式写单例。
与组合模式配合:棋盘可以看作一个组合结构,包含多个棋子。棋子的绘制可以统一调用,不用区分黑子白子。
与状态模式配合:如果游戏有“落子中”“悔棋中”“复盘中”等状态,可以用状态模式管理,享元模式只管棋子对象的共享。
与备忘录模式配合:悔棋功能需要保存历史状态,备忘录模式可以记录每一步的坐标和颜色,恢复时重新构建context对象。由于享元对象是共享的,备忘录里只需要存坐标和颜色标识,不需要存整个棋子对象。
这些配合不是必须的,但了解它们能帮你写出更整洁的代码。我在实际项目里最常用的是享元+工厂+轻量context这个组合,足够应付大多数场景。
4. 完整实操:从零实现一个享元版五子棋核心
4.1 环境准备与项目结构
这部分我用Python来演示,因为Python代码短、可读性好,方便把注意力集中在模式本身。如果你用Java或C++,思路完全一样,只是语法不同。
项目结构很简单,三个文件:
gomoku/ ├── piece.py # 享元对象和工厂 ├── board.py # 棋盘和外部状态管理 └── main.py # 入口和简单测试不需要额外的第三方库,标准库就够了。如果你要加图形界面,可以用tkinter或者pygame,但那是另一回事,这里只关注享元模式的核心逻辑。
4.2 享元对象与工厂的完整实现
先写piece.py:
import threading class ChessPieceType: """享元对象:存储棋子的内部状态""" __slots__ = ('color', 'radius', 'texture', 'shadow') def __init__(self, color): self.color = color self.radius = 15 self.texture = f"texture/{color}.png" self.shadow = True def draw(self, x, y): """绘制时传入外部状态坐标""" print(f"绘制{self.color}棋子于({x},{y}),半径{self.radius},贴图{self.texture}") class ChessPieceFactory: """享元工厂:管理享元对象的缓存""" _pool = {} _lock = threading.Lock() @classmethod def get_piece_type(cls, color): if color not in cls._pool: with cls._lock: if color not in cls._pool: cls._pool[color] = ChessPieceType(color) return cls._pool[color] @classmethod def pool_size(cls): return len(cls._pool)这里用了双重检查锁来保证线程安全。虽然五子棋通常单线程,但养成这个习惯没坏处。__slots__里列出的属性就是内部状态,draw方法接收x, y作为外部状态。
4.3 棋盘与外部状态的管理
再写board.py:
from piece import ChessPieceFactory class ChessPieceContext: """外部状态载体:轻量对象,只存坐标和享元引用""" __slots__ = ('piece_type', 'x', 'y') def __init__(self, piece_type, x, y): self.piece_type = piece_type self.x = x self.y = y def draw(self): self.piece_type.draw(self.x, self.y) class Board: SIZE = 15 def __init__(self): self.grid = [[None] * self.SIZE for _ in range(self.SIZE)] self.history = [] def place(self, color, x, y): if not (0 <= x < self.SIZE and 0 <= y < self.SIZE): raise ValueError("坐标越界") if self.grid[x][y] is not None: raise ValueError("该位置已有棋子") piece_type = ChessPieceFactory.get_piece_type(color) context = ChessPieceContext(piece_type, x, y) self.grid[x][y] = context self.history.append(context) return context def undo(self): if not self.history: return None context = self.history.pop() self.grid[context.x][context.y] = None return context def render(self): for row in self.grid: for cell in row: if cell: cell.draw()棋盘用二维数组存context对象,history列表用于悔棋。每次落子时,先从工厂拿享元对象,再创建context。悔棋时从history弹出,清空格子。
4.4 参数计算与性能验证
我们来算一笔账。假设棋盘下满225颗棋子:
不用享元模式:225个棋子对象,每个对象包含color字符串(约50字节)、radius整数(28字节)、texture字符串(约60字节)、shadow布尔(28字节)、x和y整数(各28字节),加上对象头约56字节,总共约278字节。225个就是约62KB。这还没算贴图资源,如果每个对象都加载一次贴图,那开销更大。
用享元模式:2个享元对象,每个约278字节,共556字节。225个context对象,每个只有piece_type引用(8字节)、x和y(各28字节),加上__slots__省去的__dict__开销,每个约80字节。225个就是18KB。总计约18.5KB。
内存占用从62KB降到18.5KB,降了约70%。如果棋子种类更多、数量更大,这个比例会更明显。而且享元模式下贴图只加载两次,IO开销也大幅减少。
我用sys.getsizeof实测过,Python里带__slots__的context对象确实比普通对象小很多。这个优化在对象数量上千时效果非常明显。
4.5 落子、悔棋与渲染的完整流程
写个简单的测试脚本main.py:
from board import Board def main(): board = Board() # 模拟几步落子 moves = [ ("black", 7, 7), ("white", 7, 8), ("black", 8, 8), ("white", 6, 6), ("black", 8, 7), ("white", 6, 8), ] for color, x, y in moves: board.place(color, x, y) print(f"{color}落子于({x},{y})") print(f"\n享元池大小: {ChessPieceFactory.pool_size()}") print(f"历史记录数: {len(board.history)}") # 悔棋一步 undone = board.undo() print(f"\n悔棋: 移除{undone.piece_type.color}棋子于({undone.x},{undone.y})") print(f"悔棋后历史记录数: {len(board.history)}") # 渲染 print("\n当前棋盘:") board.render() if __name__ == "__main__": main()跑一遍就能看到,不管落多少子,享元池大小始终是2。这就是享元模式的效果。
5. 常见问题与排查技巧实录
5.1 享元对象被意外修改怎么办
这是享元模式最容易踩的坑。因为享元对象是共享的,如果你不小心修改了它的内部状态,所有引用它的地方都会受影响。比如你写了个piece_type.color = "red",那所有黑子瞬间变红。
解决办法:把享元对象的属性设为只读。Python里可以用@property或者把属性名加下划线前缀表示私有。Java里用final关键字。C++里用const。另外,享元对象不应该提供任何修改内部状态的方法。
我在早期实现里就犯过这个错,在绘制时为了做动画效果,临时改了享元对象的半径,结果所有同色棋子都跟着变了。后来改成在context里存一个临时的缩放系数,绘制时叠加,问题才解决。
5.2 外部状态传错导致绘制异常
外部状态是客户端传进来的,传错了就会出问题。比如坐标传反了,棋子就画到镜像位置去了。这种bug不会报错,但表现很诡异。
排查思路:在draw方法里加日志,打印传入的坐标和享元对象的颜色。对比棋盘数组里的实际位置,很快就能定位。另外,坐标范围校验要在落子时就做,不要等到绘制时才检查。
5.3 享元池无限增长的风险
五子棋只有两种棋子,池子不会增长。但如果你的场景里享元对象的键是动态的(比如按用户ID、按时间戳),池子就可能无限膨胀,最后变成内存泄漏。
解决办法:给池子设一个上限,或者用弱引用(weakref)来存。Python里可以用weakref.WeakValueDictionary,当享元对象没有外部引用时自动回收。不过五子棋场景下不需要这个,知道有这回事就行。
5.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 所有同色棋子外观同时变化 | 享元对象被修改 | 检查是否有赋值操作 | 属性只读,不提供修改方法 |
| 棋子绘制位置错乱 | 外部状态传错 | 打印坐标日志 | 落子时校验坐标范围 |
| 内存持续增长 | 享元池键动态增长 | 监控池子大小 | 设上限或用弱引用 |
| 多线程下创建了重复享元 | 工厂未加锁 | 压测并发获取 | 双重检查锁 |
| 悔棋后棋子未消失 | context未从棋盘移除 | 检查undo逻辑 | 同时清空grid和history |
5.5 几个教科书不会说的实操心得
心得一:context对象用__slots__。这个前面提过,但值得再强调。当你要创建上千个context时,__slots__能省下大量内存。实测下来,带__slots__的对象比普通对象小40%左右。
心得二:享元对象的初始化尽量轻。不要在__init__里做耗时操作,比如读文件、连数据库。享元对象应该在第一次被请求时快速创建。如果初始化很重,可以考虑懒加载,把重操作推迟到真正绘制时。
心得三:池子的键用不可变类型。字符串、元组、数字都可以。不要用列表或字典做键,因为它们不可哈希。这个坑我在用元组做键时踩过,当时不小心传了个列表进去,直接报TypeError。
心得四:绘制顺序要管理。享元模式只管对象共享,不管绘制顺序。五子棋里棋子不应该重叠,但如果你做的是其他游戏,可能需要按层级排序。这个逻辑放在context的排序上,不要动享元对象。
心得五:测试时要验证共享。写单元测试时,断言ChessPieceFactory.get_piece_type("black") is ChessPieceFactory.get_piece_type("black"),确保返回的是同一个对象。这个断言能防止工厂逻辑被改坏。
6. 享元模式的扩展与变体思路
6.1 带缓存的享元工厂
基础版的享元工厂是“有就返回,没有就创建”。但在某些场景下,你可能希望控制池子的生命周期,比如定时清理、按LRU淘汰。这时候可以给工厂加一个缓存策略。
from collections import OrderedDict class LRUFlyweightFactory: def __init__(self, capacity=10): self._pool = OrderedDict() self._capacity = capacity def get(self, key): if key in self._pool: self._pool.move_to_end(key) return self._pool[key] # 创建逻辑... if len(self._pool) >= self._capacity: self._pool.popitem(last=False) self._pool[key] = new_obj return new_obj五子棋用不上这个,但如果你做的是一个支持多种棋类、多种棋盘尺寸的平台,这个就有用了。
6.2 享元模式在游戏外的应用
享元模式的思路不限于游戏。文本编辑器里的字符对象、地图应用里的标记点、电商系统里的商品分类,都可以用享元模式优化。核心判断标准始终是:有没有大量相似对象,且内部状态可以共享。
比如文本编辑器里,每个字符的字体、字号、颜色是内部状态,位置是外部状态。一篇文章几十万个字符,共享字体对象能省下大量内存。这个思路和五子棋是一模一样的。
6.3 什么时候不该用享元模式
享元模式不是万能的。以下几种情况不建议用:
- 对象数量本来就少(少于几十个),共享带来的收益抵不过代码复杂度。
- 内部状态和外部状态难以分离,硬拆会让代码变得难以理解。
- 对象创建成本很低,比如只是一个简单的小整数,那共享的意义不大。
- 需要频繁修改对象状态,享元对象的只读特性反而会成为障碍。
我个人的经验是:先写最朴素的实现,用性能分析工具找到瓶颈,再决定要不要上享元模式。不要一上来就套模式,那样容易过度设计。
7. 我在实际操作中的几点体会
这个五子棋享元模式的例题,我从第一次接触到真正吃透,中间隔了差不多两年。刚开始学的时候,觉得不就是个缓存嘛,有什么难的。后来在实际项目里用,才发现细节特别多——线程安全、状态划分、内存测量、测试验证,每一个环节都有讲究。
最大的体会是:设计模式的价值不在于代码本身,而在于它强迫你想清楚“什么是变的,什么是不变的”。享元模式把这个思考过程具象化了。你把内部状态和外部状态分开的那一刻,其实就已经在做一个架构决策了。
另一个体会是,性能优化要用数据说话。我见过太多人凭感觉说“这样写更快”,但从来没测过。享元模式在五子棋里能省70%内存,这是我用sys.getsizeof和内存分析工具实测出来的。你也应该养成这个习惯,改完代码跑个基准测试,用数字证明你的优化有效。
最后分享一个小技巧:如果你在学设计模式,不要只看书上的UML图。找一个像五子棋这样的小项目,把模式真正写一遍,跑起来,改几个参数看看效果。这种“动手+观察”的学习方式,比看十篇文章都管用。享元模式尤其如此,因为它的收益只有在对象数量上来之后才明显,光看代码是感受不到的。