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__ 的核心逻辑:

__slots__ 内存优化核心逻辑 普通类(无 __slots__) 实例包含 __dict__ 字典 哈希表预留空位,内存浪费 每个实例约 176 字节 优化类(使用 __slots__) 无 __dict__,固定偏移量存储 类似 C 结构体,紧凑排列 每个实例约 48 字节 内存节省约 70% | 属性访问速度提升约 40% 适用于大量实例、高频访问的场景(如游戏对象、连接池、缓存条目)

2.7 什么时候该用 __slots__?

我个人的经验是:

  • 实例数量超过 1 万个——这时候内存差异就很明显了。我一般超过 5000 个就开始考虑。
  • 属性固定不变——如果你的类属性在运行时不会动态增减,那 __slots__ 就是为你量身定做的。
  • 性能敏感场景——比如游戏循环、高频交易、实时数据处理,属性访问速度的提升能带来实实在在的好处。

但如果你写的是业务逻辑代码,实例数量不多(几百个),那用不用 __slots__ 差别不大。别为了优化而优化,先跑起来再说。

好了,这一章就到这里。记住一句话:对象池 + __slots__,是 Python 高性能编程的黄金搭档。下一章咱们聊聊更高级的内存池技术,敬请期待。

可可精品资料库