F frank.ferreira

Elixir do zero, parte 2: além do básico

Segunda parte da série: structs, streams, strings por dentro, datas, regex, arquivos, organização de módulos, documentação, erros, protocols e behaviours. Tudo reproduzível.

Elixir do zero, parte 2: além do básico

Na parte 1 a gente instalou o Elixir e passou pelos fundamentos: tipos, imutabilidade, pattern matching, pipe, Enum, controle de fluxo e mix. Com isso você já lê e escreve código do dia a dia. Agora vem a camada que transforma “sei a sintaxe” em “consigo construir coisa de verdade”.

Vale o mesmo combinado de antes: não preciso que você já saiba programar. Cada ideia eu explico do zero, e quando ajudar eu comparo com outra linguagem pra quem já conhece. Tudo aqui roda no iex ou num arquivo .exs. Abre o terminal e vem comigo.

Structs: um mapa com forma e nome

Na parte 1 você viu o mapa, que é o dicionário do Elixir: pares de chave e valor, tipo %{nome: "Frank"}. O problema do mapa é que ele aceita qualquer chave. Nada te impede de escrever :nomee por engano. Um struct resolve isso. Ele é um mapa com nome e com um conjunto fixo de campos, definido dentro de um módulo.

defmodule Usuario do
  defstruct nome: "", idade: 0, admin: false
end

defstruct diz quais campos existem e qual o valor padrão de cada um. A partir daí você cria valores daquele tipo com %Usuario{}:

iex> u = %Usuario{nome: "Frank", idade: 30}
%Usuario{nome: "Frank", idade: 30, admin: false}
iex> u.nome
"Frank"
iex> %Usuario{email: "x"}
** (KeyError) key :email not found in struct Usuario

Repare na última linha: tentar um campo que não existe quebra na hora de compilar, não em produção. Se você quer obrigar alguns campos a virem preenchidos, usa @enforce_keys:

defmodule Conta do
  @enforce_keys [:dono]
  defstruct [:dono, saldo: 0]
end

Atualizar um struct cria um novo, do mesmo jeito que com mapa (nada é mutado):

iex> %{u | idade: 31}
%Usuario{nome: "Frank", idade: 31, admin: false}

Se você vem do Python, um struct lembra um @dataclass. Do TypeScript, lembra uma interface com valores padrão. A diferença de fundo é que o struct guarda só dados. O comportamento vai pra funções de módulos, separado do dado.

Keyword lists: o jeito de passar opções

Você já viu isto sem reparar, lá nas dependências do mix.exs. Uma keyword list é uma lista de pares onde a chave é um atom. Ela serve pra passar opções pra uma função, na ordem que você quiser.

iex> String.split("a-b-c", "-", parts: 2)
["a", "b-c"]

Aquele parts: 2 é uma keyword list. Quando ela é o último argumento, o Elixir deixa você omitir os colchetes, então parts: 2 é o mesmo que [parts: 2]. Pra ler valores dela, o módulo Keyword:

iex> opts = [cor: "azul", tamanho: 42]
iex> Keyword.get(opts, :cor)
"azul"
iex> Keyword.get(opts, :peso, 0)
0

A regra de bolso: mapa quando você guarda dados, keyword list quando você passa opções.

Access: cavando dentro de estruturas aninhadas

Quando um dado tem dado dentro de dado, alcançar o que está lá no fundo na mão é chato. O Elixir tem funções pra isso: get_in, put_in e update_in. Você passa o caminho como uma lista de chaves.

iex> pessoa = %{nome: "Ana", endereco: %{cidade: "Belém", uf: "PA"}}
iex> get_in(pessoa, [:endereco, :cidade])
"Belém"
iex> put_in(pessoa, [:endereco, :cidade], "Ananindeua")
%{nome: "Ana", endereco: %{cidade: "Ananindeua", uf: "PA"}}

put_in devolve uma cópia nova com o valor trocado, sem mexer no original.

Enum, Stream e a diferença entre fazer agora e fazer depois

Na parte 1 você usou o Enum pra transformar listas. O Enum é apressado: ele percorre tudo e devolve o resultado na hora. Na maioria das vezes é o que você quer. Mas e se a coleção for gigante, ou infinita? Aí entra o Stream, que é preguiçoso: ele monta a receita do que fazer e só executa quando você pede o resultado.

Primeiro, um range, que é uma faixa de números escrita com dois pontos:

iex> 1..5
1..5
iex> Enum.to_list(1..5)
[1, 2, 3, 4, 5]

Agora compare. Com Enum, cada passo cria uma lista inteira na memória:

iex> 1..1_000_000 |> Enum.map(&(&1 * 2)) |> Enum.take(3)
[2, 4, 6]

Esse Enum.map dobrou um milhão de números só pra você pegar três. Com Stream, ele só dobra os que você realmente usa:

iex> 1..1_000_000 |> Stream.map(&(&1 * 2)) |> Enum.take(3)
[2, 4, 6]

Mesmo resultado, trabalho muito menor. O Stream também sabe lidar com sequência infinita, porque ele só gera o necessário:

iex> Stream.iterate(1, &(&1 * 2)) |> Enum.take(5)
[1, 2, 4, 8, 16]

A regra: comece com Enum. Troque pra Stream quando a coleção for enorme ou quando você for encadear vários passos antes de pegar só um pedaço.

Strings por dentro: binaries, charlists e bitstrings

Na parte 1 eu disse que string em Elixir é um binary. Vale entender o que isso quer dizer, porque uma hora você esbarra nisso. Um binary é uma sequência de bytes. Uma string é um binary com texto em UTF-8 dentro.

Isso explica uma pegadinha clássica. String.length conta letras, byte_size conta bytes, e em UTF-8 uma letra acentuada ocupa mais de um byte:

iex> String.length("olá")
3
iex> byte_size("olá")
4

Existe também a charlist, que é uma lista de números, cada um o código de um caractere. Você escreve com ~c. Ela aparece principalmente quando você conversa com bibliotecas do Erlang, que esperam esse formato:

iex> ~c"abc"
~c"abc"
iex> [?a, ?b, ?c]
~c"abc"

Aquele ?a devolve o número do caractere a (97). Não confunda charlist com string: "abc" (aspas duplas) é binary, ~c"abc" (com o til-cê) é lista de números.

E o bitstring é o binary levado ao osso, onde você mexe bit a bit. Raro no começo, mas bom saber que existe:

iex> <<104, 105>>
"hi"

Os números 104 e 105 são os códigos de h e i, então esse bitstring é a string "hi".

Datas e horas

O Elixir tem tipos próprios pra data e hora, e açúcar de sintaxe (uma escrita curtinha) com til pra criar eles: ~D pra data, ~T pra hora, ~U pra data e hora em UTC.

iex> ~D[2026-06-02]
~D[2026-06-02]
iex> Date.diff(~D[2026-06-02], ~D[2026-05-29])
4
iex> DateTime.utc_now()
~U[2026-06-02 23:10:11.482000Z]

Date.diff deu a diferença em dias entre as duas datas. Pra formatar e converter fuso de um jeito mais confortável, a comunidade usa muito a biblioteca Timex, mas pro básico o que vem na linguagem já resolve.

Regex: procurar padrões em texto

Regex (expressão regular) é uma mini-linguagem pra descrever padrões dentro de texto, tipo “uma sequência de dígitos” ou “algo com @ no meio”. Em Elixir você escreve um regex com ~r.

iex> Regex.match?(~r/\d+/, "tenho 3 gatos")
true
iex> Regex.run(~r/\d+/, "tenho 3 gatos")
["3"]
iex> String.replace("a1b2c3", ~r/\d/, "-")
"a-b-c-"

\d quer dizer “um dígito” e + quer dizer “um ou mais”. Então ~r/\d+/ casa com “um ou mais dígitos”. match? responde sim ou não, run devolve o que casou, e o String.replace troca tudo que casa.

Arquivos e IO

Ler e escrever arquivo segue o mesmo padrão de retorno que você viu na parte 1: a tupla {:ok, valor} ou {:error, motivo}.

iex> File.write("nota.txt", "comprar café\n")
:ok
iex> File.read("nota.txt")
{:ok, "comprar café\n"}
iex> File.read("nao_existe.txt")
{:error, :enoent}

Esse :enoent é o código de erro do sistema pra “arquivo não encontrado”. Pra arquivo grande, em vez de carregar tudo na memória de uma vez, você usa File.stream!, que entrega linha por linha de forma preguiçosa (lembra do Stream):

iex> File.stream!("nota.txt") |> Enum.count()
1

E pra escrever na tela, o velho conhecido IO.puts, além do IO.inspect, que mostra qualquer valor do jeito que o Elixir o enxerga (ótimo pra espiar o que está acontecendo no meio de um pipe):

iex> [1, 2, 3] |> IO.inspect(label: "antes") |> Enum.sum()
antes: [1, 2, 3]
6

MapSet: uma coleção sem repetidos

Um set é uma coleção onde cada valor aparece no máximo uma vez, e onde perguntar “tem isso aqui?” é rápido. No Elixir ele se chama MapSet.

iex> s = MapSet.new([1, 2, 2, 3])
MapSet.new([1, 2, 3])
iex> MapSet.member?(s, 2)
true
iex> MapSet.put(s, 9)
MapSet.new([1, 2, 3, 9])

Repare que o 2 repetido sumiu sozinho. Se você vem de Python, é o mesmo papel do set.

Organizando módulos: alias, import e use

Quando o projeto cresce, os nomes de módulo ficam compridos, tipo MinhaApp.Contas.Usuario. Três palavrinhas ajudam a arrumar isso.

alias cria um apelido curto:

alias MinhaApp.Contas.Usuario
# agora dá pra escrever Usuario em vez do nome inteiro
Usuario.novo()

import traz as funções de um módulo pra dentro do seu, pra você chamar sem o prefixo. Use com parcimônia, pra não sumir de onde a função veio:

import Enum, only: [map: 2]
map([1, 2, 3], &(&1 * 2))

use é o mais poderoso e o mais mágico. Ele chama um código que o outro módulo definiu pra “te preparar”, muitas vezes injetando funções e configuração no seu módulo. Você já usou na parte 1 sem perceber: use ExUnit.Case montou toda a estrutura de teste pra você. Por enquanto, basta saber que quando você vê use AlgumaCoisa, aquele módulo está te dando um kit pronto.

Atributos de módulo como constantes

Um atributo de módulo é uma anotação que começa com @. O uso mais comum é guardar uma constante, um valor fixo que você não quer repetir:

defmodule Circulo do
  @pi 3.14159

  def area(raio), do: @pi * raio * raio
end
iex> Circulo.area(2)
12.56636

O @pi é calculado uma vez, na compilação, e fica embutido onde foi usado. Bom pra valores de configuração e números mágicos.

Documentação e typespecs

Aqui mora um dos motivos pra tanta gente gostar de Elixir. Documentação não é comentário solto, é parte da linguagem. O @moduledoc documenta o módulo, o @doc documenta a função, e os exemplos iex> que você escreve viram teste (você fez isso na parte 1 com doctest).

Além disso tem o typespec, que descreve os tipos que uma função recebe e devolve. Ele não muda como o código roda, mas serve de documentação precisa e alimenta o Dialyzer, uma ferramenta que caça incoerências de tipo sem você rodar nada.

defmodule Calculo do
  @doc "Soma dois inteiros."
  @spec soma(integer(), integer()) :: integer()
  def soma(a, b), do: a + b

  @type ponto :: {number(), number()}
end

@spec soma(integer(), integer()) :: integer() se lê assim: soma recebe dois inteiros e devolve um inteiro. @type cria um apelido pra um tipo, aqui um ponto que é uma tupla de dois números.

Erros: quando levantar e quando devolver

Você tem dois caminhos pra lidar com coisa que dá errado, e a escolha entre eles diz muito sobre o estilo do Elixir.

O primeiro é levantar um erro com raise, e capturar com try/rescue. Isso existe, mas se reserva pro inesperado de verdade, o tipo de coisa que não deveria acontecer:

iex> try do
...>   raise "deu ruim"
...> rescue
...>   e -> "peguei: #{e.message}"
...> end
"peguei: deu ruim"

O segundo caminho, o do dia a dia, é devolver {:ok, valor} ou {:error, motivo} e deixar quem chamou decidir. Você combina o resultado com pattern matching, e quando precisa encadear vários passos que podem falhar, usa o with da parte 1:

with {:ok, conteudo} <- File.read("config.txt"),
     {:ok, numero} <- parse_inteiro(conteudo) do
  numero * 2
else
  {:error, motivo} -> "falhou: #{inspect(motivo)}"
end

A ideia geral: erro esperado vira valor de retorno, erro inesperado vira exceção. Na parte 3 você vai ver que o Elixir leva isso longe, deixando processos quebrarem de propósito e serem reiniciados limpos.

Protocols: a mesma função para tipos diferentes

Às vezes você quer uma função que se comporta diferente conforme o tipo do dado, sem encher tudo de if. Um protocol é um contrato que diz “qualquer tipo pode implementar esta função do seu jeito”.

defprotocol Tamanho do
  @doc "Diz o tamanho de algo."
  def medir(coisa)
end

defimpl Tamanho, for: List do
  def medir(lista), do: length(lista)
end

defimpl Tamanho, for: BitString do
  def medir(texto), do: String.length(texto)
end
iex> Tamanho.medir([1, 2, 3])
3
iex> Tamanho.medir("olá")
3

defprotocol declara a função medir, e cada defimpl ensina um tipo a respondê-la. O Enum, que você usa o tempo todo, funciona assim por baixo: ele fala com o protocol Enumerable, e por isso percorre lista, mapa, range e até Stream com as mesmas funções. Se você vem de uma linguagem orientada a objetos, o protocol faz o papel de uma interface, só que dá pra implementar até pra tipos que não são seus.

Behaviours: um contrato de funções obrigatórias

Behaviour é primo do protocol, mas com outra pegada. Ele diz “todo módulo que se diz isto aqui precisa ter estas funções”. É como a linguagem garante que um plugin tem a cara que o sistema espera.

defmodule Saudacao do
  @callback ola(nome :: String.t()) :: String.t()
end

defmodule SaudacaoFormal do
  @behaviour Saudacao

  @impl Saudacao
  def ola(nome), do: "Prezado #{nome}"
end

@callback lista as funções obrigatórias. @behaviour Saudacao no outro módulo promete cumprir o contrato, e @impl marca a função que cumpre. Se você esquecer de implementar uma função do contrato, o compilador avisa. É exatamente assim que o GenServer, que você vai conhecer na parte 3, pede que você implemente certas funções.

Próximos passos

Esta parte foi a mais densa da série de propósito, porque é aqui que mora a maior parte do “vocabulário” da linguagem. Você não precisa decorar tudo. Volte quando precisar de cada peça: struct pra dar forma a um dado, Stream pra coleção grande, protocol e behaviour quando for organizar código de verdade.

Na parte 3 a gente chega no motivo de o Elixir existir: concorrência. Processos, Task, Agent, GenServer e a ideia de deixar as coisas quebrarem e se levantarem sozinhas. É a parte mais divertida.

Como sempre, nada aqui é pra acreditar no escuro. Abre o iex, cola os exemplos e mexe neles até quebrar.

← anterior
Elixir do zero, parte 1: instalação e fundamentos
próximo →
Elixir do zero, parte 3: concorrência e o jeito BEAM