Todo objeto Python comum carrega um dicionário com os atributos dele. É isso que te deixa acrescentar um atributo a qualquer momento — e é por isso que objetos custam mais memória do que você imaginaria.
O __slots__ recusa esse dicionário.
A medição
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__'))
Ele imprime:
plain 344
slotted 48
slotted has no __dict__: True
O objeto com slots guarda os dois atributos num array de tamanho fixo em vez de um dicionário.
Uma observação honesta sobre esse número
Medido em várias instâncias em vez de uma, a diferença é menor:
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')
Ele imprime:
per instance: plain 144b, slotted 48b
for 200,000: plain 28.8 MB, slotted 9.6 MB
144 bytes, não 344. A medição de um objeto só era enganosa porque o CPython compartilha o armazenamento das chaves entre instâncias da mesma classe — a primeira instância paga pelas chaves, as seguintes não. Meça um objeto e você atribui a ele sozinho um custo que é compartilhado.
Isso vale saber em geral: um getsizeof num objeto não é o custo por instância.
Economia real com 200.000 objetos: cerca de 19 MB, mais ou menos um terço. Relevante se você mantém milhões de registros na memória. Invisível abaixo de algumas dezenas de milhares.
O benefício de graça: erros de digitação falham
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)
Ele imprime:
plain accepts a typo: 99
slotted rejects it: AttributeError: 'Slotted' object has no attribute 'z'
Esse é o motivo para usar __slots__ numa classe que nunca vai ter problema de memória. A parte 8 mostrou um objeto comum aceitando em silêncio um atributo não declarado — uma atribuição com o nome errado que cria um campo que ninguém lê. O __slots__ transforma isso em erro na linha em que você escreveu.
O que você abre mão
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)
Ele imprime:
subclass regained __dict__: True | set 5
Uma subclasse que não declara o próprio __slots__ recupera um __dict__, e tanto a economia quanto a proteção contra erros de digitação vão embora. Toda classe da cadeia precisa declarar.
Você também perde a capacidade de pendurar atributos dinamicamente, o que algumas bibliotecas fazem — cache, ORMs, e qualquer coisa que decore instâncias. Se uma biblioteca se comporta mal com uma classe com slots, normalmente é por isso.
A versão fácil
@dataclass(slots=True)
class SlottedDC:
x: int
y: int
print('dataclass(slots=True):', SlottedDC(1,2), sys.getsizeof(SlottedDC(1,2)))
Ele imprime:
dataclass(slots=True): SlottedDC(x=1, y=2) 48
O slots=True chegou no Python 3.10 e é a versão que a maioria das pessoas deveria usar — o __init__ e o __repr__ gerados, com os slots.
O que lembrar
-
__slots__elimina o dicionário por instância. Economia real na casa das centenas de milhares de objetos, invisível abaixo disso. -
Não meça com
getsizeofnum objeto só — o compartilhamento de chaves torna isso enganoso. -
Ele recusa atributos não declarados, que é o melhor motivo para usá-lo.
-
Toda classe da cadeia de herança precisa declarar, ou a economia é desfeita.
Não é padrão. Use quando um profiler apontar para memória, ou quando você quiser uma classe mutável com a resistência a erros de digitação de uma NamedTuple.