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.
