`__init_subclass__` como alternativa moderna a metaclases

Cuando necesitas que una clase base “sepa” de sus subclases en el momento de su definición, el primer instinto suele ser escribir una metaclase. Desde Python 3.6 eso casi nunca es necesario: __init_subclass__ te da el mismo poder con una fracción de la complejidad.

__init_subclass__ es un class method especial que Python invoca automáticamente en la clase base cada vez que alguien hereda de ella. El mecanismo vive en type.__init_subclass__, que es un no-op por defecto; cuando lo sobreescribes, entras justo después de que la subclase ha sido completamente construida pero antes de que el módulo que la define continúe ejecutándose. Eso te da acceso al objeto clase ya formado, con todos sus atributos y métodos resueltos.

El timing importa: la llamada ocurre al final de type.__new__, después de que el diccionario de la clase se ha consolidado. Por eso puedes inspeccionar cls.__dict__, leer atributos de clase, o incluso mutar la clase recién creada. Lo que no puedes hacer es interceptar la construcción del namespace antes de que los atributos existan —para eso todavía necesitas una metaclase real, específicamente __prepare__ o __new__.

El parámetro **kwargs es la parte más elegante: cualquier keyword que pongas en la línea class Foo(Base, key=value) llega directamente a tu __init_subclass__. Eso te permite parametrizar el comportamiento sin decoradores adicionales ni atributos de clase ad-hoc.

Si te equivocas en el contrato —no llamas a super().__init_subclass__(**kwargs), por ejemplo— rompes la cadena de herencia cooperativa y las jerarquías con múltiples bases silenciosamente dejan de funcionar.

from __future__ import annotations

import inspect
from typing import ClassVar


class PluginBase:
    """
    Clase base que actúa como registro central de plugins.
    Las subclases declaran `kind` y opcionalmente `version`.
    """

    _registry: ClassVar[dict[str, type[PluginBase]]] = {}

    def __init_subclass__(cls, kind: str | None = None, strict: bool = False, **kwargs):
        # Siempre propagar kwargs para no romper la cadena cooperativa.
        super().__init_subclass__(**kwargs)

        if kind is None:
            # Subclases intermedias (mixins, ABC parciales) pueden omitir kind.
            return

        # Validación de contrato: toda subclase con kind debe implementar run().
        if not callable(getattr(cls, "run", None)):
            raise TypeError(
                f"{cls.__name__!r} declara kind={kind!r} pero no implementa run()"
            )

        # En modo estricto, run() no puede estar heredado: debe ser propio.
        if strict and "run" not in cls.__dict__:
            raise TypeError(
                f"{cls.__name__!r} usa strict=True pero run() viene de una superclase"
            )

        # Detectar shadowing accidental: avisamos pero no bloqueamos.
        if kind in PluginBase._registry:
            existing = PluginBase._registry[kind].__name__
            import warnings
            warnings.warn(
                f"kind={kind!r} ya estaba registrado por {existing!r}; "
                f"ahora lo sobreescribe {cls.__name__!r}",
                stacklevel=2,
            )

        PluginBase._registry[kind] = cls

        # Inyectamos kind como atributo de clase para introspección posterior.
        cls._kind = kind

    @classmethod
    def get(cls, kind: str) -> type[PluginBase]:
        try:
            return cls._registry[kind]
        except KeyError:
            available = ", ".join(cls._registry)
            raise KeyError(f"Plugin {kind!r} no encontrado. Disponibles: {available}")


# ── Subclases concretas ───────────────────────────────────────────────────────

class CSVExporter(PluginBase, kind="csv"):
    def run(self, data: list) -> str:
        return "\n".join(",".join(str(v) for v in row) for row in data)


class JSONExporter(PluginBase, kind="json"):
    def run(self, data: list) -> str:
        import json
        return json.dumps(data)


# Subclase intermedia sin kind: actúa como mixin, no se registra.
class LoggedPlugin(PluginBase):
    def run(self, data):
        print(f"[{self.__class__.__name__}] ejecutando con {len(data)} items")
        return super().run(data)


class VerboseCSV(LoggedPlugin, CSVExporter, kind="csv-verbose", strict=True):
    # strict=True: run() debe definirse aquí, no puede venir solo de herencia.
    def run(self, data: list) -> str:
        super().run(data)           # dispara el log de LoggedPlugin
        return CSVExporter.run(self, data)


# ── Uso ──────────────────────────────────────────────────────────────────────

if __name__ == "__main__":
    payload = [[1, "alpha", True], [2, "beta", False]]

    for kind in ("csv", "json", "csv-verbose"):
        plugin_cls = PluginBase.get(kind)
        result = plugin_cls().run(payload)
        print(f"[{kind}] → {result!r}\n")

Lo que está pasando y por qué importa

El registro ocurre en tiempo de definición de clase, no en tiempo de instanciación. Cuando Python ejecuta class CSVExporter(PluginBase, kind="csv"):, el runtime llama a PluginBase.__init_subclass__(cls=CSVExporter, kind="csv") antes de que esa línea termine. No necesitas que nadie instancie nada; con solo importar el módulo, el plugin queda registrado.

El super().__init_subclass__(**kwargs) al principio es no negociable en producción. Si tienes una jerarquía con múltiples bases —como VerboseCSV(LoggedPlugin, CSVExporter, ...)— Python resuelve el MRO y llama a cada __init_subclass__ en cadena. Si rompes la cadena, las bases intermedias dejan de recibir la llamada silenciosamente. Pasar **kwargs hacia arriba también es crítico: si una base intermedia consume sus propios kwargs pero no pasa los del resto, obtienes un TypeError confuso en bases que esperaban recibir los suyos.

La validación con raise TypeError dentro de __init_subclass__ es especialmente poderosa porque falla en el momento de la definición, no cuando se usa el plugin. Si alguien escribe class BrokenPlugin(PluginBase, kind="x"): sin implementar run(), el error ocurre en la línea del class, con un traceback que apunta exactamente ahí.

El parámetro strict=True demuestra cómo **kwargs convierte la declaración de herencia en algo parecido a una anotación de intención: class VerboseCSV(..., kind="csv-verbose", strict=True) es legible y no requiere ningún decorador adicional.

cls.__dict__ versus getattr(cls, ...) es una distinción que aparece en strict: "run" not in cls.__dict__ comprueba si el método es propio de la subclase, sin subir por el MRO. getattr sí sube. Esa diferencia es exactamente lo que permite implementar la semántica de “tienes que definirlo tú mismo”.

Errores que debes conocer

Error: Olvidar super().__init_subclass__(**kwargs) rompe la herencia cooperativa en jerarquías con más de una base.

# ❌ Wrong
class PluginBase:
    def __init_subclass__(cls, kind=None, **kwargs):
        # Consumimos kwargs pero nunca los propagamos
        if kind:
            PluginBase._registry[kind] = cls
        # super() no llamado → LoggedPlugin nunca recibirá su llamada

# ✅ Right
class PluginBase:
    def __init_subclass__(cls, kind=None, **kwargs):
        super().__init_subclass__(**kwargs)  # primero propagar, luego tu lógica
        if kind:
            PluginBase._registry[kind] = cls

Llamar a super().__init_subclass__(**kwargs) garantiza que toda la cadena MRO reciba su oportunidad de inicializar la subclase.


Error: Asumir que __init_subclass__ es equivalente a una metaclase cuando necesitas interceptar el namespace antes de la construcción de la clase.

# ❌ Wrong — __init_subclass__ no puede hacer esto:
class Base:
    def __init_subclass__(cls, **kwargs):
        super().__init_subclass__(**kwargs)
        # Intentamos ordenar el diccionario de clase, pero ya está construido
        # y es un mappingproxy inmutable; no podemos reordenar definiciones.
        pass

# ✅ Right — para interceptar el namespace usas __prepare__ en una metaclase:
class OrderedMeta(type):
    @classmethod
    def __prepare__(mcs, name, bases, **kwargs):
        return {}  # aquí controlas el namespace antes de que se ejecute el cuerpo

__init_subclass__ recibe la clase ya construida; __prepare__ recibe el namespace vacío antes de que el intérprete ejecute el cuerpo de la clase.


Error: Definir __init_subclass__ como método de instancia en lugar de classmethod.

# ❌ Wrong
class Base:
    def __init_subclass__(self, **kwargs):  # self → cls, y Python lo recibe mal
        super().__init_subclass__(**kwargs)

# ✅ Right
class Base:
    def __init_subclass__(cls, **kwargs):   # implícitamente classmethod
        super().__init_subclass__(**kwargs)

Python convierte __init_subclass__ en classmethod implícitamente, pero si escribes self como nombre del primer parámetro introduces confusión y errores sutiles si luego combinas con @classmethod explícito —que en este caso sería redundante y puede causar un doble-wrapping en Python < 3.9.

135

Dejar un comentario

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

Scroll al inicio