Metaprogramación en Python: Metaclases, Descriptores y slots
La metaprogramación es la capacidad de un programa de inspeccionar, modificar o generar código en tiempo de ejecución. Python expone su modelo de objetos de forma extraordinariamente transparente, lo que permite técnicas avanzadas como metaclases, descriptores y personalización del sistema de tipos que son la base de frameworks como Django ORM, SQLAlchemy, Pydantic y dataclasses.
El modelo de objetos de Python
En Python todo es un objeto, incluyendo las clases mismas. Una clase es una instancia de type, y type es su propia metaclase:
class MiClase:
pass
print(type(42)) # <class 'int'>
print(type(MiClase)) # <class 'type'>
print(type(type)) # <class 'type'> ← type es su propio tipo
# Crear una clase dinámicamente con type(nombre, bases, dict)
Animal = type('Animal', (object,), {
'sonido': 'genérico',
'hablar': lambda self: f"Soy un animal, hago {self.sonido}",
})
perro = Animal()
print(perro.hablar()) # Soy un animal, hago genérico
Descriptores: el motor de los atributos
Un descriptor es cualquier objeto que implementa __get__, __set__ o __delete__. Son el mecanismo detrás de property, classmethod, staticmethod y los campos de Django/SQLAlchemy.
Descriptor de datos vs. no-datos
class ValidadorRango:
"""Descriptor que valida que un valor numérico esté en un rango."""
def __set_name__(self, owner, name):
# Llamado automáticamente al crear la clase propietaria
self.nombre_publico = name
self.nombre_privado = f"_{name}"
def __init__(self, minimo, maximo):
self.minimo = minimo
self.maximo = maximo
def __get__(self, obj, objtype=None):
if obj is None:
return self # Acceso desde la clase, no instancia
return getattr(obj, self.nombre_privado, None)
def __set__(self, obj, valor):
if not isinstance(valor, (int, float)):
raise TypeError(f"{self.nombre_publico} debe ser numérico, no {type(valor).__name__}")
if not (self.minimo <= valor <= self.maximo):
raise ValueError(f"{self.nombre_publico} debe estar entre {self.minimo} y {self.maximo}, recibido: {valor}")
setattr(obj, self.nombre_privado, valor)
def __delete__(self, obj):
raise AttributeError(f"{self.nombre_publico} no puede eliminarse")
class Producto:
precio = ValidadorRango(0.01, 99_999.99)
stock = ValidadorRango(0, 100_000)
descuento = ValidadorRango(0, 100)
def __init__(self, nombre, precio, stock):
self.nombre = nombre
self.precio = precio # Pasa por __set__ del descriptor
self.stock = stock
self.descuento = 0
p = Producto("Laptop", 999.99, 50)
print(p.precio) # 999.99
try:
p.precio = -10 # ValueError
except ValueError as e:
print(e) # precio debe estar entre 0.01 y 99999.99, recibido: -10
try:
p.stock = "mucho" # TypeError
except TypeError as e:
print(e)
Descriptor lazy (caché por instancia)
class AtributoLazy:
"""Descriptor que calcula el valor una vez y lo cachea en la instancia."""
def __set_name__(self, owner, name):
self.nombre = name
self.funcion = None
def __init__(self, funcion):
self.funcion = funcion
def __get__(self, obj, objtype=None):
if obj is None:
return self
# Si ya está calculado, está en __dict__ de la instancia y
# tiene prioridad sobre el descriptor (no-data descriptor)
valor = self.funcion(obj)
obj.__dict__[self.nombre] = valor # cachear
return valor
def __set_name__(self, owner, name):
self.nombre = name
class Circulo:
def __init__(self, radio):
self.radio = radio
@AtributoLazy
def area(self):
import math
print("Calculando área...")
return math.pi * self.radio ** 2
@AtributoLazy
def perimetro(self):
import math
print("Calculando perímetro...")
return 2 * math.pi * self.radio
c = Circulo(5)
print(c.area) # "Calculando área..." → 78.53...
print(c.area) # Sin mensaje — usa caché de __dict__
print(c.perimetro) # "Calculando perímetro..." → 31.41...
slots: optimización de memoria
Por defecto, Python almacena los atributos de instancia en un diccionario __dict__, que consume ~200-300 bytes por instancia. Con __slots__ usas descriptores de ranura fijos:
import sys
class PuntoDict:
def __init__(self, x, y, z):
self.x, self.y, self.z = x, y, z
class PuntoSlots:
__slots__ = ('x', 'y', 'z')
def __init__(self, x, y, z):
self.x, self.y, self.z = x, y, z
p_dict = PuntoDict(1.0, 2.0, 3.0)
p_slots = PuntoSlots(1.0, 2.0, 3.0)
print(sys.getsizeof(p_dict)) # ~48 bytes (solo el objeto)
print(sys.getsizeof(p_dict.__dict__)) # ~232 bytes (el diccionario)
print(sys.getsizeof(p_slots)) # ~64 bytes (sin __dict__)
# Con 1 millón de instancias:
n = 1_000_000
lista_dict = [PuntoDict(i, i, i) for i in range(n)]
lista_slots = [PuntoSlots(i, i, i) for i in range(n)]
# Ahorro típico: 150-200 MB para 1M instancias
# Restricciones de __slots__
try:
p_slots.w = 4.0 # AttributeError: no atributo 'w'
except AttributeError as e:
print(e)
# Combinar __slots__ con herencia
class Punto4D(PuntoSlots):
__slots__ = ('w',) # Solo añade el nuevo atributo
def __init__(self, x, y, z, w):
super().__init__(x, y, z)
self.w = w
getattr y getattribute
class ProxyConfigurable:
"""Proxy que delega atributos no encontrados a un objeto interno."""
def __init__(self, objeto_base):
# Usar object.__setattr__ para evitar recursión infinita
object.__setattr__(self, '_base', objeto_base)
object.__setattr__(self, '_accesos', {})
def __getattribute__(self, nombre):
# Se llama para CUALQUIER acceso a atributo
# Peligro: muy fácil causar recursión infinita
if nombre.startswith('_'):
return object.__getattribute__(self, nombre)
accesos = object.__getattribute__(self, '_accesos')
accesos[nombre] = accesos.get(nombre, 0) + 1
return object.__getattribute__(self, nombre)
def __getattr__(self, nombre):
# Solo se llama cuando el atributo NO existe en la instancia
base = object.__getattribute__(self, '_base')
return getattr(base, nombre)
def estadisticas(self):
return object.__getattribute__(self, '_accesos')
import io
proxy = ProxyConfigurable(io.StringIO("hola mundo"))
proxy.read()
proxy.read()
proxy.seek(0)
print(proxy.estadisticas()) # {'read': 2, 'seek': 1, ...}
Metaclases: fábricas de clases
class MetaSingleton(type):
"""Metaclase que implementa el patrón Singleton."""
_instancias = {}
def __call__(cls, *args, **kwargs):
if cls not in cls._instancias:
cls._instancias[cls] = super().__call__(*args, **kwargs)
return cls._instancias[cls]
class Configuracion(metaclass=MetaSingleton):
def __init__(self):
self.debug = False
self.db_url = "sqlite:///app.db"
c1 = Configuracion()
c2 = Configuracion()
print(c1 is c2) # True — misma instancia
class MetaRegistro(type):
"""Metaclase que registra automáticamente todas las subclases."""
_registro: dict = {}
def __init_subclass__(cls, tipo=None, **kwargs):
super().__init_subclass__(**kwargs)
if tipo:
MetaRegistro._registro[tipo] = cls
@classmethod
def obtener(mcs, tipo: str):
if tipo not in mcs._registro:
raise KeyError(f"Tipo desconocido: {tipo}. Disponibles: {list(mcs._registro)}")
return mcs._registro[tipo]
class Conversor(metaclass=MetaRegistro):
def convertir(self, datos): raise NotImplementedError
class ConversorPDF(Conversor, tipo="pdf"):
def convertir(self, datos): return f"PDF: {datos}"
class ConversorDocx(Conversor, tipo="docx"):
def convertir(self, datos): return f"DOCX: {datos}"
class ConversorMp4(Conversor, tipo="mp4"):
def convertir(self, datos): return f"MP4: {datos}"
# Factory pattern automático
tipo = "pdf"
conversor = MetaRegistro.obtener(tipo)()
print(conversor.convertir("mi_archivo")) # PDF: mi_archivo
Metaclase que añade validación automática
class MetaValidado(type):
"""Metaclase que envuelve setattr con validadores declarativos."""
def __new__(mcs, nombre, bases, namespace):
validadores = {
k: v for k, v in namespace.items()
if isinstance(v, ValidadorRango)
}
namespace['_validadores'] = validadores
cls = super().__new__(mcs, nombre, bases, namespace)
return cls
class ModeloBase(metaclass=MetaValidado):
def __setattr__(self, nombre, valor):
if nombre in self._validadores:
self._validadores[nombre].__set__(self, valor)
else:
super().__setattr__(nombre, valor)
class Empleado(ModeloBase):
salario = ValidadorRango(1000, 500_000)
edad = ValidadorRango(16, 100)
def __init__(self, nombre, salario, edad):
self.nombre = nombre
self.salario = salario
self.edad = edad
emp = Empleado("Ana", 35000, 30)
print(emp.salario) # 35000
try:
emp.salario = -100 # ValueError
except ValueError as e:
print(e)
init_subclass: alternativa moderna a metaclases
class Plugin:
"""Sistema de plugins sin metaclases."""
_plugins: dict[str, type] = {}
def __init_subclass__(cls, nombre: str = "", **kwargs):
super().__init_subclass__(**kwargs)
if nombre:
Plugin._plugins[nombre] = cls
print(f"Plugin registrado: {nombre}")
@classmethod
def crear(cls, nombre: str, *args, **kwargs):
if nombre not in cls._plugins:
raise ValueError(f"Plugin desconocido: {nombre}")
return cls._plugins[nombre](*args, **kwargs)
class PluginJSON(Plugin, nombre="json"):
def procesar(self, texto): return f"JSON({texto})"
class PluginXML(Plugin, nombre="xml"):
def procesar(self, texto): return f"XML({texto})"
# Uso
p = Plugin.crear("json")
print(p.procesar("datos")) # JSON(datos)
print(Plugin._plugins) # {'json': <class PluginJSON>, 'xml': <class PluginXML>}
class_getitem para genéricos
from typing import Generic, TypeVar
T = TypeVar('T')
class Pila(Generic[T]):
"""Pila tipada con soporte para genéricos."""
def __init__(self) -> None:
self._items: list[T] = []
def push(self, item: T) -> None:
self._items.append(item)
def pop(self) -> T:
if not self._items:
raise IndexError("Pila vacía")
return self._items.pop()
def __len__(self) -> int:
return len(self._items)
def __repr__(self) -> str:
return f"Pila({self._items!r})"
pila_int: Pila[int] = Pila()
pila_int.push(1)
pila_int.push(2)
print(pila_int.pop()) # 2
pila_str: Pila[str] = Pila()
pila_str.push("hola")
Protocolo dunder completo para objetos de datos
from functools import total_ordering
import math
@total_ordering # genera __le__, __gt__, __ge__ a partir de __eq__ y __lt__
class Vector:
__slots__ = ('_x', '_y', '_z')
def __init__(self, x, y, z):
self._x, self._y, self._z = float(x), float(y), float(z)
# Representación
def __repr__(self): return f"Vector({self._x}, {self._y}, {self._z})"
def __str__(self): return f"({self._x:.2f}, {self._y:.2f}, {self._z:.2f})"
# Acceso como secuencia
def __len__(self): return 3
def __getitem__(self, i): return (self._x, self._y, self._z)[i]
def __iter__(self): return iter((self._x, self._y, self._z))
# Aritmética
def __add__(self, otro): return Vector(self._x+otro._x, self._y+otro._y, self._z+otro._z)
def __sub__(self, otro): return Vector(self._x-otro._x, self._y-otro._y, self._z-otro._z)
def __mul__(self, escalar): return Vector(self._x*escalar, self._y*escalar, self._z*escalar)
def __rmul__(self, escalar):return self.__mul__(escalar)
def __neg__(self): return Vector(-self._x, -self._y, -self._z)
def __abs__(self): return math.sqrt(self._x**2 + self._y**2 + self._z**2)
# Producto punto con @ (operador de matmul)
def __matmul__(self, otro): return self._x*otro._x + self._y*otro._y + self._z*otro._z
# Comparación (por magnitud)
def __eq__(self, otro): return abs(self) == abs(otro)
def __lt__(self, otro): return abs(self) < abs(otro)
# Hashable (solo si los objetos son inmutables)
def __hash__(self): return hash((self._x, self._y, self._z))
# Contexto booleano
def __bool__(self): return abs(self) > 0
# Normalizar
def normalizar(self):
m = abs(self)
return Vector(self._x/m, self._y/m, self._z/m)
v1 = Vector(1, 2, 3)
v2 = Vector(4, 5, 6)
print(v1 + v2) # (5.00, 7.00, 9.00)
print(v1 @ v2) # 32.0 (producto punto)
print(3 * v1) # (3.00, 6.00, 9.00)
print(abs(v1)) # 3.741...
print(v1 < v2) # True
print(list(v1)) # [1.0, 2.0, 3.0]
print({v1, v2}) # hashable → puede estar en sets
Conclusión
La metaprogramación de Python no es magia oscura — es la consecuencia natural de un modelo de objetos coherente donde todo es un objeto y las clases son ciudadanos de primera clase. Los descriptores permiten crear atributos con comportamiento personalizado sin exponer getters/setters; los __slots__ reducen drásticamente el consumo de memoria en clases con millones de instancias; y las metaclases (o el más moderno __init_subclass__) permiten implementar patrones como Singleton, Registry o validación automática de forma elegante. Dominar estas herramientas es lo que distingue a un desarrollador Python intermedio de uno avanzado.
Conversiones relacionadas
Conversiones frecuentes del catálogo: