__slots__: impacto real en memoria y velocidad

Cada vez que Python crea una instancia de una clase ordinaria, bajo el capó se reserva un diccionario —__dict__— que actúa como tabla de despacho para sus atributos. Es flexible, genial para prototipado, y completamente innecesario cuando sabes exactamente qué atributos va a tener tu objeto desde el diseño. __slots__ es la forma de decirle al intérprete: “estos son todos los atributos, nada más, usa un array de tamaño fijo”.

El mecanismo es concreto: cuando defines __slots__ = ['x', 'y'] en el cuerpo de la clase, CPython no crea __dict__ en las instancias. En su lugar, genera descriptores de tipo member_descriptor en la clase misma —uno por slot— que apuntan a posiciones fijas en un array compacto adjunto al objeto. El acceso a un atributo así es una indirección directa a memoria, no un lookup en un hash table.

¿Cuándo vale la pena pagar ese coste de diseño? Cuando tienes decenas de miles (o millones) de instancias vivas simultáneamente. Un __dict__ vacío en CPython 3.11+ ocupa alrededor de 232 bytes solo de overhead; eliminar ese diccionario y el puntero que lo referencia supone en la práctica 40-60 bytes menos por instancia. En un millón de objetos eso es entre 40 y 60 MB devueltos al sistema operativo sin tocar ningún algoritmo.

Lo que se rompe si lo usas mal: si una subclase no declara __slots__ o lo deja vacío, esa subclase recupera su __dict__ y pierdes el beneficio. Peor aún, en herencia múltiple, si dos clases base definen __slots__ con el mismo nombre, obtienes slots duplicados en el layout del objeto —acceso inconsistente, más memoria de la que evitabas—. El problema no lanza excepción; simplemente el descriptor de la clase más a la izquierda en el MRO gana y el otro queda inaccesible.

import sys
import tracemalloc
from dataclasses import dataclass
from typing import NamedTuple

# ------------------------------------------------------------
# Clase sin slots (baseline)
# ------------------------------------------------------------
class PointDict:
    def __init__(self, x: float, y: float, z: float) -> None:
        self.x = x
        self.y = y
        self.z = z


# ------------------------------------------------------------
# Clase con slots declarados explícitamente
# ------------------------------------------------------------
class PointSlots:
    __slots__ = ("x", "y", "z")  # tupla, no lista; convención habitual

    def __init__(self, x: float, y: float, z: float) -> None:
        self.x = x
        self.y = y
        self.z = z


# ------------------------------------------------------------
# Subclase correcta: también declara __slots__ (aunque vacío)
# para no reintroducir __dict__
# ------------------------------------------------------------
class Point3DSlots(PointSlots):
    __slots__ = ("label",)  # añade un slot nuevo sin romper el padre

    def __init__(self, x: float, y: float, z: float, label: str) -> None:
        super().__init__(x, y, z)
        self.label = label


# ------------------------------------------------------------
# Medición de memoria por instancia
# ------------------------------------------------------------
def measure_bytes(cls, *args) -> int:
    instance = cls(*args)
    # sys.getsizeof no incluye objetos referenciados; útil para comparar
    # el tamaño del objeto en sí, sin contar los floats que referencia
    return sys.getsizeof(instance)


print("=== Tamaño del objeto en sí ===")
print(f"PointDict   : {measure_bytes(PointDict,   1.0, 2.0, 3.0)} bytes")
print(f"PointSlots  : {measure_bytes(PointSlots,  1.0, 2.0, 3.0)} bytes")
print(f"Point3DSlots: {measure_bytes(Point3DSlots, 1.0, 2.0, 3.0, 'A')} bytes")

# ------------------------------------------------------------
# Medición de heap total al crear N instancias
# ------------------------------------------------------------
N = 500_000

tracemalloc.start()
points_dict = [PointDict(float(i), float(i), float(i)) for i in range(N)]
snapshot_dict = tracemalloc.take_snapshot()
tracemalloc.stop()

tracemalloc.start()
points_slots = [PointSlots(float(i), float(i), float(i)) for i in range(N)]
snapshot_slots = tracemalloc.take_snapshot()
tracemalloc.stop()

def top_total(snapshot) -> int:
    stats = snapshot.statistics("lineno")
    return sum(s.size for s in stats)

print("\n=== Heap total para 500 000 instancias ===")
print(f"PointDict  : {top_total(snapshot_dict)  / 1_048_576:.1f} MB")
print(f"PointSlots : {top_total(snapshot_slots) / 1_048_576:.1f} MB")

# ------------------------------------------------------------
# Confirmación: los slots no tienen __dict__ en la instancia
# ------------------------------------------------------------
print("\n=== Presencia de __dict__ ===")
print(f"PointDict   tiene __dict__: {hasattr(PointDict(0, 0, 0), '__dict__')}")
print(f"PointSlots  tiene __dict__: {hasattr(PointSlots(0, 0, 0), '__dict__')}")

# ------------------------------------------------------------
# Intentar añadir atributo no declarado lanza AttributeError
# ------------------------------------------------------------
try:
    p = PointSlots(1.0, 2.0, 3.0)
    p.color = "red"  # no está en __slots__
except AttributeError as exc:
    print(f"\nError esperado: {exc}")

Qué está pasando y por qué importa cada decisión

La declaración de __slots__ como tupla en vez de lista es solo convención —ambas funcionan— pero una tupla deja claro que el contrato es inmutable por diseño. CPython la itera una sola vez durante la creación de la clase; después almacena los descriptores en el tp_members de la estructura C subyacente.

sys.getsizeof mide el tamaño del objeto contenedor, no de los objetos que referencia. Por eso es la herramienta correcta aquí: te dice cuánto ocupa el PyObject en heap, no los float que comparten referencia con otras listas. Verás típicamente 48-56 bytes para PointSlots frente a 104-120 bytes para PointDict en CPython 3.11, dependiendo del número de atributos.

tracemalloc captura el heap completo de Python, lo que incluye el overhead real del __dict__ de cada instancia, sus buckets internos y la tabla de referencias. La diferencia entre ambas mediciones —habitualmente 25-35 MB en 500 000 instancias con tres atributos— es la cifra que le mostrarías a un colega para justificar la migración.

Point3DSlots hereda de PointSlots y declara su propio __slots__. Fíjate en que no repite x, y, z: si lo hiciera, el descriptor del padre quedaría ensombrecido y perderías acceso consistente. La herencia funciona bien con __slots__ siempre que cada nivel declare solo los atributos que él mismo introduce.

El bloque try/except al final no es pedagogía: es el trade-off que aceptas. Sin __dict__, la instancia no puede crecer dinámicamente. Eso es exactamente lo que quieres cuando el diseño es estable y la presión de memoria es real.

Errores que debes conocer

Error: subclase que olvida declarar __slots__, reintroduciendo __dict__ y anulando el beneficio del padre.

# ❌ Wrong
class PointSlots:
    __slots__ = ("x", "y", "z")

class ColoredPoint(PointSlots):
    # sin __slots__ → CPython añade __dict__ a la instancia
    def __init__(self, x, y, z, color):
        super().__init__(x, y, z)
        self.color = color

p = ColoredPoint(1, 2, 3, "red")
print(hasattr(p, "__dict__"))  # True — toda la memoria ahorrada se pierde

# ✅ Right
class ColoredPoint(PointSlots):
    __slots__ = ("color",)  # declara solo los atributos nuevos

    def __init__(self, x, y, z, color):
        super().__init__(x, y, z)
        self.color = color

Declarar __slots__ en la subclase, aunque solo tenga un elemento, es lo que le dice a CPython que no cree __dict__ para esas instancias.


Error: herencia múltiple con slots del mismo nombre en dos bases, generando acceso inconsistente sin aviso.

# ❌ Wrong
class A:
    __slots__ = ("value",)

class B:
    __slots__ = ("value",)  # mismo nombre que A

class C(A, B):
    __slots__ = ()

# El descriptor de A.value gana por MRO; B.value queda inaccesible
# pero ocupa espacio en el layout. No hay error, solo confusión y bytes
# desperdiciados.
c = C()
c.value = 42
print(type(C.value))  # <member 'value' of 'A' objects> — B.value ignorado

# ✅ Right
class A:
    __slots__ = ("a_value",)

class B:
    __slots__ = ("b_value",)  # nombres únicos por clase

class C(A, B):
    __slots__ = ()

Usar nombres únicos por clase en herencia múltiple evita el solapamiento silencioso; si el slot compartido es realmente necesario, extrae la lógica a una clase base común.


Error: usar __slots__ en una clase que necesita __weakref__ o pickle sin declarar los slots especiales correspondientes.

# ❌ Wrong
import weakref

class Node:
    __slots__ = ("value",)

n = Node()
n.value = 10
ref = weakref.ref(n)  # TypeError: cannot create weak reference to 'Node' object

# ✅ Right
class Node:
    __slots__ = ("value", "__weakref__")  # habilita referencias débiles explícitamente

n = Node()
n.value = 10
ref = weakref.ref(n)  # funciona

__weakref__ y __dict__ son slots especiales que CPython añade automáticamente a clases ordinarias pero omite cuando usas __slots__; si los necesitas, los declaras tú.

105

Dejar un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

Scroll al inicio