El duck typing siempre ha sido el alma de Python: si un objeto tiene el método correcto, funciona, sin importar su tipo nominal. El problema es que eso era invisible para los type checkers hasta Python 3.8. Protocol resuelve exactamente eso — te permite describir una interfaz por su estructura sin exigir herencia, y el type checker la verifica en tiempo de análisis.
La diferencia conceptual con ABC es sutil pero importante. Cuando defines una ABC, estás creando una jerarquía nominal: las clases que la implementan deben declarar explícitamente class Foo(MyABC). Con Protocol, la relación es estructural: si una clase tiene los métodos correctos con las firmas correctas, ya satisface el protocolo, aunque nunca haya oído hablar de él. Eso cambia completamente el juego cuando trabajas con código externo que no puedes modificar — datetime.datetime, objetos de terceros, dataclasses generadas, etc.
Internamente, mypy y pyright implementan esto como subtyping estructural (nominal typing vs. structural typing). El type checker simplemente revisa si el tipo concreto expone los atributos que el Protocol exige. No hay ningún mecanismo en runtime que fuerce esto, a menos que uses @runtime_checkable.
El momento ideal para Protocol es cuando diseñas una función o módulo que depende de capacidades, no de linajes. Si tu código solo necesita “algo que se pueda serializar”, no tiene sentido imponer que ese algo herede de tu clase base.
Lo que se rompe si lo ignoras: terminas creando acoplamiento innecesario con ABC, o aceptas Any y pierdes toda verificación estática. Ambas opciones son peores.
from __future__ import annotations
from typing import Protocol, runtime_checkable
# Protocol define la interfaz por estructura, no por herencia
@runtime_checkable
class Serializable(Protocol):
def serialize(self) -> str:
... # no hay implementación; los "..." son la convención correcta aquí
def get_id(self) -> int:
...
# Esta clase no hereda de Serializable — ni siquiera la importa
class UserRecord:
def __init__(self, user_id: int, name: str) -> None:
self.user_id = user_id
self.name = name
def serialize(self) -> str:
return f'{{"id": {self.user_id}, "name": "{self.name}"}}'
def get_id(self) -> int:
return self.user_id
# Esta clase tampoco — viene de una "librería externa" que no controlamos
class ProductRecord:
def __init__(self, product_id: int, sku: str) -> None:
self.product_id = product_id
self.sku = sku
def serialize(self) -> str:
return f'{{"id": {self.product_id}, "sku": "{self.sku}"}}'
def get_id(self) -> int:
return self.product_id
# Esta clase está *incompleta*: falta get_id — el type checker lo detectará
class BrokenRecord:
def serialize(self) -> str:
return "{}"
def save_to_store(item: Serializable) -> None:
"""Acepta cualquier objeto que satisfaga el Protocol, sin importar su tipo."""
record_id = item.get_id()
payload = item.serialize()
# Simula persistencia
print(f"[store] id={record_id} payload={payload}")
def batch_save(items: list[Serializable]) -> None:
for item in items:
save_to_store(item)
# isinstance funciona en runtime gracias a @runtime_checkable
def safe_save(item: object) -> None:
if isinstance(item, Serializable): # chequeo estructural en runtime
save_to_store(item)
else:
print(f"[warn] {type(item).__name__} no es Serializable, omitiendo")
if __name__ == "__main__":
user = UserRecord(1, "Ana")
product = ProductRecord(42, "XYZ-9")
batch_save([user, product])
# runtime_checkable en acción
safe_save(user)
safe_save("esto no es serializable")
# mypy/pyright rechazaría esto en análisis estático:
# batch_save([BrokenRecord()]) # Error: falta get_id
Qué está pasando en cada decisión
@runtime_checkable merece atención especial. Sin ese decorador, isinstance(obj, Serializable) lanza TypeError en runtime. Con él, Python verifica en tiempo de ejecución que el objeto tenga los nombres de los métodos requeridos — pero solo los nombres, no las firmas. Eso significa que isinstance con un @runtime_checkable Protocol es una guardia útil pero no un contrato completo: el type checker sigue siendo más estricto que el runtime check.
La firma def serialize(self) -> str: ... usa ... (Ellipsis) en lugar de pass. Es la convención estándar en Protocols para comunicar “esto es una especificación, no una implementación”. Técnicamente ambos funcionan, pero ... es idiomático y los type checkers lo prefieren.
UserRecord y ProductRecord satisfacen Serializable sin ninguna declaración explícita. Puedes verificarlo pasándolos a save_to_store — mypy no se queja. Si eliminas get_id de cualquiera de ellas, el type checker levanta un error en la llamada a batch_save, no en la definición de la clase. Ese es el modelo mental correcto: el Protocol no “se registra” en las clases; se verifica en los puntos de uso.
BrokenRecord ilustra la ventaja central: el error se detecta antes de ejecutar el código. Con duck typing puro descubrirías el AttributeError en producción; con Protocol lo atrapas en CI.
La función batch_save acepta list[Serializable]. Fíjate que no hay ningún import de UserRecord ni ProductRecord en esa función — el módulo que define la interfaz está completamente desacoplado de los módulos que la implementan. Eso es imposible con ABC sin crear una dependencia circular o un módulo base compartido.
Errores que debes conocer
Error: usar isinstance con un Protocol que no tiene @runtime_checkable — Python lanza TypeError en runtime porque los Protocols no son clases ordinarias.
# ❌ Wrong
class Serializable(Protocol):
def serialize(self) -> str: ...
if isinstance(obj, Serializable): # TypeError: Protocols with non-method members don't support issubclass/isinstance
...
# ✅ Right
@runtime_checkable
class Serializable(Protocol):
def serialize(self) -> str: ...
if isinstance(obj, Serializable): # funciona
...
Agregar @runtime_checkable habilita el chequeo estructural de nombres en runtime; sin él, el Protocol existe solo para el type checker.
Error: confiar en que isinstance con @runtime_checkable verifica firmas completas — solo comprueba que el atributo exista, no que tenga el tipo de retorno correcto.
# ❌ Wrong (pasa isinstance pero falla en runtime o análisis)
class FakeRecord:
def serialize(self): # retorna None implícitamente
pass
def get_id(self):
return "no soy un int" # tipo incorrecto
obj = FakeRecord()
print(isinstance(obj, Serializable)) # True — solo verifica nombres
# ✅ Right: confía en el type checker para validación de firmas
# usa isinstance solo como guardia de primer nivel, no como garantía completa
El isinstance con @runtime_checkable es una guardia de presencia, no de corrección. El análisis estático con mypy/pyright es lo que garantiza la firma completa.
N° 103