Como usar o protocolo em Elixir para polimorfismo sem herança
1. O problema do polimorfismo em linguagens funcionais
Em linguagens orientadas a objetos tradicionais, o polimorfismo é frequentemente alcançado por meio de herança de classes. Uma classe pai define um comportamento genérico, e classes filhas o sobrescrevem. Em Elixir, linguagem funcional que roda na Erlang VM, não existe herança clássica. Não há classes, nem hierarquias de tipo rígidas. Isso levanta uma questão: como compartilhar comportamentos entre tipos diferentes sem herança?
Elixir resolve esse problema com protocolos, inspirados diretamente no sistema de protocolos de Clojure. Protocolos permitem polimorfismo ad hoc: você define uma interface abstrata e implementa comportamentos específicos para cada tipo de dado, sem que esses tipos precisem ter qualquer relação entre si. Isso difere do polimorfismo paramétrico (como em generics do Java), onde o comportamento é uniforme para qualquer tipo. Com protocolos, cada tipo pode ter sua própria lógica.
2. Estrutura básica de um protocolo em Elixir
Um protocolo é definido com a macro defprotocol, que declara uma ou mais funções. Cada função deve ser implementada para tipos específicos usando defimpl.
Vamos criar um protocolo Stringifiable que converte diferentes estruturas em strings legíveis:
defprotocol Stringifiable do
@doc "Converte um valor em uma string legível"
def to_string(value)
end
defimpl Stringifiable, for: Integer do
def to_string(value), do: "Número: #{value}"
end
defimpl Stringifiable, for: Atom do
def to_string(value), do: "Atom: #{value}"
end
defimpl Stringifiable, for: Map do
def to_string(value) do
pairs = Enum.map(value, fn {k, v} -> "#{k}: #{v}" end)
"Mapa: {#{Enum.join(pairs, ", ")}}"
end
end
Agora podemos chamar Stringifiable.to_string/1 para qualquer tipo implementado:
iex> Stringifiable.to_string(42)
"Número: 42"
iex> Stringifiable.to_string(:hello)
"Atom: hello"
iex> Stringifiable.to_string(%{nome: "Alice", idade: 30})
"Mapa: {nome: Alice, idade: 30}"
3. Implementando protocolos para tipos built-in e personalizados
Protocolos funcionam com todos os tipos built-in do Elixir: Integer, Float, Atom, List, Map, Tuple, BitString, PID, Function, Reference, Port. Para tipos personalizados, como structs, usamos o nome do módulo.
Vamos criar uma struct Usuario e implementar Stringifiable:
defmodule Usuario do
defstruct [:nome, :email]
end
defimpl Stringifiable, for: Usuario do
def to_string(%Usuario{nome: nome, email: email}) do
"Usuário: #{nome} <#{email}>"
end
end
O dispatch em tempo de execução funciona assim: quando você chama Stringifiable.to_string(valor), o Elixir verifica o tipo do argumento e busca a implementação correspondente. Para structs, ele usa o módulo definido no campo __struct__.
4. Protocolos com múltiplas funções e fallback
Protocolos podem ter várias funções. Vamos criar um protocolo Serializer com serialize/1 e deserialize/1:
defprotocol Serializer do
def serialize(data)
def deserialize(binary)
end
defimpl Serializer, for: Integer do
def serialize(value), do: Integer.to_string(value)
def deserialize(binary), do: String.to_integer(binary)
end
defimpl Serializer, for: Map do
def serialize(value), do: Jason.encode!(value)
def deserialize(binary), do: Jason.decode!(binary)
end
Para evitar erros quando um tipo não tem implementação, usamos @fallback_to_any:
defprotocol Serializer do
@fallback_to_any true
def serialize(data)
def deserialize(binary)
end
defimpl Serializer, for: Any do
def serialize(data), do: inspect(data)
def deserialize(binary), do: binary
end
Agora qualquer tipo não implementado usará o fallback.
5. Composição de protocolos e integração com pattern matching
Protocolos podem chamar outros protocolos internamente. Vamos criar um protocolo Comparable que usa String.Chars para comparação textual:
defprotocol Comparable do
def compare(a, b)
end
defimpl Comparable, for: Integer do
def compare(a, b) when a < b, do: :less
def compare(a, b) when a > b, do: :greater
def compare(_a, _b), do: :equal
end
defimpl Comparable, for: Map do
def compare(a, b) do
string_a = Stringifiable.to_string(a)
string_b = Stringifiable.to_string(b)
if string_a < string_b, do: :less, else: (:greater if string_a > string_b, else: :equal)
end
end
Guard clauses funcionam perfeitamente em implementações de protocolo, como visto acima com when a < b.
6. Protocolos vs. Behaviours: quando usar cada um
Behaviours e protocolos são mecanismos complementares, mas com propósitos distintos:
| Aspecto | Behaviours | Protocolos |
|---|---|---|
| Dispatch | Por módulo (qual módulo implementa o behaviour) | Por tipo de dado (qual é o tipo do argumento) |
| Contrato | Explícito, definido em módulo | Implícito, definido por tipo |
| Extensão | O módulo declara que implementa o behaviour | Você adiciona implementação externamente |
| Herança | Sim, via @behaviour e callbacks |
Não há herança, apenas implementações paralelas |
Use behaviours quando você quer que um módulo (geralmente um processo GenServer, Supervisor, etc.) siga um contrato específico. Use protocolos quando você quer que diferentes tipos de dados respondam à mesma operação, sem que esses tipos tenham relação entre si.
7. Boas práticas e armadilhas comuns
Evite implementações muito genéricas: usar @fallback_to_any para tudo pode mascarar erros. Prefira implementações explícitas para tipos que fazem sentido.
Performance: o dispatch de protocolos tem custo em tempo de execução, pois o Elixir precisa verificar o tipo e buscar a implementação. Em loops críticos, considere alternativas como pattern matching direto.
Documentação: sempre documente as funções do protocolo e o comportamento esperado. Use @doc e @spec para clareza.
Versionamento semântico: adicionar uma nova função a um protocolo existente é uma mudança que quebra compatibilidade, pois todas as implementações precisam ser atualizadas.
Refatoração de condicionais: protocolos são excelentes para substituir cadeias de case ou cond:
# Antes: condicional feio
def format(value) do
cond do
is_integer(value) -> "Número: #{value}"
is_atom(value) -> "Atom: #{value}"
is_map(value) -> "Mapa: #{inspect(value)}"
true -> inspect(value)
end
end
# Depois: com protocolo
def format(value), do: Stringifiable.to_string(value)
Conclusão
Protocolos em Elixir oferecem uma forma elegante de polimorfismo sem herança, respeitando a filosofia funcional da linguagem. Eles permitem estender tipos existentes com novos comportamentos, compor funcionalidades e manter o código limpo e previsível. Combinados com pattern matching e behaviours, formam um sistema completo de abstração que cobre desde dados simples até arquiteturas complexas de processos.
Referências
- Elixir Lang: Protocolos (documentação oficial) — Guia introdutório oficial sobre definição e implementação de protocolos em Elixir.
- Elixir School: Protocolos — Tutorial prático com exemplos de protocolos para tipos built-in e customizados.
- Programming Elixir ≥ 1.6: Chapter on Protocols — Livro de Dave Thomas com capítulo detalhado sobre protocolos e polimorfismo.
- Elixir Forum: Protocol vs Behaviour — When to Use Which — Discussão da comunidade sobre diferenças entre protocolos e behaviours.
- HexDocs: defprotocol documentation — Documentação oficial da macro
defprotocolno módulo Kernel do Elixir.