__slots__ y weakref en Python: optimizar memoria y evitar referencias circulares

Cuando creas miles de instancias de una clase, el __dict__ que Python añade a cada objeto consume más memoria de lo que parece: un diccionario vacío ocupa ya unos 200 bytes. __slots__ elimina ese diccionario y almacena los atributos directamente en la instancia, reduciendo el consumo entre un 40 y un 50 %. Por otro lado, weakref permite mantener referencias a objetos sin que esas referencias cuenten para el recolector de basura, lo que es imprescindible para cachés y grafos con referencias circulares.

__slots__ básico

class PuntoNormal:
    def __init__(self, x: float, y: float):
        self.x = x
        self.y = y


class PuntoSlots:
    __slots__ = ('x', 'y')

    def __init__(self, x: float, y: float):
        self.x = x
        self.y = y


import sys

p_normal = PuntoNormal(1.0, 2.0)
p_slots  = PuntoSlots(1.0, 2.0)

print(sys.getsizeof(p_normal))         # ~48 bytes (sin contar __dict__)
print(sys.getsizeof(p_normal.__dict__))# ~232 bytes
print(sys.getsizeof(p_slots))          # ~56 bytes (sin __dict__)

# Con __slots__ no hay __dict__:
# p_slots.__dict__   # AttributeError

Medición real de memoria con muchas instancias

import tracemalloc

def crear_objetos(clase, n: int = 100_000):
    tracemalloc.start()
    objetos = [clase(float(i), float(i)) for i in range(n)]
    _, pico = tracemalloc.get_traced_memory()
    tracemalloc.stop()
    return pico / 1024 / 1024, objetos  # MB

mb_normal, _ = crear_objetos(PuntoNormal)
mb_slots,  _ = crear_objetos(PuntoSlots)

print(f"Sin __slots__: {mb_normal:.1f} MB")   # ~28.8 MB
print(f"Con __slots__: {mb_slots:.1f} MB")    # ~10.7 MB
ahorro = (1 - mb_slots / mb_normal) * 100
print(f"Ahorro:        {ahorro:.0f}%")         # ~63%

__slots__ con herencia

Al heredar de una clase con __slots__, la subclase también debe definir __slots__. Si no lo hace, Python le añade un __dict__ y se pierde la mayoría del beneficio:

class Vector2D:
    __slots__ = ('x', 'y')

    def __init__(self, x: float, y: float):
        self.x = x
        self.y = y

class Vector3D(Vector2D):
    __slots__ = ('z',)   # solo los atributos nuevos

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

    def magnitud(self) -> float:
        return (self.x**2 + self.y**2 + self.z**2) ** 0.5


v = Vector3D(1, 2, 3)
print(v.magnitud())   # 3.7416...

weakref: referencias débiles

Una referencia débil apunta a un objeto sin incrementar su contador de referencias. Cuando el objeto no tiene más referencias fuertes, el recolector de basura lo elimina aunque existan referencias débiles apuntando a él.

import weakref

class Recurso:
    def __init__(self, nombre: str):
        self.nombre = nombre

    def __del__(self):
        print(f"Destruyendo {self.nombre}")


recurso = Recurso("fichero_config")
ref_debil = weakref.ref(recurso)

print(ref_debil())         # <__main__.Recurso object ...>
print(ref_debil().nombre)  # fichero_config

del recurso               # Destruyendo fichero_config
print(ref_debil())        # None — el objeto ya no existe

WeakValueDictionary: caché sin fugas de memoria

El problema clásico de las cachés en memoria es que retienen objetos aunque nadie más los use. WeakValueDictionary almacena referencias débiles a los valores: cuando el único referenciador externo desaparece, la entrada desaparece de la caché automáticamente.

import weakref

class ConexionDB:
    def __init__(self, host: str):
        self.host = host
        print(f"Abriendo conexión a {host}")

    def __del__(self):
        print(f"Cerrando conexión a {self.host}")


class PoolConexiones:
    def __init__(self):
        self._cache: weakref.WeakValueDictionary = weakref.WeakValueDictionary()

    def obtener(self, host: str) -> ConexionDB:
        if host in self._cache:
            print(f"Reutilizando conexión a {host}")
            return self._cache[host]
        conn = ConexionDB(host)
        self._cache[host] = conn
        return conn


pool = PoolConexiones()

c1 = pool.obtener("localhost")   # Abriendo conexión a localhost
c2 = pool.obtener("localhost")   # Reutilizando conexión a localhost
print(c1 is c2)                  # True

del c1
del c2
# Cerrando conexión a localhost — se destruye al no quedar referencias fuertes

c3 = pool.obtener("localhost")   # Abriendo conexión a localhost (nueva)

WeakSet y WeakKeyDictionary

import weakref

class Observador:
    def __init__(self, nombre: str):
        self.nombre = nombre

    def notificar(self, evento: str):
        print(f"[{self.nombre}] recibió: {evento}")


class Emisor:
    def __init__(self):
        self._observadores: weakref.WeakSet = weakref.WeakSet()

    def suscribir(self, obs: Observador):
        self._observadores.add(obs)

    def emitir(self, evento: str):
        for obs in list(self._observadores):
            obs.notificar(evento)


emisor = Emisor()
obs1 = Observador("Logger")
obs2 = Observador("Analytics")
emisor.suscribir(obs1)
emisor.suscribir(obs2)

emisor.emitir("inicio")   # Logger y Analytics reciben el evento

del obs2
emisor.emitir("datos")    # Solo Logger — obs2 desapareció automáticamente

Limitaciones de __slots__

Antes de usar __slots__ de forma generalizada, considera estas restricciones:

  • No puedes añadir atributos dinámicos a la instancia (no hay __dict__).
  • Las instancias no son débilmente referenciables por defecto; necesitas incluir '__weakref__' en __slots__.
  • La herencia múltiple entre clases con slots no vacíos es complicada y en general se evita.
  • Pickle, copy y algunas bibliotecas que esperan __dict__ pueden fallar.

La regla práctica: usa __slots__ cuando creas decenas de miles de instancias y cada byte importa, como en simulaciones, parsers o representaciones de vértices en gráficos.

COMPARTE ESTE ARTÍCULO

COMPARTIR EN FACEBOOK
COMPARTIR EN TWITTER
COMPARTIR EN LINKEDIN
COMPARTIR EN WHATSAPP