Python já pratica duck typing: se um objeto oferece o comportamento esperado, ele pode ser usado. typing.Protocol descreve esse contrato para ferramentas de análise estática sem obrigar classes a herdar de uma base comum.
O recurso complementa type hints em Python. Ele não altera automaticamente o comportamento em execução.
Definir um contrato
from typing import Protocol
class RepositorioUsuario(Protocol):
def buscar(self, usuario_id: int) -> dict | None:
...
def exibir_usuario(repo: RepositorioUsuario, usuario_id: int) -> str:
usuario = repo.buscar(usuario_id)
return usuario["nome"] if usuario else "Não encontrado"
Qualquer classe com um método buscar compatível pode ser passada:
class RepositorioMemoria:
def buscar(self, usuario_id: int) -> dict | None:
return {"id": usuario_id, "nome": "Ada"}
Não há herança explícita. Um type checker compara os membros estruturais. Isso facilita testes com implementações pequenas e reduz dependência da camada de banco.
Protocol com propriedades
class TemIdentificador(Protocol):
@property
def id(self) -> int:
...
def ids(itens: list[TemIdentificador]) -> list[int]:
return [item.id for item in itens]
Defina apenas o que a função realmente usa. Protocolos grandes viram interfaces difíceis de implementar e escondem responsabilidades. A especificação oficial de Protocol explica membros, subprotocolos e compatibilidade.
Verificação em runtime
from typing import Protocol, runtime_checkable
@runtime_checkable
class Fechavel(Protocol):
def close(self) -> None:
...
Agora isinstance(objeto, Fechavel) verifica a presença do membro, mas não valida completamente sua assinatura. Não use isso como sistema de validação de dados. Para dados externos, ferramentas como Pydantic atendem outro problema.
Protocol, ABC ou classe base
Escolha Protocol quando consumidores precisam de poucos comportamentos e implementações não devem conhecer a interface. Use ABC quando há relação nominal importante, métodos abstratos obrigatórios ou implementação compartilhada. Uma classe base concreta é adequada quando existe estado e comportamento comum reais.
- Mantenha contratos pequenos.
- Nomeie pelo comportamento, não pela tecnologia.
- Execute um type checker na CI.
- Teste o comportamento das implementações.
- Evite
runtime_checkablequando análise estática basta.
Protocol formaliza o duck typing sem retirar sua flexibilidade. O ganho aparece nas fronteiras do sistema: serviços, repositórios, clientes externos e testes ficam mais fáceis de substituir e compreender.
Compatibilidade considera assinaturas
Não basta repetir o nome do método. O type checker também compara parâmetros e retorno. Um repositório cujo buscar() recebe texto não satisfaz um contrato que exige int. Regras de variância ainda importam: de modo geral, uma implementação precisa aceitar tudo o que o consumidor pode enviar e devolver algo compatível com o que ele espera.
Esse diagnóstico acontece na análise estática, não ao importar o programa. Pyright, mypy e outras ferramentas podem apresentar mensagens diferentes e oferecer níveis distintos de rigor, mas o contrato deve continuar válido segundo a especificação de tipagem. Execute a ferramenta escolhida no projeto inteiro, pois inferência e configuração influenciam o resultado.
Uma atribuição explícita pode tornar a intenção e o erro mais visíveis:
repositorio: RepositorioUsuario = RepositorioMemoria()
Não é necessário espalhar essas atribuições por todo o código. Elas são úteis perto da composição da aplicação, onde uma implementação concreta é conectada ao consumidor.
Protocolos genéricos
Quando o mesmo comportamento serve a tipos diferentes, torne o protocolo genérico:
from typing import Protocol, TypeVar
T_co = TypeVar("T_co", covariant=True)
class Leitor(Protocol[T_co]):
def ler(self) -> T_co:
...
def imprimir_texto(leitor: Leitor[str]) -> None:
print(leitor.ler())
O sufixo co documenta covariância: um leitor apenas produz valores. Um protocolo que consome e produz o mesmo tipo normalmente precisa de um parâmetro invariável. Não marque variância apenas para silenciar o checker; modele o fluxo real dos valores.
Em Python recente também existe sintaxe de parâmetros de tipo, mas bibliotecas devem respeitar sua versão mínima. Escolher sintaxe compatível com o projeto costuma ser mais importante do que adotar imediatamente a forma mais nova.
Métodos de classe, estáticos e callbacks
Protocol pode descrever @classmethod, @staticmethod, atributos somente leitura e métodos assíncronos. Para callbacks com vários parâmetros ou sobrecargas, um protocolo com __call__ costuma expressar mais do que Callable:
class Transformador(Protocol):
def __call__(
self,
valor: str,
*,
remover_espacos: bool = True,
) -> str:
...
def normalizar(
texto: str,
transformar: Transformador,
) -> str:
return transformar(texto, remover_espacos=True)
Essa forma preserva nomes e categorias dos parâmetros, como o argumento exclusivo por palavra-chave. Ela também permite declarar atributos adicionais do callback quando isso fizer parte do contrato.
Para código assíncrono, declare o método com async def; o retorno observado pelo chamador será awaitable conforme a anotação. Não descreva um método síncrono como assíncrono apenas porque uma implementação acessa rede. O consumidor precisa saber que deve usar await.
Composição e extensão
Um protocolo pode herdar de outros protocolos. Isso é útil quando um consumidor realmente precisa da combinação:
class Legivel(Protocol):
def read(self, tamanho: int = -1) -> bytes:
...
class Fechavel(Protocol):
def close(self) -> None:
...
class RecursoLeitura(Legivel, Fechavel, Protocol):
pass
Prefira aceitar Legivel em funções que apenas leem. Exigir RecursoLeitura sem chamar close() aumenta acoplamento sem benefício. Interfaces segregadas deixam testes menores e permitem reutilizar tipos existentes da biblioteca padrão.
Herança explícita de um protocolo é possível e pode fazer sentido para documentar intenção. Nesse caso, a classe passa a receber verificação nominal adicional e pode herdar implementações padrão. Entretanto, a vantagem estrutural continua sendo aceitar implementações existentes que nunca importaram o protocolo.
Limites de runtime_checkable
Com @runtime_checkable, isinstance() verifica se os atributos exigidos estão presentes. Ele não chama métodos nem compara todas as assinaturas e tipos. Um objeto com close = 42 pode passar por uma verificação baseada em presença embora não seja utilizável como função. Além disso, essa inspeção pode ser mais lenta que um isinstance() nominal.
Use-a em integrações em que uma verificação superficial é suficiente, e depois trate os erros normais da operação. Para validar JSON, formulários ou variáveis de ambiente, use validação de dados. Para impor uma hierarquia em runtime, considere ABC. São responsabilidades diferentes.
Um exemplo de teste desacoplado
Imagine um serviço que envia recibos:
class Enviador(Protocol):
def enviar(self, destino: str, mensagem: str) -> None:
...
def emitir_recibo(
enviador: Enviador,
email: str,
total: str,
) -> None:
enviador.enviar(email, f"Total: {total}")
class EnviadorEspiao:
def __init__(self) -> None:
self.mensagens: list[tuple[str, str]] = []
def enviar(self, destino: str, mensagem: str) -> None:
self.mensagens.append((destino, mensagem))
O teste passa EnviadorEspiao sem subclassificação ou mock complexo. O protocolo deixa visível exatamente o que o serviço usa. Ainda assim, teste efeitos e resultados; passar no type checker não garante regras de negócio corretas, tratamento de falhas ou segurança.
Evite criar um protocolo para toda classe concreta. Ele compensa quando existe uma fronteira com mais de uma implementação plausível, quando consumidores precisam de apenas um subconjunto ou quando uma dependência externa deve ser substituída em testes. Para funções internas simples, anotações diretas costumam ser suficientes.
Revisão do contrato
Antes de publicar um protocolo, procure cada membro no código consumidor. Remova o que não é usado e confirme se atributos deveriam ser propriedades somente leitura. Verifique métodos síncronos e assíncronos, argumentos posicionais e exclusivos por palavra-chave, além da possibilidade de retorno None.
Rode o checker com ao menos duas implementações: o adaptador real e um substituto de teste. Depois execute testes comportamentais para ambos. Essa combinação encontra contratos imprecisos sem transformar a interface em uma cópia extensa da classe concreta.
Documente também efeitos que tipos não expressam, como fechar um recurso, repetir uma chamada com segurança ou levantar uma exceção específica. Protocol descreve a forma de uso; a documentação e os testes preservam a semântica.