2、对象池模式:用 __slots__ 给实例“瘦身”
好,咱们接着聊对象池模式。今天要讲的这个技巧,我个人特别喜欢——__slots__。说白了,它就是给 Python 对象“减减肥”。
你想想看,Python 的每个实例对象,默认都带着一个 __dict__ 字典。这个字典用来存实例的属性。听起来挺方便对吧?但代价呢?内存开销巨大。尤其是当你创建成千上万个对象时,这个 __dict__ 就成了内存杀手。
我记得有一次,我在做一个游戏服务器。玩家上线后,每个玩家对象大概占 1KB 内存。当时觉得还行。结果一上线 10 万人,服务器直接 OOM 了。排查下来,罪魁祸首就是那个 __dict__。嗯,从那以后,我对 __slots__ 就特别上心。
2.1 普通类 vs 优化类:内存差异有多大?
咱们直接看代码。先定义一个普通类:
class Player:
def __init__(self, uid, name, level):
self.uid = uid
self.name = name
self.level = level
再定义一个用了 __slots__ 的优化类:
class PlayerOptimized:
__slots__ = ('uid', 'name', 'level')
def __init__(self, uid, name, level):
self.uid = uid
self.name = name
self.level = level
区别就一行:__slots__ = ('uid', 'name', 'level')。但效果天差地别。
咱们用 sys.getsizeof 测一下单个实例的大小:
import sys
p1 = Player(1, 'Alice', 10)
p2 = PlayerOptimized(1, 'Alice', 10)
print(f'普通类实例大小: {sys.getsizeof(p1)} 字节')
print(f'优化类实例大小: {sys.getsizeof(p2)} 字节')
输出结果:
普通类实例大小: 56 字节
优化类实例大小: 48 字节
单个实例只差了 8 字节?别急,这只是冰山一角。真正的内存大头在 __dict__ 本身。咱们看看:
print(f'普通类 __dict__ 大小: {sys.getsizeof(p1.__dict__)} 字节')
输出:
普通类 __dict__ 大小: 120 字节
看到了吗?__dict__ 本身就要 120 字节。加上实例的 56 字节,总共 176 字节。而优化类呢?没有 __dict__,只有 48 字节。差了将近 4 倍!
核心结论:使用 __slots__ 后,每个实例节省约 70% 的内存。对象数量越多,效果越明显。
2.2 为什么 __slots__ 能省内存?
原理其实不复杂。Python 的普通实例,属性存在 __dict__ 这个哈希表里。哈希表为了快速查找,会预留很多空位。你存 3 个属性,它可能给你分配 8 个槽位。这就是内存浪费的根源。
而 __slots__ 呢?它告诉 Python:“我就这几个属性,别给我搞什么字典了,直接用固定大小的数组存吧。” 于是 Python 就为每个属性分配一个固定偏移量,像 C 语言的结构体一样紧凑。
我习惯把 __slots__ 比作“定制化衣柜”。普通类是一个大仓库,里面放了个大箱子(__dict__),你只放几件衣服,但箱子占了一大块地方。优化类呢?直接给你钉几个挂钩在墙上,衣服挂上去就行,省空间。
2.3 避坑指南:__slots__ 的注意事项
我曾经踩过的坑:
- 不能动态添加新属性——用了 __slots__ 后,实例就不能再添加 __slots__ 里没定义的属性了。比如
p2.email = 'a@b.com'会报 AttributeError。如果你需要动态属性,那就别用 __slots__。 - 继承时要小心——子类如果没定义 __slots__,它还是会生成 __dict__。子类必须也定义 __slots__,才能继续享受内存优化。
- 多继承时有限制——多个父类都有 __slots__ 且属性名冲突时,会报错。建议在单继承场景下使用。
我的小技巧:如果你不确定某个类是否适合用 __slots__,可以先写普通类,跑通逻辑后,再批量加上 __slots__。反正改动就一行代码,测试成本很低。
2.4 对象池 + __slots__:双剑合璧
对象池模式的核心是复用对象,减少频繁创建和销毁的开销。而 __slots__ 让每个对象更轻量。两者结合,效果 1+1 > 2。
举个例子,一个简单的对象池:
class Bullet:
__slots__ = ('x', 'y', 'speed', 'active')
def __init__(self):
self.x = 0
self.y = 0
self.speed = 0
self.active = False
def reset(self, x, y, speed):
self.x = x
self.y = y
self.speed = speed
self.active = True
class BulletPool:
def __init__(self, size=1000):
self.pool = [Bullet() for _ in range(size)]
self.index = 0
def acquire(self, x, y, speed):
bullet = self.pool[self.index]
bullet.reset(x, y, speed)
self.index = (self.index + 1) % len(self.pool)
return bullet
这个子弹对象池,每个 Bullet 实例只有 48 字节(加上对象池列表的引用,总共也就 50 多字节)。1000 个子弹才占 50KB 左右。如果不用 __slots__,每个实例 176 字节,1000 个就是 176KB。差了 3 倍多。
2.5 性能对比:不只是省内存
除了省内存,__slots__ 还能提升属性访问速度。因为没有了哈希查找的开销,直接通过偏移量访问。咱们做个简单测试:
import timeit
p1 = Player(1, 'Alice', 10)
p2 = PlayerOptimized(1, 'Alice', 10)
def test_normal():
for _ in range(1000000):
_ = p1.uid
_ = p1.name
_ = p1.level
def test_slots():
for _ in range(1000000):
_ = p2.uid
_ = p2.name
_ = p2.level
print(f'普通类属性访问: {timeit.timeit(test_normal, number=1):.3f} 秒')
print(f'优化类属性访问: {timeit.timeit(test_slots, number=1):.3f} 秒')
输出结果:
普通类属性访问: 0.089 秒
优化类属性访问: 0.052 秒
快了将近 40%。虽然单次访问差距不大,但在高频调用的场景下,这个提升就很可观了。
2.6 本章知识体系
下面这张图,帮你理清 __slots__ 的核心逻辑:
2.7 什么时候该用 __slots__?
我个人的经验是:
- 实例数量超过 1 万个——这时候内存差异就很明显了。我一般超过 5000 个就开始考虑。
- 属性固定不变——如果你的类属性在运行时不会动态增减,那 __slots__ 就是为你量身定做的。
- 性能敏感场景——比如游戏循环、高频交易、实时数据处理,属性访问速度的提升能带来实实在在的好处。
但如果你写的是业务逻辑代码,实例数量不多(几百个),那用不用 __slots__ 差别不大。别为了优化而优化,先跑起来再说。
好了,这一章就到这里。记住一句话:对象池 + __slots__,是 Python 高性能编程的黄金搭档。下一章咱们聊聊更高级的内存池技术,敬请期待。