Todo objeto normal de Python carga un diccionario con sus atributos. Eso es lo que te deja agregar un atributo en cualquier momento, y también la razón por la que los objetos cuestan más memoria de lo que uno supondría.
__slots__ renuncia a eso.
La medición
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__'))
Imprime:
plain 344
slotted 48
slotted has no __dict__: True
El objeto con slots guarda sus dos atributos en un arreglo fijo en lugar de un diccionario.
Una nota honesta sobre ese número
Medido sobre muchas instancias en vez de una sola, la diferencia es 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')
Imprime:
per instance: plain 144b, slotted 48b
for 200,000: plain 28.8 MB, slotted 9.6 MB
144 bytes, no 344. La medición sobre un solo objeto engañaba porque CPython comparte el almacenamiento de las claves entre instancias de la misma clase: la primera instancia paga por las claves, las siguientes no. Mide un objeto y le atribuyes a él solo un costo compartido.
Eso vale la pena saberlo en general: un getsizeof sobre un objeto no es un costo por instancia.
Ahorro real con 200.000 objetos: unos 19 MB, más o menos un tercio. Significativo si tienes millones de filas en memoria. Invisible por debajo de las decenas de miles.
El beneficio gratis: los errores de tipeo fallan
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)
Imprime:
plain accepts a typo: 99
slotted rejects it: AttributeError: 'Slotted' object has no attribute 'z'
Esta es la razón para usar __slots__ en una clase que nunca va a tener un problema de memoria. La parte 8 mostró un objeto normal aceptando en silencio un atributo no declarado: una asignación mal escrita que crea un campo que nadie lee. __slots__ convierte eso en un error en la línea donde lo escribiste.
Lo que resignas
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)
Imprime:
subclass regained __dict__: True | set 5
Una subclase que no declara sus propios __slots__ recupera un __dict__, y con eso se pierden tanto el ahorro como la protección contra errores de tipeo. Cada clase de la cadena tiene que declararlo.
También pierdes la posibilidad de colgar atributos dinámicamente, cosa que algunas bibliotecas hacen: cachés, ORMs, y todo lo que decore instancias. Si una biblioteca se porta mal contra una clase con slots, normalmente es por esto.
La versión fácil
@dataclass(slots=True)
class SlottedDC:
x: int
y: int
print('dataclass(slots=True):', SlottedDC(1,2), sys.getsizeof(SlottedDC(1,2)))
Imprime:
dataclass(slots=True): SlottedDC(x=1, y=2) 48
slots=True llegó en Python 3.10 y es la versión que la mayoría debería usar: el __init__ y el __repr__ generados, más los slots.
Qué recordar
-
__slots__elimina el diccionario por instancia. Ahorro real con cientos de miles de objetos, invisible por debajo de eso. -
No lo midas con
getsizeofsobre un solo objeto: compartir las claves hace que esa medición engañe. -
Rechaza los atributos no declarados, que es la mejor razón para usarlo.
-
Cada clase de una cadena de herencia tiene que declararlo, o el ahorro se deshace.
No es una opción por defecto. Recurre a él cuando un profiler apunte a la memoria, o cuando quieras una clase mutable con la resistencia a errores de tipeo de un NamedTuple.