Enum e pattern matching em Rust

1. Visão geral

Em muitas linguagens, enum é apenas uma lista de constantes nomeadas. Em Rust, cada variante do enum ainda pode carregar seus próprios dados. Quando combinado com match, o compilador o força a lidar com todos os casos.

Este artigo vai desde como definir enums, passando por match, guards, binding @, até Option, Result, if let, let else e matches!. Depois de ler, você poderá modelar estados de negócio com enums e evitar os erros mais comuns.

O artigo usa Rust 1.65 ou superior (necessário para let else) e não precisa de bibliotecas externas.

2. Definindo enums com dados

Suponha que você precise descrever o estado de um pedido:

#[derive(Debug, Clone, PartialEq)]
enum OrderStatus {
    Pending,
    Paid(u64),
    Shipped { carrier: String, tracking: String },
    Cancelled { reason: String },
}

fn main() {
    let statuses = vec![
        OrderStatus::Pending,
        OrderStatus::Paid(250_000),
        OrderStatus::Shipped {
            carrier: "GHN".to_string(),
            tracking: "GHN123".to_string(),
        },
        OrderStatus::Cancelled { reason: "Sem estoque".to_string() },
    ];

    for s in &statuses {
        println!("{:?}", s);
    }
}

Este enum tem três tipos de variantes:

  • Pending é uma unit variant, não carrega dados.
  • Paid(u64) é uma tuple variant, carrega o valor pago por posição.
  • Shipped { .. } e Cancelled { .. } são struct variants, carregam dados com campos nomeados.

#[derive(Debug, Clone, PartialEq)] permite imprimir valores com {:?}, clonar e comparar com ==. A parte de comparação será necessária ao escrever testes.

Pending
Paid(250000)
Shipped { carrier: "GHN", tracking: "GHN123" }
Cancelled { reason: "Sem estoque" }

O ponto crucial: um pedido Pending não pode “acidentalmente” ter um código de rastreamento. Os dados existem apenas na variante que os necessita. Ao usar um struct com muitos campos Option, você não tem essa garantia.

3. match básico e exaustividade

match compara um valor com cada padrão de cima para baixo. O primeiro padrão que corresponder será executado:

fn describe(status: &OrderStatus) -> String {
    match status {
        OrderStatus::Pending => "Aguardando pagamento".to_string(),
        OrderStatus::Paid(amount) => format!("Pagamento confirmado: {amount} VND"),
        OrderStatus::Shipped { carrier, tracking } => {
            format!("Sendo entregue por {carrier}, código {tracking}")
        }
        OrderStatus::Cancelled { reason } => format!("Cancelado: {reason}"),
    }
}

Alguns pontos para observar:

  • status tem tipo &OrderStatus. Graças aos default binding modes, você escreve padrões como faria com valores normais. As variáveis amount, carrier, tracking serão referências (&u64, &String), então nenhum dado é movido.
  • match é uma expressão. Todos os ramos devem retornar o mesmo tipo, neste caso String.

Agora tente remover o ramo Cancelled. O compilador recusará compilar:

error[E0004]: non-exhaustive patterns: `&OrderStatus::Cancelled { .. }` not covered
 --> src/main.rs:12:11
   |
12 |     match status {
   |           ^^^^^^ pattern `&OrderStatus::Cancelled { .. }` not covered

Este é o maior valor do match. Quando você adiciona uma nova variante, por exemplo Refunded, todo match que não a trata reporta um erro. Você não precisa procurar manualmente por onde fazer mudanças.

4. Padrões avançados: tupla, |, guard, range e @

Padrões não são apenas para enums. Você pode fazer pattern matching em uma tupla para verificar múltiplas condições ao mesmo tempo:

enum CustomerTier {
    Regular,
    Silver,
    Gold,
}

fn discount(tier: &CustomerTier, total: u64) -> u64 {
    match (tier, total) {
        (CustomerTier::Gold, _) => total / 10,
        (CustomerTier::Silver, t @ 1_000_000..) => t / 20,
        (CustomerTier::Silver | CustomerTier::Regular, t) if t >= 2_000_000 => 50_000,
        _ => 0,
    }
}

fn main() {
    println!("{}", discount(&CustomerTier::Gold, 300_000));
    println!("{}", discount(&CustomerTier::Silver, 1_200_000));
    println!("{}", discount(&CustomerTier::Regular, 2_500_000));
    println!("{}", discount(&CustomerTier::Regular, 100_000));
}

Cada ramo funciona da seguinte maneira:

  • _ corresponde a qualquer valor e não vincula nenhuma variável.
  • t @ 1_000_000.. verifica se total está no intervalo de 1 milhão em diante, enquanto vincula esse valor a t.
  • CustomerTier::Silver | CustomerTier::Regular usa | para combinar múltiplos padrões em um ramo.
  • if t >= 2_000_000 é um match guard. É uma condição adicional que só é avaliada após o padrão corresponder.
  • _ => 0 é o ramo padrão. Este ramo é obrigatório porque u64 tem infinitos valores possíveis.
30000
60000
50000
0

Para struct variants, use .. para ignorar campos que não lhe interessam:

fn shipping_fee(status: &OrderStatus) -> u64 {
    match status {
        OrderStatus::Shipped { carrier, .. } if carrier == "GHN" => 22_000,
        OrderStatus::Shipped { .. } => 30_000,
        _ => 0,
    }
}

Nota: O compilador não usa guards ao verificar exaustividade. Se você escrever apenas o ramo Shipped { .. } if ... sem um ramo Shipped sem o guard, você ainda deve adicionar outro ramo para cobrir todos os casos.

5. Option e Result também são apenas enums

A biblioteca padrão define Option<T> (consistindo em Some(T) e None) e Result<T, E> (consistindo em Ok(T) e Err(E)). Porque são enums, você os manipula com exatamente as ferramentas acima:

fn find_customer(id: u32) -> Option<&'static str> {
    match id {
        1 => Some("Ana"),
        2 => Some("Bruno"),
        _ => None,
    }
}

fn parse_quantity(input: &str) -> Result<u32, String> {
    match input.trim().parse::<u32>() {
        Ok(0) => Err("A quantidade deve ser maior que 0".to_string()),
        Ok(n) => Ok(n),
        Err(e) => Err(format!("Inválido: {e}")),
    }
}

fn main() {
    println!("{:?}", find_customer(2));
    println!("{:?}", find_customer(9));
    println!("{:?}", parse_quantity(" 3 "));
    println!("{:?}", parse_quantity("0"));
    println!("{:?}", parse_quantity("abc"));
}

Ok(0) é um padrão aninhado. Ele corresponde quando o resultado é Ok e o valor dentro é 0. Como Ok(0) vem antes de Ok(n), o valor 0 é capturado antes de chegar ao padrão genérico.

Some("Bruno")
None
Ok(3)
Err("A quantidade deve ser maior que 0")
Err("Inválido: invalid digit found in string")

Rust não tem null. Todo valor que “pode estar ausente” deve passar por Option, e o compilador o força a lidar com o caso None.

6. Sintaxe concisa: if let, let else, while let, matches!

Escrever match completo pode ser verboso quando você só se importa com uma variante. Rust tem uma sintaxe mais concisa para esses casos.

6.1. if let

fn tracking_code(status: &OrderStatus) -> Option<&str> {
    if let OrderStatus::Shipped { tracking, .. } = status {
        Some(tracking.as_str())
    } else {
        None
    }
}

if let executa um bloco quando o padrão corresponde. A parte else é opcional. Esta sintaxe não o força a lidar com todos os casos, então use quando as outras variantes realmente não importam.

6.2. let else

let else funciona bem com “early return”: extrai os dados se o padrão corresponder, caso contrário sai imediatamente.

fn charge(status: &OrderStatus) -> Result<u64, String> {
    let OrderStatus::Paid(amount) = status else {
        return Err("Pedido não foi pago".to_string());
    };
    Ok(*amount)
}

Após a linha let, a variável amount fica disponível no resto da função sem exigir blocos aninhados. O bloco else deve sair do fluxo atual usando return, break, continue ou panic!.

6.3. while let e matches!

fn main() {
    let mut queue = vec![
        OrderStatus::Paid(100_000),
        OrderStatus::Pending,
        OrderStatus::Paid(400_000),
    ];

    let paid = queue
        .iter()
        .filter(|s| matches!(s, OrderStatus::Paid(_)))
        .count();
    println!("Pedidos pagos: {paid}");

    while let Some(status) = queue.pop() {
        println!("Processando: {status:?}");
    }
}
  • matches!(valor, padrão) retorna bool. Esta macro é muito útil em filter ou assert!.
  • while let Some(status) = queue.pop() itera até que pop() retorne None.
Pedidos pagos: 2
Processando: Paid(400000)
Processando: Pending
Processando: Paid(100000)

7. Anexar métodos aos enums: modelando transições de estado

Enums também têm impl como structs. Uma boa forma de usá-los é representar transições de estado válidas:

impl OrderStatus {
    fn ship(self, carrier: &str, tracking: &str) -> Result<OrderStatus, String> {
        match self {
            OrderStatus::Paid(_) => Ok(OrderStatus::Shipped {
                carrier: carrier.to_string(),
                tracking: tracking.to_string(),
            }),
            other => Err(format!("Não é possível entregar no estado {other:?}")),
        }
    }

    fn cancel(self, reason: &str) -> Result<OrderStatus, String> {
        match self {
            OrderStatus::Pending | OrderStatus::Paid(_) => Ok(OrderStatus::Cancelled {
                reason: reason.to_string(),
            }),
            other => Err(format!("Não é possível cancelar no estado {other:?}")),
        }
    }

    fn is_final(&self) -> bool {
        matches!(self, OrderStatus::Cancelled { .. })
    }
}

Alguns pontos para observar:

  • ship e cancel recebem self por valor, então o estado anterior é consumido. Você não pode continuar usando a variável de estado antiga após a transição.
  • O ramo other => ... vincula todo o valor restante à variável other para incluir na mensagem de erro.
  • is_final é somente leitura, então recebe &self.

8. Escrevendo testes para o comportamento principal

Rust vem com um framework de teste integrado. Você coloca testes em um módulo #[cfg(test)] no mesmo arquivo:

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn paid_order_can_be_shipped() {
        let result = OrderStatus::Paid(250_000).ship("GHN", "GHN123");

        assert_eq!(
            result,
            Ok(OrderStatus::Shipped {
                carrier: "GHN".to_string(),
                tracking: "GHN123".to_string(),
            })
        );
    }

    #[test]
    fn pending_order_cannot_be_shipped() {
        let result = OrderStatus::Pending.ship("GHN", "X");

        assert!(result.is_err());
        assert!(result.unwrap_err().contains("Pending"));
    }

    #[test]
    fn cancelled_order_is_final() {
        let status = OrderStatus::Pending.cancel("Cliente mudou de ideia").unwrap();

        assert!(status.is_final());
        assert!(matches!(status, OrderStatus::Cancelled { ref reason } if reason == "Cliente mudou de ideia"));
    }
}

assert_eq! funciona porque o enum derivou PartialEq e Debug. Quando um teste falha, Rust imprime tanto o valor esperado quanto o real. No último teste, ref reason vincula o campo por referência, então status não é movido para dentro de matches!.

cargo test
running 3 tests
test tests::cancelled_order_is_final ... ok
test tests::paid_order_can_be_shipped ... ok
test tests::pending_order_cannot_be_shipped ... ok

test result: ok. 3 passed; 0 failed; 0 ignored

9. Erros comuns e quando não usar

Abuso de _ perde a exaustividade. Se você escreve _ => ... para um enum que você controla, quando adicionar uma nova variante depois, o compilador fica silencioso. A nova variante cai diretamente no ramo padrão, e isso geralmente não é o que você quer. Para enums de negócio, liste explicitamente cada variante.

Mover dados de uma referência. Escrever match *status { OrderStatus::Cancelled { reason } => ... } causará erro:

error[E0507]: cannot move out of `status` as enum variant `Cancelled` which is behind a shared reference

Há três maneiras de corrigir: fazer pattern matching diretamente em status (referência), usar ref reason, ou clonar os dados se você realmente precisar de propriedade.

Colocar o ramo genérico antes do ramo específico. Se Ok(n) vem antes de Ok(0), o ramo Ok(0) nunca será executado. O compilador apenas avisa sobre unreachable pattern e não trata como erro, então você pode facilmente perder isso.

Variantes muito grandes. O tamanho de um enum é o tamanho da maior variante mais o espaço para a tag. Se uma variante contém um array grande, todo valor do enum usará tanta memória. Envolva essa variante com Box<...>. O lint clippy::large_enum_variant vai lembrá-lo disso.

Nota: Se você está escrevendo uma biblioteca e quer adicionar uma variante depois sem quebrar o código dos usuários, marque o enum com #[non_exhaustive]. Depois disso, o código em outros crates é obrigado a ter um ramo _.

Quando não usar enums? Enums são apropriados quando o conjunto de casos é fechado e sob seu controle, como estados de pedido ou tipos de erro. Se o conjunto de tipos é aberto, como quando usuários de sua biblioteca precisam adicionar novos métodos de pagamento, use trait (Box<dyn PaymentMethod> ou genéricos). Adicionar uma variante exige modificar o enum original, enquanto traits permitem extensão de fora.

10. Conclusão

Enums em Rust permitem que cada caso leve exatamente os dados que precisa. match o força a lidar completamente com todos os casos em tempo de compilação. Você viu:

  • Unit, tuple e struct variants.
  • match com binding, |, guards, ranges, @ e ...
  • Option e Result como enums comuns.
  • Sintaxes concisas if let, let else, while let, matches!.
  • Como usar métodos em enums para modelar transições de estado, com testes.

A regra mais fácil de lembrar: para enums que você possui, liste todas as variantes em vez de usar _. Assim, toda vez que você adiciona uma nova variante, o compilador mostrará exatamente onde você precisa fazer mudanças.

rustenumpattern matchingmatchoption