Metaclases en Python: type, __init_subclass__, __class_getitem__ y registro de clases

En Python, todo es un objeto, incluidas las clases. Y como cualquier objeto, las clases tienen un tipo: ese tipo es su metaclase. Por defecto, la metaclase de todas las clases es type. Las metaclases permiten controlar cómo se crean las clases, añadir métodos automáticamente, validar la herencia o implementar registros de plugins sin que el usuario tenga que hacer nada explícito.

Crear clases dinámicamente con type()

type tiene dos usos: cuando se llama con un argumento devuelve el tipo del objeto; cuando se llama con tres argumentos crea una clase nueva en tiempo de ejecución.

# type(nombre, bases, dict_de_atributos)
Animal = type('Animal', (), {
    'especie': 'desconocida',
    'hablar': lambda self: print(f"Soy un {self.especie}")
})

Perro = type('Perro', (Animal,), {
    'especie': 'Canis lupus familiaris'
})

rex = Perro()
rex.hablar()   # Soy un Canis lupus familiaris
print(type(Perro))  # <class 'type'>

Escribir una metaclase con __new__

Una metaclase es una clase que hereda de type. Su __new__ recibe el nombre, las bases y el namespace de la clase que se está creando:

class SingletonMeta(type):
    _instancias: dict = {}

    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=SingletonMeta):
    def __init__(self):
        self.debug = False
        self.host = "localhost"


c1 = Configuracion()
c2 = Configuracion()
print(c1 is c2)   # True — siempre la misma instancia

Metaclase con __new__: validar la clase al crearla

class ValidarMetodos(type):
    def __new__(mcs, nombre, bases, namespace):
        for clave, valor in namespace.items():
            if callable(valor) and not clave.startswith('_'):
                if not valor.__doc__:
                    raise TypeError(
                        f"El método '{nombre}.{clave}' debe tener docstring"
                    )
        return super().__new__(mcs, nombre, bases, namespace)


class API(metaclass=ValidarMetodos):
    def obtener_usuarios(self):
        """Devuelve la lista de usuarios activos."""
        return []

    def borrar_usuario(self, uid: int):
        """Elimina el usuario con el id dado."""
        pass

# Esto lanzaría TypeError:
# class APIRota(metaclass=ValidarMetodos):
#     def sin_doc(self):
#         pass

__init_subclass__: alternativa más sencilla

Desde Python 3.6, __init_subclass__ permite reaccionar a la creación de subclases sin necesidad de escribir una metaclase completa. Es la opción recomendada para la mayoría de los casos:

class Forma:
    subclases: list = []

    def __init_subclass__(cls, color: str = "negro", **kwargs):
        super().__init_subclass__(**kwargs)
        cls.color = color
        Forma.subclases.append(cls)
        print(f"Nueva subclase registrada: {cls.__name__} (color={color})")


class Circulo(Forma, color="rojo"):
    pass

class Cuadrado(Forma, color="azul"):
    pass

print(Forma.subclases)  # [Circulo, Cuadrado]
print(Circulo.color)    # rojo

__class_getitem__: habilitar MiClase[tipo]

__class_getitem__ se llama cuando escribes MiClase[algo]. Es lo que permite la sintaxis de genéricos en Python:

class Pila:
    def __class_getitem__(cls, tipo):
        nombre = f"Pila[{tipo.__name__ if hasattr(tipo, '__name__') else tipo}]"
        return type(nombre, (cls,), {'_tipo': tipo})

    def __init__(self):
        self._datos: list = []

    def push(self, valor):
        if not isinstance(valor, self.__class__._tipo):
            raise TypeError(f"Se esperaba {self.__class__._tipo.__name__}")
        self._datos.append(valor)

    def pop(self):
        return self._datos.pop()


PilaInt = Pila[int]
p = PilaInt()
p.push(1)
p.push(2)
p.push("hola")   # TypeError: Se esperaba int

Sistema de registro automático de plugins

El caso de uso más habitual de las metaclases en producción es un registro de plugins: cada subclase que alguien defina se registra automáticamente sin que tenga que llamar a ninguna función.

class RegistroPlugin(type):
    _registro: dict[str, type] = {}

    def __new__(mcs, nombre, bases, namespace):
        cls = super().__new__(mcs, nombre, bases, namespace)
        if bases:  # no registrar la clase base
            alias = namespace.get('nombre', nombre.lower())
            mcs._registro[alias] = cls
        return cls

    @classmethod
    def obtener(mcs, nombre: str) -> type:
        try:
            return mcs._registro[nombre]
        except KeyError:
            raise ValueError(f"Plugin '{nombre}' no registrado") from None


class Exportador(metaclass=RegistroPlugin):
    """Clase base de exportadores."""
    def exportar(self, datos) -> bytes:
        raise NotImplementedError


class ExportadorJSON(Exportador):
    nombre = 'json'
    def exportar(self, datos) -> bytes:
        import json
        return json.dumps(datos).encode()


class ExportadorCSV(Exportador):
    nombre = 'csv'
    def exportar(self, datos) -> bytes:
        import csv, io
        buf = io.StringIO()
        w = csv.writer(buf)
        for fila in datos:
            w.writerow(fila)
        return buf.getvalue().encode()


# En tiempo de ejecución, cargamos el plugin por nombre
plugin = RegistroPlugin.obtener('json')
resultado = plugin().exportar({"clave": "valor"})
print(resultado)  # b'{"clave": "valor"}'

¿Metaclase o __init_subclass__?

Usa __init_subclass__ cuando solo necesitas reaccionar a la creación de subclases: es más sencillo, más legible y evita conflictos de metaclases cuando hay herencia múltiple. Reserva las metaclases para cuando necesitas modificar cómo se crea la clase (__new__), interceptar el proceso completo de creación o mantener registros a nivel de metaclase (como el ejemplo de plugins anterior).

COMPARTE ESTE ARTÍCULO

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