Protocols para typing estructural en Python

El duck typing de Python siempre ha sido una de sus fortalezas: si un objeto tiene el método quack(), es un pato, sin importar de qué clase sea. El problema es que hasta Protocol (Python 3.8+), ese contrato era completamente invisible para los type checkers. Protocol cierra esa brecha: te permite describir una interfaz por estructura —qué métodos y atributos debe tener un objeto— sin que ninguna clase tenga que heredar de nada.

La diferencia con las Abstract Base Classes (ABC) es de filosofía. Una ABC exige que las clases participantes hereden de ella y registren su conformidad en tiempo de ejecución; isinstance() funciona porque hay una jerarquía real. Un Protocol, en cambio, trabaja offline: mypy o pyright verifican que la estructura esperada exista en el tipo estático, sin tocar la cadena de herencia. Dos clases completamente ajenas entre sí pueden satisfacer el mismo Protocol si ambas tienen los métodos correctos.

¿Cuándo usarlo? Cuando estás escribiendo código que debe aceptar cualquier objeto que se comporte de cierta manera, pero no quieres —o no puedes— obligar a los usuarios de tu biblioteca a heredar de una clase base tuya. Es el patrón ideal para interfaces en APIs públicas, sistemas de plugins, o cuando integras código legado que no puedes modificar.

Lo que se rompe si lo ignoras es sutil pero doloroso: terminas usando Any en las firmas o escribiendo ABCs que fuerzan acoplamiento innecesario. Y si declaras un Protocol pero cometes un error en el nombre de un método, el type checker te lo señala en el sitio de uso, no dentro de la clase —lo cual puede ser confuso si no entiendes el mecanismo.

Un detalle importante: por defecto, Protocol es solo estático. Si necesitas isinstance() en runtime, debes decorar con @runtime_checkable, pero ese cheque solo verifica la presencia de los métodos, no sus firmas.

from __future__ import annotations

from typing import Protocol, runtime_checkable
from collections.abc import Iterator


# ── Protocols básicos ────────────────────────────────────────────────────

@runtime_checkable
class Drawable(Protocol):
    def draw(self) -> None: ...
    def bounding_box(self) -> tuple[float, float, float, float]: ...


class Serializable(Protocol):
    def to_bytes(self) -> bytes: ...

    @classmethod
    def from_bytes(cls, data: bytes) -> Serializable: ...


# ── Implementaciones sin herencia ────────────────────────────────────────

class Circle:
    def __init__(self, x: float, y: float, radius: float) -> None:
        self.x = x
        self.y = y
        self.radius = radius

    def draw(self) -> None:
        print(f"Drawing circle at ({self.x}, {self.y}) r={self.radius}")

    def bounding_box(self) -> tuple[float, float, float, float]:
        r = self.radius
        return (self.x - r, self.y - r, self.x + r, self.y + r)


class Sprite:
    """Clase de una librería externa hipotética — no podemos modificarla."""

    def draw(self) -> None:
        print("Drawing sprite")

    def bounding_box(self) -> tuple[float, float, float, float]:
        return (0.0, 0.0, 32.0, 32.0)


# ── Protocol con método especial ─────────────────────────────────────────

class Shaped(Protocol):
    """Cualquier objeto con __len__ y __iter__ sobre sus vértices."""

    def __len__(self) -> int: ...
    def __iter__(self) -> Iterator[tuple[float, float]]: ...


class Polygon:
    def __init__(self, vertices: list[tuple[float, float]]) -> None:
        self._vertices = vertices

    def __len__(self) -> int:
        return len(self._vertices)

    def __iter__(self) -> Iterator[tuple[float, float]]:
        return iter(self._vertices)


# ── Funciones que consumen los protocols ─────────────────────────────────

def render_all(drawables: list[Drawable]) -> None:
    for item in drawables:
        box = item.bounding_box()
        print(f"  bbox={box}")
        item.draw()


def total_vertices(shape: Shaped) -> int:
    # __len__ está en el Protocol, así que el type checker sabe que existe
    return len(shape)


# ── Comprobación runtime con @runtime_checkable ──────────────────────────

def safe_draw(obj: object) -> None:
    if isinstance(obj, Drawable):          # solo funciona por @runtime_checkable
        obj.draw()
    else:
        print(f"{type(obj).__name__} no es Drawable en runtime")


# ── Demo ─────────────────────────────────────────────────────────────────

if __name__ == "__main__":
    scene: list[Drawable] = [Circle(0, 0, 5), Sprite()]
    render_all(scene)

    poly = Polygon([(0, 0), (1, 0), (1, 1), (0, 1)])
    print(f"Vértices: {total_vertices(poly)}")

    safe_draw(Circle(1, 1, 2))
    safe_draw("soy un string")     # no tiene draw() → rama else

Lo que hace cada decisión

Drawable lleva @runtime_checkable porque safe_draw necesita hacer isinstance() en runtime —ese es el único caso donde lo necesitas. Serializable no lo lleva porque solo se usa como anotación estática; añadirlo sin necesidad es ruido.

Fíjate en que Circle y Sprite no tienen ninguna relación de herencia entre sí ni con Drawable. El type checker —mypy, pyright— verifica estructuralmente que ambos tienen draw() -> None y bounding_box() -> tuple[float, float, float, float]. Si renombras bounding_box a bbox en Circle, mypy te avisa en la firma de render_all, no dentro de Circle.

Shaped demuestra que los métodos especiales (__len__, __iter__) funcionan exactamente igual. Esto es lo que usa la stdlib: collections.abc.Sized, collections.abc.Iterable, collections.abc.Hashable son todos Protocol bajo el capó desde 3.8. Cuando anotas un parámetro como Iterable[int], estás usando typing estructural, no nominal.

El método from_bytes en Serializable es un @classmethod dentro del Protocol. El type checker entiende esa semántica: cualquier clase que satisfaga Serializable debe tener ese classmethod con esa firma. Es un caso que con ABCs requeriría @classmethod + @abstractmethod apilados; con Protocol es más limpio.

Errores que debes conocer

Error: Usar isinstance() con un Protocol que no tiene @runtime_checkable lanza TypeError en runtime porque los Protocol sin ese decorador no soportan chequeos de instancia.

from typing import Protocol

class Flyable(Protocol):
    def fly(self) -> None: ...

class Bird:
    def fly(self) -> None:
        print("flap")

# ❌ Wrong
print(isinstance(Bird(), Flyable))  # TypeError: Protocols with non-method members...

# ✅ Right
from typing import runtime_checkable

@runtime_checkable
class Flyable(Protocol):
    def fly(self) -> None: ...

print(isinstance(Bird(), Flyable))  # True

Añadir @runtime_checkable habilita isinstance(), pero recuerda que solo comprueba que el método existe, no que su firma sea compatible.


Error: Definir un Protocol que hereda de otra clase no-Protocol rompe la semántica estructural: el type checker empieza a tratar la conformidad como nominal para esa parte de la jerarquía.

from typing import Protocol

class Base:
    def setup(self) -> None: ...

# ❌ Wrong — Base no es Protocol; heredar de ella hace el contrato nominal
class Configurable(Base, Protocol):
    def configure(self) -> None: ...

# ✅ Right — si necesitas componer, haz que Base también sea Protocol
class Setupable(Protocol):
    def setup(self) -> None: ...

class Configurable(Setupable, Protocol):
    def configure(self) -> None: ...

Cuando compones Protocol con otro Protocol, la verificación sigue siendo estructural en toda la jerarquía.

129

Dejar un comentario

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

Scroll al inicio