资讯动态

享元模式实战:五子棋棋子对象内存优化与性能提升

发布时间:2026/10/13 22:49:58 来源:尧图企业网站定制
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.LockJava里可以用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.pyimport threading class ChessPieceType: 享元对象存储棋子的内部状态 __slots__ (color, radius, texture, shadow) def __init__(self, color): self.color color self.radius 15 self.texture ftexture/{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.pyfrom 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.pyfrom 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和history5.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, capacity10): 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(lastFalse) self._pool[key] new_obj return new_obj五子棋用不上这个但如果你做的是一个支持多种棋类、多种棋盘尺寸的平台这个就有用了。6.2 享元模式在游戏外的应用享元模式的思路不限于游戏。文本编辑器里的字符对象、地图应用里的标记点、电商系统里的商品分类都可以用享元模式优化。核心判断标准始终是有没有大量相似对象且内部状态可以共享。比如文本编辑器里每个字符的字体、字号、颜色是内部状态位置是外部状态。一篇文章几十万个字符共享字体对象能省下大量内存。这个思路和五子棋是一模一样的。6.3 什么时候不该用享元模式享元模式不是万能的。以下几种情况不建议用对象数量本来就少少于几十个共享带来的收益抵不过代码复杂度。内部状态和外部状态难以分离硬拆会让代码变得难以理解。对象创建成本很低比如只是一个简单的小整数那共享的意义不大。需要频繁修改对象状态享元对象的只读特性反而会成为障碍。我个人的经验是先写最朴素的实现用性能分析工具找到瓶颈再决定要不要上享元模式。不要一上来就套模式那样容易过度设计。7. 我在实际操作中的几点体会这个五子棋享元模式的例题我从第一次接触到真正吃透中间隔了差不多两年。刚开始学的时候觉得不就是个缓存嘛有什么难的。后来在实际项目里用才发现细节特别多——线程安全、状态划分、内存测量、测试验证每一个环节都有讲究。最大的体会是设计模式的价值不在于代码本身而在于它强迫你想清楚“什么是变的什么是不变的”。享元模式把这个思考过程具象化了。你把内部状态和外部状态分开的那一刻其实就已经在做一个架构决策了。另一个体会是性能优化要用数据说话。我见过太多人凭感觉说“这样写更快”但从来没测过。享元模式在五子棋里能省70%内存这是我用sys.getsizeof和内存分析工具实测出来的。你也应该养成这个习惯改完代码跑个基准测试用数字证明你的优化有效。最后分享一个小技巧如果你在学设计模式不要只看书上的UML图。找一个像五子棋这样的小项目把模式真正写一遍跑起来改几个参数看看效果。这种“动手观察”的学习方式比看十篇文章都管用。享元模式尤其如此因为它的收益只有在对象数量上来之后才明显光看代码是感受不到的。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑