__slots__ 去掉了每个实例自带的字典。对象一多,能省下实实在在的内存,还顺带拦住拼写错误,但不适合当默认选择。
普通的 Python 对象都带着一个字典,用来存放属性。正因为有它,你才能随时给对象加属性;也正因为有它,对象占的内存比你想象的多。
__slots__ 就是不要这个字典。
实测
class Plain:
def __init__(self, x, y): self.x, self.y = x, y
class Slotted:
__slots__ = ('x', 'y')
def __init__(self, x, y): self.x, self.y = x, y
p, s = Plain(1,2), Slotted(1,2)
print('plain ', sys.getsizeof(p) + sys.getsizeof(p.__dict__))
print('slotted', sys.getsizeof(s))
print('slotted has no __dict__:', not hasattr(s, '__dict__'))
输出:
plain 344
slotted 48
slotted has no __dict__: True
用了 slots 的对象把两个属性存在一个固定的数组里,而不是字典里。
关于这个数字,说句实话
换成测很多个实例,差距就没那么大了:
N = 200_000
plains = [Plain(i, i) for i in range(N)]
slots = [Slotted(i, i) for i in range(N)]
pb = sum(sys.getsizeof(o) + sys.getsizeof(o.__dict__) for o in plains[:1000]) / 1000
sb = sum(sys.getsizeof(o) for o in slots[:1000]) / 1000
print(f'per instance: plain {pb:.0f}b, slotted {sb:.0f}b')
print(f'for {N:,}: plain {pb*N/1e6:.1f} MB, slotted {sb*N/1e6:.1f} MB')
输出:
per instance: plain 144b, slotted 48b
for 200,000: plain 28.8 MB, slotted 9.6 MB
是 144 字节,不是 344。只测一个对象会误导人,因为 CPython 让同一个类的实例共享键的存储:第一个实例为键付了账,后面的实例就不用再付。只测一个对象,就把共享的开销全算到了它一个头上。
这一点在别处也用得上:对单个对象调用 getsizeof,得到的并不是每个实例的开销。
20 万个对象时,实际省下大约 19 MB,差不多三分之一。如果你在内存里放着上百万行数据,这很可观。几万个以下,根本察觉不到。
白送的好处:拼错了会报错
p.z = 99
print('plain accepts a typo:', p.z)
try:
s.z = 99
except AttributeError as err:
print('slotted rejects it:', type(err).__name__ + ':', err)
输出:
plain accepts a typo: 99
slotted rejects it: AttributeError: 'Slotted' object has no attribute 'z'
就算一个类永远不会有内存问题,这也是给它加 __slots__ 的理由。第 8 篇演示过:普通对象会悄无声息地接受一个没声明过的属性,一次拼错的赋值就凭空多出一个没人读的字段。有了 __slots__,这种错误会在你写错的那一行直接报出来。
代价
class SlottedSub(Slotted):
pass
sub = SlottedSub(1,2)
sub.anything = 5 # subclass without __slots__ regains a __dict__
print('subclass regained __dict__:', hasattr(sub, '__dict__'), '| set', sub.anything)
输出:
subclass regained __dict__: True | set 5
子类如果不自己声明 __slots__,就会重新得到 __dict__,省下的内存和防拼错的保护都没了。继承链上的每个类都得声明。
你还失去了动态挂属性的能力,而有些库恰恰要这么做,比如缓存、ORM,以及任何会往实例上加东西的库。如果某个库遇到用了 slots 的类就出问题,原因通常就在这里。
省事的写法
@dataclass(slots=True)
class SlottedDC:
x: int
y: int
print('dataclass(slots=True):', SlottedDC(1,2), sys.getsizeof(SlottedDC(1,2)))
输出:
dataclass(slots=True): SlottedDC(x=1, y=2) 48
slots=True 从 Python 3.10 开始提供,大多数人应该用这种写法:自动生成的 __init__ 和 __repr__ 都有,再加上 slots。
记住这几点
__slots__去掉每个实例自带的字典。几十万个对象时能真正省内存,数量少了就看不出来。- 别用
getsizeof测单个对象来衡量它,键共享会让结果失真。 - 它会拒绝没声明过的属性,这才是用它更好的理由。
- 继承链上的每个类都必须声明,否则省下的内存又回去了。
它不是默认选项。等性能分析工具指出内存有问题,或者你想让一个可变的类像 NamedTuple 那样不怕拼错属性名时,再用它。