Enums e Pattern Matching — O Sistema Mais Expressivo que Você Já Viu

Enums e Pattern Matching — O Sistema Mais Expressivo que Você Já Viu

O enum de Rust não é um rótulo numérico: cada variante carrega os dados que lhe fazem sentido, e o match obriga a tratar todas. Daí saem o Option que aposenta o null e a possibilidade de tornar estados impossíveis irrepresentáveis, em vez de depender de convenção entre quem escreve.
Rust

12 min de leitura

 

Se structs são a forma de Rust agrupar dados relacionados, enums são a forma de expressar que um valor pode ser uma coisa ou outra. Em linguagens como Java ou C, enums são basicamente constantes nomeadas. Em Rust, enums são muito mais poderosos — cada variante pode carregar dados diferentes, e o compilador exige que você trate todos os casos possíveis. Combinados com pattern matching, formam um dos sistemas mais expressivos da linguagem.

Este artigo vai mudar a forma como você pensa sobre modelagem de dados.

Enums básicos

A forma mais simples de enum define um conjunto de variantes nomeadas:

enum DirecaoCardinal {
    Norte,
    Sul,
    Leste,
    Oeste,
}

fn descricao(d: DirecaoCardinal) -> &'static str {
    match d {
        DirecaoCardinal::Norte => "Indo para o Norte",
        DirecaoCardinal::Sul   => "Indo para o Sul",
        DirecaoCardinal::Leste => "Indo para o Leste",
        DirecaoCardinal::Oeste => "Indo para o Oeste",
    }
}

fn main() {
    let direcao = DirecaoCardinal::Norte;
    println!("{}", descricao(direcao));
}

Note o match — voltaremos a ele em detalhes daqui a pouco. Por ora, perceba que ele é exaustivo: se você remover um dos braços, o compilador reclama. Nenhuma variante pode ser ignorada silenciosamente.

Enums com dados — onde a mágica começa

Em Rust, cada variante de um enum pode carregar dados — e cada variante pode carregar dados de tipos diferentes:

enum Forma {
    Circulo(f64),               // raio
    Retangulo(f64, f64),        // largura, altura
    Triangulo(f64, f64, f64),   // lados a, b, c
}

fn area(forma: &Forma) -> f64 {
    match forma {
        Forma::Circulo(raio) => std::f64::consts::PI * raio * raio,
        Forma::Retangulo(l, a) => l * a,
        Forma::Triangulo(a, b, c) => {
            let s = (a + b + c) / 2.0;
            (s * (s - a) * (s - b) * (s - c)).sqrt()
        }
    }
}

fn main() {
    let formas = vec![
        Forma::Circulo(3.0),
        Forma::Retangulo(4.0, 5.0),
        Forma::Triangulo(3.0, 4.0, 5.0),
    ];

    for forma in &formas {
        println!("Área: {:.2}", area(forma));
    }
}

Saída:

Área: 28.27
Área: 20.00
Área: 6.00

Cada variante é essencialmente um tipo diferente embutido num mesmo enum. Isso é o que linguagens funcionais chamam de tipos soma — e é uma das ferramentas mais poderosas para modelar domínios com precisão.

Variantes com campos nomeados

Variantes de enum também podem ter campos nomeados, como structs:

enum Evento {
    Clique { x: i32, y: i32 },
    Teclado { tecla: char, modificador: bool },
    Redimensionar { largura: u32, altura: u32 },
    Fechar,
}

fn processar(evento: Evento) {
    match evento {
        Evento::Clique { x, y } => {
            println!("Clique em ({x}, {y})");
        }
        Evento::Teclado { tecla, modificador } => {
            let mod_str = if modificador { "Ctrl+" } else { "" };
            println!("Tecla: {mod_str}{tecla}");
        }
        Evento::Redimensionar { largura, altura } => {
            println!("Janela: {largura}×{altura}");
        }
        Evento::Fechar => {
            println!("Encerrando aplicação.");
        }
    }
}

fn main() {
    processar(Evento::Clique { x: 100, y: 200 });
    processar(Evento::Teclado { tecla: 's', modificador: true });
    processar(Evento::Redimensionar { largura: 1920, altura: 1080 });
    processar(Evento::Fechar);
}

Saída:

Clique em (100, 200)
Tecla: Ctrl+s
Janela: 1920×1080
Encerrando aplicação.

Um único tipo Evento representa quatro realidades completamente diferentes, cada uma com seus próprios dados. Sem herança, sem casting, sem verificações de tipo em tempo de execução. O compilador sabe exatamente o que cada variante carrega.

Option<T> — o fim do null

Tony Hoare, criador do null, chamou sua invenção de "billion dollar mistake" — em referência ao custo acumulado de bugs causados por null pointer exceptions ao longo das décadas. Rust não tem null. Em vez disso, tem Option<T>:

enum Option<T> {
    Some(T),  // existe um valor do tipo T
    None,     // não existe valor
}

Option<T> é um enum da biblioteca padrão — e é tão fundamental que suas variantes Some e None estão disponíveis sem qualificação:

fn dividir(a: f64, b: f64) -> Option<f64> {
    if b == 0.0 {
        None
    } else {
        Some(a / b)
    }
}

fn main() {
    let resultado = dividir(10.0, 2.0);
    let invalido  = dividir(10.0, 0.0);

    match resultado {
        Some(v) => println!("Resultado: {v}"),
        None    => println!("Divisão inválida"),
    }

    match invalido {
        Some(v) => println!("Resultado: {v}"),
        None    => println!("Divisão inválida"),
    }
}

Saída:

Resultado: 5
Divisão inválida

A beleza está na obrigatoriedade: você não pode usar o valor dentro de um Option sem primeiro verificar se ele é Some ou None. O compilador não deixa. Isso elimina por completo os null pointer exceptions — em tempo de compilação.

match em profundidade

O match de Rust é muito mais poderoso que o switch de outras linguagens. Ele compara um valor contra padrões e executa o braço correspondente:

fn classificar_nota(nota: u32) -> &'static str {
    match nota {
        90..=100 => "Excelente",
        70..=89  => "Bom",
        50..=69  => "Regular",
        0..=49   => "Insuficiente",
        _        => "Nota inválida",
    }
}

fn main() {
    for nota in [100, 85, 60, 40, 150] {
        println!("{nota}: {}", classificar_nota(nota));
    }
}

Saída:

100: Excelente
85: Bom
60: Regular
40: Insuficiente
150: Nota inválida

O _ é o padrão curinga — captura qualquer valor não coberto pelos braços anteriores. Como o match é exaustivo, você precisa cobrir todos os casos possíveis ou usar _.

Guards em padrões

Você pode adicionar condições extras a um braço com if:

fn descrever_numero(n: i32) -> &'static str {
    match n {
        x if x < 0    => "negativo",
        0              => "zero",
        x if x % 2 == 0 => "positivo e par",
        _              => "positivo e ímpar",
    }
}

fn main() {
    for n in [-5, 0, 4, 7] {
        println!("{n}: {}", descrever_numero(n));
    }
}

Saída:

-5: negativo
0: zero
4: positivo e par
7: positivo e ímpar

Múltiplos padrões e desestruturação

O match suporta múltiplos padrões com |, e desestruturação de tuplas e structs:

fn main() {
    // Múltiplos padrões
    let x = 3;
    match x {
        1 | 2 => println!("um ou dois"),
        3 | 4 => println!("três ou quatro"),
        _     => println!("outro"),
    }

    // Desestruturação de tupla
    let ponto = (0, -2);
    match ponto {
        (0, 0) => println!("origem"),
        (x, 0) => println!("no eixo x: {x}"),
        (0, y) => println!("no eixo y: {y}"),
        (x, y) => println!("ponto ({x}, {y})"),
    }
}

Saída:

três ou quatro
no eixo y: -2

if let — match para um único caso

Quando você só se importa com uma variante e quer ignorar as demais, if let é mais conciso que match:

fn main() {
    let config: Option<&str> = Some("modo_escuro");

    // Com match — verboso para um único caso
    match config {
        Some(valor) => println!("Config: {valor}"),
        None => {}
    }

    // Com if let — mais limpo
    if let Some(valor) = config {
        println!("Config: {valor}");
    }

    // Com else opcional
    if let Some(valor) = config {
        println!("Usando: {valor}");
    } else {
        println!("Usando configuração padrão");
    }
}

Use if let quando tiver um único caso de interesse. Use match quando precisar tratar múltiplas variantes.

Métodos em enums

Assim como structs, enums podem ter métodos em blocos impl:

#[derive(Debug)]
enum Semaforo {
    Verde,
    Amarelo,
    Vermelho,
}

impl Semaforo {
    fn duracao_segundos(&self) -> u32 {
        match self {
            Semaforo::Verde    => 45,
            Semaforo::Amarelo  => 5,
            Semaforo::Vermelho => 40,
        }
    }

    fn pode_passar(&self) -> bool {
        matches!(self, Semaforo::Verde)
    }

    fn proximo(&self) -> Semaforo {
        match self {
            Semaforo::Verde    => Semaforo::Amarelo,
            Semaforo::Amarelo  => Semaforo::Vermelho,
            Semaforo::Vermelho => Semaforo::Verde,
        }
    }
}

fn main() {
    let mut estado = Semaforo::Verde;

    for _ in 0..6 {
        println!(
            "{:?} — {}s — {}",
            estado,
            estado.duracao_segundos(),
            if estado.pode_passar() { "SIGA" } else { "PARE" }
        );
        estado = estado.proximo();
    }
}

Saída:

Verde — 45s — SIGA
Amarelo — 5s — PARE
Vermelho — 40s — PARE
Verde — 45s — SIGA
Amarelo — 5s — PARE
Vermelho — 40s — PARE

Um programa completo: sistema de pagamentos

Vamos modelar um sistema simplificado de métodos de pagamento — um caso de uso real onde enums brilham:

#[derive(Debug)]
enum MetodoPagamento {
    CartaoCredito { numero: String, parcelas: u8 },
    Pix { chave: String },
    Boleto { vencimento_dias: u32 },
    Dinheiro,
}

impl MetodoPagamento {
    fn taxa(&self, valor: f64) -> f64 {
        match self {
            MetodoPagamento::CartaoCredito { parcelas, .. } => {
                if *parcelas > 1 {
                    valor * 0.03 * (*parcelas as f64)
                } else {
                    0.0
                }
            }
            MetodoPagamento::Pix { .. }     => 0.0,
            MetodoPagamento::Boleto { .. }  => 3.50,
            MetodoPagamento::Dinheiro       => 0.0,
        }
    }

    fn descricao(&self) -> String {
        match self {
            MetodoPagamento::CartaoCredito { numero, parcelas } => {
                let final_numero = &numero[numero.len()-4..];
                format!("Cartão ****{final_numero} em {parcelas}x")
            }
            MetodoPagamento::Pix { chave } => {
                format!("PIX para {chave}")
            }
            MetodoPagamento::Boleto { vencimento_dias } => {
                format!("Boleto — vence em {vencimento_dias} dias")
            }
            MetodoPagamento::Dinheiro => {
                String::from("Pagamento em dinheiro")
            }
        }
    }
}

fn processar_pagamento(valor: f64, metodo: &MetodoPagamento) {
    let taxa = metodo.taxa(valor);
    let total = valor + taxa;

    println!("── Pagamento ──────────────────");
    println!("Método : {}", metodo.descricao());
    println!("Valor  : R$ {valor:.2}");
    if taxa > 0.0 {
        println!("Taxa   : R$ {taxa:.2}");
    }
    println!("Total  : R$ {total:.2}");
    println!("────────────────────────────────\n");
}

fn main() {
    let pagamentos = vec![
        (150.0, MetodoPagamento::Pix {
            chave: String::from("ana@email.com")
        }),
        (500.0, MetodoPagamento::CartaoCredito {
            numero: String::from("1234567890121234"),
            parcelas: 3,
        }),
        (89.90, MetodoPagamento::Boleto {
            vencimento_dias: 5
        }),
        (30.0, MetodoPagamento::Dinheiro),
    ];

    for (valor, metodo) in &pagamentos {
        processar_pagamento(*valor, metodo);
    }
}

Saída:

── Pagamento ──────────────────
Método : PIX para ana@email.com
Valor  : R$ 150.00
Total  : R$ 150.00

── Pagamento ──────────────────
Método : Cartão ****1234 em 3x
Valor  : R$ 500.00
Taxa   : R$ 45.00
Total  : R$ 545.00

── Pagamento ──────────────────
Método : Boleto — vence em 5 dias
Valor  : R$ 89.90
Taxa   : R$ 3.50
Total  : R$ 93.40

── Pagamento ──────────────────
Método : Pagamento em dinheiro
Valor  : R$ 30.00
Total  : R$ 30.00

O que enums revelam sobre design

Modelar com enums força você a pensar em todos os estados possíveis do seu sistema antes de escrever a lógica. Essa é a essência do que a comunidade Rust chama de "make illegal states unrepresentable" — tornar estados inválidos impossíveis de existir no tipo.

Se um pagamento precisa obrigatoriamente ser PIX, cartão, boleto ou dinheiro, e nada mais, o enum garante isso. Não há como criar um MetodoPagamento com um quinto estado não previsto. O compilador é a documentação viva do seu domínio.

Exercícios

Exercício 1

Defina um enum Forma cujas variantes carreguem os dados necessários (círculo com raio, retângulo com lados, triângulo com base e altura) e escreva uma função que calcule a área usando match.

Ver resposta

✓ Resposta:

enum Forma {
    Circulo(f64),
    Retangulo(f64, f64),
    Triangulo { base: f64, altura: f64 },
}

fn area(f: &Forma) -> f64 {
    match f {
        Forma::Circulo(r) => std::f64::consts::PI * r * r,
        Forma::Retangulo(l, a) => l * a,
        Forma::Triangulo { base, altura } => base * altura / 2.0,
    }
}

Cada variante carrega exatamente os dados que lhe fazem sentido — é o que diferencia o enum de Rust do enum de C, que é só um rótulo numérico.

Exercício 2

Explique por que Option<T> torna desnecessário o null, e o que o compilador exige de quem recebe um Option.

Ver resposta

✓ Resposta: Option<T> tem duas variantes, Some(valor) e None, e é um tipo distinto de T. Isso significa que só pode estar ausente quem foi declarado como podendo — um i32 nunca é nulo, ao contrário de linguagens em que qualquer referência pode ser. O compilador exige tratar o caso None antes de chegar ao valor, seja com match, if let, unwrap_or ou similares. A verificação que em outras linguagens depende de disciplina aqui é imposta pelo tipo, e o erro de esquecer vira erro de compilação em vez de falha em produção.

Exercício 3

O match abaixo não compila. Explique e mostre duas formas de corrigir.

let nota = 7;
let conceito = match nota {
    10 => "excelente",
    7..=9 => "bom",
    5..=6 => "regular",
};
Ver resposta

✓ Resposta: O match em Rust precisa ser exaustivo: os padrões dados não cobrem todos os i32 possíveis (faltam os menores que 5 e os negativos), e o erro é non-exhaustive patterns. Primeira correção, com o padrão curinga:

    _ => "insuficiente",

Segunda, nomeando o valor restante quando ele for útil:

    outra => { println!("nota {outra} fora da faixa"); "invalida" }

A exaustividade é o que faz o compilador avisar quando alguém acrescenta uma variante nova a um enum e esquece de tratá-la em algum lugar.

Exercício 4

Explique a diferença entre match e if let, e reescreva o trecho abaixo com a construção mais adequada.

match configuracao {
    Some(valor) => println!("configurado: {valor}"),
    None => (),
}
Ver resposta

✓ Resposta: match obriga a cobrir todos os casos; if let trata apenas um padrão e ignora o resto, trocando exaustividade por concisão. Quando um dos ramos é => (), como aqui, o if let comunica melhor:

if let Some(valor) = configuracao {
    println!("configurado: {valor}");
}

O contrapeso é que, ao surgir uma variante nova no enum, o match avisaria e o if let não — por isso ele é adequado para Option e arriscado para enums de domínio que ainda vão crescer.

Exercício 5

Escreva um enum que represente o resultado de uma operação bancária (sucesso com o novo saldo, saldo insuficiente com o quanto falta, conta inexistente) e uma função que trate os três casos.

Ver resposta

✓ Resposta:

enum Operacao {
    Sucesso { saldo: f64 },
    SaldoInsuficiente { faltam: f64 },
    ContaInexistente,
}

fn relatar(op: Operacao) {
    match op {
        Operacao::Sucesso { saldo } => println!("ok, saldo agora é {saldo:.2}"),
        Operacao::SaldoInsuficiente { faltam } => println!("faltam {faltam:.2}"),
        Operacao::ContaInexistente => println!("conta não encontrada"),
    }
}

Repare que cada variante carrega só o que faz sentido para ela — não existe um campo faltam preenchido com zero no caso de sucesso. Estados impossíveis ficam irrepresentáveis, em vez de dependerem de convenção.

Fontes e leituras recomendadas

Comentários

Mais em Rust

Borrowing e Referências — Usando sem Possuir
Borrowing e Referências — Usando sem Possuir

Ownership resolve a memória e cria um incômodo: passar um valor adiante…

Funções, Expressões e Como Rust Pensa Diferente sobre Retorno de Valores
Funções, Expressões e Como Rust Pensa Diferente sobre Retorno de Valores

Rust distingue o que executa do que produz valor, e essa distinção decide como…

Macros — Escrevendo Código que Escreve Código
Macros — Escrevendo Código que Escreve Código

Macro opera sobre o código antes da compilação, e é o que permite a println…