Python 默认的对象创建方式虽然灵活,但在处理数百万甚至上亿个小对象时,会消耗巨大的内存资源并拖慢运行速度。这是由于 Python 默认为每个对象分配了一个字典来存储属性。通过使用 __slots__,我们可以显著优化这两个方面。
1. 理解默认内存开销
在 Python 中,当你定义一个类并实例化时,Python 会自动为每个对象添加一个 __dict__ 属性。这是一个字典,用于存储该对象的所有属性和方法引用。
字典虽然提供了极高的灵活性(允许随时添加新属性),但其内部实现是基于哈希表的,这就带来了额外的内存开销:
- 哈希表结构:需要维护一个用于存储键值对的数组,以及处理哈希冲突的额外空间。
- 内存碎片:字典为了减少冲突,通常会预留比实际元素更多的空间。
运行以下代码,查看普通对象的内存占用情况:
import sys
class NormalObject:
def __init__(self, x, y):
self.x = x
self.y = y
# 创建实例
obj = NormalObject(10, 20)
# 打印对象自身大小
print(f"对象基础大小: {sys.getsizeof(obj)} 字节")
# 打印内部字典大小
print(f"__dict__ 大小: {sys.getsizeof(obj.__dict__)} 字节")
# 打印总大小
print(f"总大小: {sys.getsizeof(obj) + sys.getsizeof(obj.__dict__)} 字节")
注意:sys.getsizeof 仅计算容器本身,不递归计算容器内的对象。即便如此,你也会发现 __dict__ 占用了相当一部分空间。在创建海量对象时,这种开销会急剧放大。
2. 使用 slots 优化内存
__slots__ 是一个类变量,通过给它赋值一个序列(包含字符串的元组、列表等),你明确告诉 Python 解释器:这个类的实例只包含这些特定的属性。
当定义了 __slots__ 后,Python 会做两件事:
- 禁止创建
__dict__。 - 为每个声明的属性分配固定大小的空间(类似 C 语言的结构体),直接存储在对象实例中。
定义一个使用 __slots__ 的类:
class SlotObject:
# 限制属性只能是 x 和 y
__slots__ = ('x', 'y')
def __init__(self, x, y):
self.x = x
self.y = y
slot_obj = SlotObject(10, 20)
# 打印对象大小
print(f"Slots 对象大小: {sys.getsizeof(slot_obj)} 字节")
# 尝试添加新属性(这将报错,展示了其限制)
try:
slot_obj.z = 30
except AttributeError as e:
print(f"错误捕获: {e}")
对比输出结果,你会发现 SlotObject 的实例大小远小于 NormalObject 的总大小。这是因为 Python 不再需要维护那个沉重的哈希表。
3. 内存占用对比分析
为了更直观地理解差异,我们可以创建一个包含大量对象的列表进行对比。
执行以下压力测试代码:
import sys
def create_objects(cls, count):
return [cls(i, i+1) for i in range(count)]
count = 100000
# 普通类
class A:
__slots__ = ()
def __init__(self, x, y):
self.x = x
self.y = y
# Slots 类
class B:
__slots__ = ('x', 'y')
def __init__(self, x, y):
self.x = x
self.y = y
# 注意:由于 __dict__ 的存在,直接精确对比非常复杂。
# 这里主要观察内存增长趋势的显著差异。
print(f"普通类实例单体参考大小: {sys.getsizeof(A(1,2)) + sys.getsizeof(A(1,2).__dict__)}")
print(f"Slots类实例单体参考大小: {sys.getsizeof(B(1,2))}")
在实际生产环境中,对于百万级实例,使用 __slots__ 通常能节省 30% 到 60% 的内存,具体取决于属性的数量和大小。
4. 加速属性访问的原理
除了节省内存,__slots__ 还能显著加快属性访问速度。这涉及到两种不同的查找机制。
普通对象(字典查找)
访问 obj.x 时,Python 需要:
- 找到
obj的__dict__。 - 在哈希表中计算字符串
'x'的哈希值。 - 根据哈希值定位到对应的槽位。
- 处理可能发生的哈希冲突。
- 返回值。
Slots 对象(描述符查找)
当使用 __slots__ 时,Python 将这些属性转化为了“描述符”。访问过程变为:
- Python 知道
x在内存中的固定偏移量。 - 直接通过内存地址访问:$Address = BaseAddress + Offset$。
- 返回值。
这是一个从“查表”到“数组索引”的优化,省去了哈希计算和冲突处理的开销。
为了可视化这两种结构的区别,请看以下内存布局图:
运行速度测试代码以验证差异:
import timeit
def test_access(cls):
obj = cls(100, 200)
# 循环访问属性
for _ in range(1000000):
_ = obj.x
_ = obj.y
# 测试普通类
t_normal = timeit.timeit(lambda: test_access(A), number=10)
# 测试 Slots 类
t_slots = timeit.timeit(lambda: test_access(B), number=10)
print(f"普通类访问耗时: {t_normal:.4f} 秒")
print(f"Slots类访问耗时: {t_slots:.4f} 秒")
print(f"速度提升约: {t_normal/t_slots:.2f} 倍")
5. 掌握使用限制与最佳场景
虽然 __slots__ 性能强大,但它并非没有代价。了解其限制对于正确使用至关重要。
主要限制
- 无法动态添加属性:一旦类定义了
__slots__,你将无法给实例添加任何不在列表中的属性。 - 类变量冲突:
__slots__定义的名称会与类变量冲突,因为它们描述的是实例数据。 - 继承复杂性:如果父类定义了
__slots__,子类也必须定义__slots__,否则子类实例将拥有父类的__slots__空间以及自己的__dict__,这反而增加了内存开销。
适用场景对照表
| 场景特征 | 是否推荐使用 slots | 原因 |
|---|---|---|
| 实例数量极少 | ❌ | 优化效果不明显,反而牺牲了灵活性。 |
| 纯数据类,字段固定 | ✅ | 完美契合,内存节省巨大。 |
| 需要动态添加属性 | ❌ | __slots__ 会直接导致 AttributeError。 |
| 高性能计算、游戏引擎 | ✅ | 减少内存压力,提升数据读写频率。 |
遵循以下最佳实践步骤:
- 识别代码中创建实例数量巨大的类(如粒子系统、股票报价数据)。
- 确认这些类的属性在实例生命周期内保持不变。
- 定义
__slots__,列出所有属性名称。 - 重构所有试图动态添加属性的代码逻辑。
注意:单实例继承时,确保子类也定义了 __slots__。
class Parent:
__slots__ = ('x',)
class Child(Parent):
# 子类如果不定义 __slots__,通常会回退到带 __dict__ 的模式
# 正确做法是显式包含父类的 slots (如果需要访问) 或仅定义新 slots
__slots__ = ('y',)
暂无评论,快来抢沙发吧!