Protocolos e duck typing

1. Fundamentos do Duck Typing

Duck typing é um conceito fundamental em Python que se baseia no princípio: "Se parece com um pato, nada como um pato e grasna como um pato, então é um pato". Em termos práticos, isso significa que o tipo de um objeto é determinado pelo seu comportamento (métodos e atributos), não pela sua classe ou hierarquia de herança.

class Pato:
    def quack(self):
        return "Quack!"

class Pessoa:
    def quack(self):
        return "Imitação de quack!"

class Pedra:
    def som(self):
        return "Silêncio"

def fazer_barulho(objeto):
    # Duck typing: qualquer objeto com método .quack() funciona
    return objeto.quack()

print(fazer_barulho(Pato()))    # "Quack!"
print(fazer_barulho(Pessoa()))  # "Imitação de quack!"
# print(fazer_barulho(Pedra())) # AttributeError: 'Pedra' object has no attribute 'quack'

A diferença fundamental entre tipos nominais (herança explícita) e tipos estruturais (comportamento) é que no primeiro caso você precisa declarar explicitamente que uma classe herda de outra, enquanto no segundo caso basta implementar os métodos necessários.

2. Protocolos Informais vs. Formais

Protocolos informais são convenções documentadas na comunidade Python. Por exemplo, qualquer objeto que implemente __len__ e __getitem__ é tratado como uma sequência:

class ListaPersonalizada:
    def __init__(self, itens):
        self._itens = itens

    def __len__(self):
        return len(self._itens)

    def __getitem__(self, index):
        return self._itens[index]

minha_lista = ListaPersonalizada([1, 2, 3])
print(len(minha_lista))  # 3
print(minha_lista[0])    # 1

Já os protocolos formais, introduzidos com typing.Protocol no Python 3.8, permitem definir contratos explícitos que podem ser verificados estaticamente:

from typing import Protocol

class Saudavel(Protocol):
    def __len__(self) -> int:
        ...

    def __getitem__(self, index: int) -> str:
        ...

def processar_saudavel(obj: Saudavel) -> None:
    print(f"Tamanho: {len(obj)}")
    print(f"Primeiro: {obj[0]}")

processar_saudavel("Olá")  # String implementa ambos os métodos
processar_saudavel([1, 2]) # Lista também implementa

3. Criando Protocolos com typing.Protocol

A sintaxe básica para criar um protocolo é simples e intuitiva:

from typing import Protocol, runtime_checkable

@runtime_checkable
class Voador(Protocol):
    def voar(self) -> str:
        ...

    def pousar(self) -> str:
        return "Pousando..."

class Passaro:
    def voar(self):
        return "Batendo asas"

    def pousar(self):
        return "Pousando suavemente"

class Aviao:
    def voar(self):
        return "Ligando turbinas"

    # pousar() usa implementação padrão do protocolo

def testar_voo(objeto: Voador):
    print(objeto.voar())
    print(objeto.pousar())

testar_voo(Passaro())
testar_voo(Aviao())

# Verificação em tempo de execução (requer @runtime_checkable)
print(isinstance(Passaro(), Voador))  # True
print(isinstance(42, Voador))         # False

Métodos opcionais podem ter implementação padrão, como pousar() no exemplo acima. Classes que implementam apenas os métodos obrigatórios já são consideradas compatíveis.

4. Protocolos da Biblioteca Padrão

Python inclui vários protocolos na biblioteca padrão que facilitam a criação de objetos compatíveis com operações comuns:

from typing import Sized, Iterable, Iterator, Container, Collection
from collections.abc import Sequence

class ColecaoPersonalizada:
    def __init__(self, dados):
        self._dados = dados

    # Implementando Sized
    def __len__(self) -> int:
        return len(self._dados)

    # Implementando Iterable
    def __iter__(self):
        return iter(self._dados)

    # Implementando Container
    def __contains__(self, item) -> bool:
        return item in self._dados

# Agora podemos usar nossa classe com funções que esperam esses protocolos
colecao = ColecaoPersonalizada([10, 20, 30])

# Sized
print(len(colecao))  # 3

# Iterable
for item in colecao:
    print(item)

# Container
print(20 in colecao)  # True

# Podemos verificar os protocolos em tempo de execução
from typing import runtime_checkable
print(isinstance(colecao, Sized))      # True
print(isinstance(colecao, Iterable))   # True
print(isinstance(colecao, Container))  # True

Outros protocolos importantes incluem Iterator, Reversible e ContextManager.

5. Duck Typing com hasattr e EAFP

Existem duas abordagens principais para verificar comportamentos em tempo de execução:

# Abordagem LBYL (Look Before You Leap) com hasattr
def processar_lbyl(objeto):
    if hasattr(objeto, 'processar') and callable(objeto.processar):
        return objeto.processar()
    raise TypeError("Objeto não possui método processar")

# Abordagem EAFP (Easier to Ask for Forgiveness than Permission)
def processar_eafp(objeto):
    try:
        return objeto.processar()
    except AttributeError:
        raise TypeError("Objeto não possui método processar")

# Comparação de desempenho
import timeit

class ObjetoValido:
    def processar(self):
        return "OK"

obj = ObjetoValido()

# LBYL é mais lento devido à dupla verificação
tempo_lbyl = timeit.timeit(lambda: processar_lbyl(obj), number=100000)
tempo_eafp = timeit.timeit(lambda: processar_eafp(obj), number=100000)

print(f"LBYL: {tempo_lbyl:.4f}s")
print(f"EAFP: {tempo_eafp:.4f}s")  # Geralmente mais rápido

EAFP é considerado mais "pythônico" e geralmente mais eficiente, especialmente quando a maioria dos objetos passados são válidos.

6. Protocolos vs. Herança de Classes Abstratas (ABC)

A escolha entre Protocol e ABC depende do caso de uso:

from abc import ABC, abstractmethod
from typing import Protocol, Sequence

# Com ABC - herança explícita obrigatória
class ProcessadorABC(ABC):
    @abstractmethod
    def processar(self, dados):
        ...

class MeuProcessador(ProcessadorABC):
    def processar(self, dados):
        return f"Processado: {dados}"

# Com Protocol - duck typing estrutural
class ProcessadorProtocol(Protocol):
    def processar(self, dados):
        ...

def executar_com_protocol(proc: ProcessadorProtocol, dados):
    return proc.processar(dados)

# Qualquer objeto com método processar funciona
class ObjetoQualquer:
    def processar(self, dados):
        return f"Qualquer: {dados}"

print(executar_com_protocol(ObjetoQualquer(), "teste"))  # Funciona!

# Protocolos aceitam tipos da biblioteca padrão
def processar_sequencia(seq: Sequence[int]) -> int:
    return sum(seq)

print(processar_sequencia([1, 2, 3]))     # 6
print(processar_sequencia((4, 5, 6)))     # 15

Protocolos oferecem mais flexibilidade, mas não garantem que o objeto foi intencionalmente projetado para aquele propósito. ABCs fornecem garantias mais fortes através de herança explícita.

7. Exemplo Prático: Sistema de Plugins com Protocolos

Vamos criar um sistema de plugins usando protocolos:

from typing import Protocol, List

class Plugin(Protocol):
    def executar(self, dados: str) -> str:
        ...

class CSVProcessor:
    def executar(self, dados: str) -> str:
        linhas = dados.split('\n')
        return f"CSV processado: {len(linhas)} linhas"

class JSONFormatter:
    def executar(self, dados: str) -> str:
        import json
        try:
            obj = json.loads(dados)
            return f"JSON formatado: {json.dumps(obj, indent=2)}"
        except json.JSONDecodeError:
            return "Erro: JSON inválido"

class ImageResizer:
    def __init__(self, largura: int, altura: int):
        self.largura = largura
        self.altura = altura

    def executar(self, dados: str) -> str:
        return f"Imagem redimensionada para {self.largura}x{self.altura}"

def run_plugins(plugins: List[Plugin], dados: str) -> List[str]:
    resultados = []
    for plugin in plugins:
        try:
            resultado = plugin.executar(dados)
            resultados.append(resultado)
        except Exception as e:
            resultados.append(f"Erro no plugin: {e}")
    return resultados

# Uso do sistema
plugins = [
    CSVProcessor(),
    JSONFormatter(),
    ImageResizer(800, 600)
]

dados_teste = '{"nome": "João", "idade": 30}'
resultados = run_plugins(plugins, dados_teste)

for i, resultado in enumerate(resultados):
    print(f"Plugin {i+1}: {resultado}")

8. Boas Práticas e Armadilhas Comuns

from typing import Protocol, Callable, runtime_checkable

# Boa prática: usar Callable para funções simples
def aplicar_transformacao(func: Callable[[int], int], valor: int) -> int:
    return func(valor)

print(aplicar_transformacao(lambda x: x * 2, 5))  # 10

# Armadilha: runtime_checkable tem limitações
@runtime_checkable
class ProtocoloComplexo(Protocol):
    def metodo1(self) -> int:
        ...

    def metodo2(self, x: int) -> str:
        ...

class ImplementacaoSimples:
    def metodo1(self):
        return 42

    def metodo2(self, x):
        return str(x)

# Isso funciona
obj = ImplementacaoSimples()
print(isinstance(obj, ProtocoloComplexo))  # True

# Mas protocolos com métodos que retornam tipos complexos podem falhar
@runtime_checkable
class ProtocoloGenerico(Protocol):
    def metodo(self) -> list[int]:
        ...

class ImplementacaoGenerica:
    def metodo(self):
        return [1, 2, 3]

print(isinstance(ImplementacaoGenerica(), ProtocoloGenerico))  # True (mas impreciso)

# Documentação explícita é essencial
def funcao_publica(obj):
    """
    Processa um objeto que implemente o protocolo Saudavel.

    Espera que obj tenha:
    - __len__() -> int
    - __getitem__(index) -> str
    """
    return len(obj) > 0

Boas práticas resumidas:
- Use protocolos para interfaces que serão usadas por código externo
- Prefira Callable para funções simples em vez de criar protocolos
- Documente claramente o protocolo esperado em funções públicas
- Use @runtime_checkable com moderação e apenas quando necessário
- Evite protocolos excessivamente complexos que dificultam a compreensão

Referências