
Qué son los protocols
Un protocol es una clase que declara la forma que debe tener un objeto para ser aceptado en una determinada posición de tipo. No exige herencia ni registro explícito: si un objeto tiene los métodos y atributos que el protocolo enumera, cumple el protocolo. Es el equivalente tipado del duck typing tradicional de Python.
Los protocols se definen en el módulo typing y se apoyan en el analizador estático (mypy, pyright) para verificar la conformidad sin penalizar el tiempo de ejecución.
from typing import Protocol
class Nombrable(Protocol):
nombre: str
def presentarse(self) -> str: ...
Cualquier clase con un atributo nombre: str y un método presentarse() -> str encaja en Nombrable, aunque no herede de él.
class Persona:
def __init__(self, nombre: str) -> None:
self.nombre = nombre
def presentarse(self) -> str:
return f"Hola, soy {self.nombre}"
def saludar(n: Nombrable) -> None:
print(n.presentarse())
saludar(Persona("Ana"))
El tipado estructural es ideal para desacoplar módulos, un servicio expone una función que recibe un Nombrable y acepta cualquier objeto compatible, de tu código o de una librería externa.
Tipado nominal frente a estructural
El tipado nominal exige que un objeto sea instancia de una clase concreta o de una subclase. Es la forma tradicional de las clases abstractas (abc.ABC), donde la relación se declara con class Concreta(AbstractaBase).
El tipado estructural comprueba la forma del objeto, no su linaje. Esta distinción es relevante cuando se trabaja con dependencias de terceros o modelos de dominio que no se pueden alterar.
flowchart LR
A[Tipado nominal] -->|requiere herencia| B[class Hijo ABC]
C[Tipado estructural] -->|comprueba forma| D[atributos y métodos]
D --> E[Protocol acepta el objeto]
Un ejemplo clásico es un lector genérico que acepta cualquier fuente con un método read. Sin protocols, habría que inventar una clase base abstracta, con protocols basta con declarar la forma.
class Lector(Protocol):
def read(self, n: int = -1) -> bytes: ...
def cargar(fuente: Lector) -> bytes:
return fuente.read()
Archivos abiertos en modo binario, io.BytesIO, sockets adaptados o un mock en tests cumplen Lector sin cambios.
Protocols en tiempo de ejecución
Por defecto, los protocols son invisibles en tiempo de ejecución, solo existen para el verificador estático. Si necesitas comprobar con isinstance, puedes decorar el protocolo con runtime_checkable:
from typing import Protocol, runtime_checkable
@runtime_checkable
class Cerrable(Protocol):
def close(self) -> None: ...
def liberar(obj: object) -> None:
if isinstance(obj, Cerrable):
obj.close()
La comprobación en tiempo de ejecución mira la presencia de los métodos, no su firma. Es útil para ramas defensivas, pero no sustituye al análisis estático.
El decorador runtime_checkable tiene coste, hace que isinstance recorra los atributos del objeto. Úsalo solo cuando realmente necesites la comprobación dinámica.
Los protocols también admiten atributos tipados y métodos con implementación por defecto si se marcan con ..., y pueden declararse genéricos combinándolos con TypeVar para describir colecciones y contenedores reutilizables.
from typing import Protocol, TypeVar
T = TypeVar("T", covariant=True)
class FuenteDatos(Protocol[T]):
def obtener(self, clave: str) -> T: ...
Con este patrón, un repositorio de productos, de usuarios o de facturas comparte la misma forma y se puede sustituir por cualquier implementación concreta, desde una base de datos hasta un diccionario en memoria para tests.
Caso B2B: desacoplar un servicio de scoring
En un equipo de riesgo financiero, un servicio que calcula scores necesita consultar datos de clientes. El código de negocio no debería acoplarse a una base de datos concreta porque durante las pruebas interesa cargar datos desde un CSV y en producción desde PostgreSQL. Un Protocol describe la forma mínima que cualquier fuente debe cumplir.
from typing import Protocol
class FuenteClientes(Protocol):
def obtener(self, cliente_id: str) -> dict: ...
def listar_activos(self) -> list[dict]: ...
class ServicioScoring:
def __init__(self, fuente: FuenteClientes) -> None:
self.fuente = fuente
def score_cliente(self, cliente_id: str) -> float:
datos = self.fuente.obtener(cliente_id)
ratio = datos["deuda"] / max(datos["ingresos"], 1.0)
return max(0.0, 100.0 - ratio * 100.0)
Dos implementaciones concretas sirven al mismo servicio sin que este conozca sus detalles:
import csv
import psycopg
class FuentePostgres:
def __init__(self, dsn: str) -> None:
self.conn = psycopg.connect(dsn)
def obtener(self, cliente_id: str) -> dict:
row = self.conn.execute(
"SELECT id, ingresos, deuda FROM clientes WHERE id = %s",
(cliente_id,),
).fetchone()
return {"id": row[0], "ingresos": row[1], "deuda": row[2]}
def listar_activos(self) -> list[dict]:
return [
{"id": r[0], "ingresos": r[1], "deuda": r[2]}
for r in self.conn.execute("SELECT id, ingresos, deuda FROM clientes")
]
class FuenteCSV:
def __init__(self, ruta: str) -> None:
with open(ruta, encoding="utf-8") as f:
self.datos = {row["id"]: row for row in csv.DictReader(f)}
def obtener(self, cliente_id: str) -> dict:
fila = self.datos[cliente_id]
return {
"id": fila["id"],
"ingresos": float(fila["ingresos"]),
"deuda": float(fila["deuda"]),
}
def listar_activos(self) -> list[dict]:
return [
{"id": r["id"], "ingresos": float(r["ingresos"]), "deuda": float(r["deuda"])}
for r in self.datos.values()
]
# Mismo servicio, fuentes intercambiables
servicio_test = ServicioScoring(FuenteCSV("clientes_sample.csv"))
servicio_prod = ServicioScoring(FuentePostgres("postgresql://..."))
Ninguna implementación hereda de FuenteClientes. El verificador estático comprueba que ambas exponen los métodos del protocolo con las firmas correctas. El patrón permite sustituir la base de datos real por un mock ligero en tests unitarios, reducir la superficie de integración y mantener el núcleo de negocio libre de imports que lo aten a un sistema externo.
Resumen del flujo
flowchart LR
Dominio[Servicio de negocio] -->|depende del contrato| Proto[Protocol FuenteClientes]
Proto -.cumple.-> Prod[FuentePostgres]
Proto -.cumple.-> Test[FuenteCSV]
Proto -.cumple.-> Mock[Mock en memoria]
Prod --> DB["(PostgreSQL)"]
Test --> CSV["(CSV local)"]
Mock --> Mem[dict]
El contrato vive en el dominio, las implementaciones viven en la capa de infraestructura, y el servicio se compone por inyección. Este es el mismo patrón que usan frameworks como FastAPI en sus dependencias y que habilita tests unitarios rápidos en pipelines de ML, servicios de scoring o plataformas de datos.
Alan Sastre
Ingeniero de Software y formador, CEO en CertiDevs
Ingeniero de software especializado en Full Stack y en Inteligencia Artificial. Como CEO de CertiDevs, Python es una de sus áreas de expertise. Con más de 15 años programando, 6K seguidores en LinkedIn y experiencia como formador, Alan se dedica a crear contenido educativo de calidad para desarrolladores de todos los niveles.
Más tutoriales de Python
Explora más contenido relacionado con Python y continúa aprendiendo con nuestros tutoriales gratuitos.
Aprendizajes de esta lección
Definir interfaces estructurales con typing.Protocol. Comprender la diferencia entre tipado nominal y estructural. Usar runtime_checkable cuando se necesita comprobar compatibilidad con isinstance. Aplicar protocols a funciones, clases y dependencias externas sin modificar su código.