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