Blog

Python 的 __slots__,以及内存什么时候真的重要

__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 那样不怕拼错属性名时,再用它。

这篇文章对你有帮助吗?

点一颗爱心来评分!

平均评分 0 / 5. 投票总数: 0

还没有人投票。来做第一个评分的人吧。