Pattern matching avançado
Esta aula explora técnicas avançadas de pattern matching em Rust, incluindo padrões em ranges, desestruturação aninhada, bindings com @ e uso de padrões em funções. O conteúdo é prático, com exemplos de código e exercícios para consolidar o aprendizado.
O pattern matching é uma das características mais poderosas de Rust, permitindo que você escreva código expressivo e seguro ao lidar com dados complexos. Nesta aula, vamos aprofundar nosso conhecimento em técnicas avançadas de pattern matching, que vão desde padrões em ranges até a desestruturação de estruturas aninhadas e o uso de bindings com @. Essas ferramentas são essenciais para escrever código idiomático e eficiente em Rust, especialmente quando lidamos com enums, structs e tipos compostos.
Dominar essas técnicas não só torna seu código mais legível, mas também ajuda a evitar erros comuns, como acessos inseguros a campos opcionais. Vamos começar explorando padrões em ranges, que permitem verificar se um valor está dentro de um intervalo de forma concisa e segura.
Padrões em ranges
Em Rust, podemos usar padrões de range para combinar valores que se encontram em um intervalo específico. Isso é especialmente útil quando queremos verificar se um número está dentro de um limite, como em validações ou em lógica de jogos. Os padrões de range podem ser usados com tipos numéricos (inteiros e floats) e com caracteres, e podem ser inclusivos (..=) ou exclusivos (..).
Por exemplo, para classificar uma nota de 0 a 100 em conceitos, podemos usar ranges em um match. Isso evita uma série de condições if verbosas e torna o código mais declarativo. É importante notar que ranges em padrões são fechados à esquerda e abertos à direita por padrão, mas com ..= incluímos o valor final.
fn classificar(nota: u8) -> &'static str {
match nota {
0..=59 => "Reprovado",
60..=79 => "Aprovado",
80..=89 => "Bom",
90..=100 => "Excelente",
_ => "Nota inválida",
}
}
fn main() {
println!("{}", classificar(72)); // Aprovado
}
Além de números, podemos usar ranges com chars, o que é útil para verificar se um caractere está em um intervalo alfabético. Por exemplo, para verificar se um caractere é uma letra minúscula, podemos usar 'a'..='z'. Essa abordagem é mais eficiente e clara do que comparar manualmente cada faixa.
fn tipo_caractere(c: char) -> &'static str {
match c {
'a'..='z' => "Minúscula",
'A'..='Z' => "Maiúscula",
'0'..='9' => "Dígito",
_ => "Outro",
}
}
fn main() {
println!("{}", tipo_caractere('M')); // Maiúscula
}
Uma observação importante: ranges em padrões não podem ser usados com tipos de ponto flutuante (float) porque a igualdade exata é problemática. Para floats, é mais comum usar condições com if em guardas de match.
Desestruturação aninhada
Desestruturação é o processo de extrair valores de estruturas de dados (como structs, enums, tuplas) diretamente no padrão. Quando essas estruturas estão aninhadas, podemos desestruturar em múltiplos níveis de uma só vez, o que torna o código muito mais conciso e legível. Por exemplo, se temos uma struct Pessoa que contém uma struct Endereco, podemos extrair campos internos diretamente no padrão.
Considere o seguinte exemplo com uma enum Mensagem que contém variantes com dados compostos:
enum Mensagem {
Texto(String),
Coordenadas { x: f64, y: f64 },
Move { direcao: String, distancia: u32 },
}
fn processar(mensagem: Mensagem) {
match mensagem {
Mensagem::Texto(t) => println!("Texto: {}", t),
Mensagem::Coordenadas { x, y } => println!("Coordenadas: ({}, {})", x, y),
Mensagem::Move { direcao, distancia } => {
println!("Mover {} para {}", direcao, distancia);
}
}
}
Agora, se Coordenadas contiver uma tupla ou outra struct, podemos desestruturar ainda mais. Por exemplo, se Coordenadas tiver um campo origem que é uma tupla (f64, f64), podemos extrair os valores da tupla diretamente no padrão:
enum Mensagem {
Coordenadas { origem: (f64, f64), destino: (f64, f64) },
}
fn processar(mensagem: Mensagem) {
match mensagem {
Mensagem::Coordenadas { origem: (x1, y1), destino: (x2, y2) } => {
println!("Origem: ({}, {}) - Destino: ({}, {})", x1, y1, x2, y2);
}
}
}
Essa técnica é extremamente útil quando trabalhamos com dados complexos, como respostas de APIs ou configurações. Ela reduz a necessidade de acessar campos por meio de métodos ou índices, tornando o código mais robusto a mudanças na estrutura.
@ bindings
O operador @ em padrões nos permite vincular um valor a uma variável enquanto também desestruturamos parte dele. Isso é útil quando queremos usar o valor inteiro em um braço do match, mas também queremos acessar partes internas. Por exemplo, em uma enum que representa uma árvore binária, podemos querer tanto o nó inteiro quanto seus campos.
A sintaxe é variável @ padrão. Isso vincula o valor que corresponde ao padrão à variável, permitindo usá-lo no corpo do braço. Vamos ver um exemplo clássico com uma lista:
enum Lista {
Vazio,
Cons(i32, Box<Lista>),
}
fn comprimento(lista: &Lista) -> u32 {
match lista {
Lista::Vazio => 0,
Lista::Cons(_, resto) => 1 + comprimento(resto),
}
}
fn primeira_lista(lista: &Lista) -> Option<&i32> {
match lista {
Lista::Cons(valor, _) => Some(valor),
Lista::Vazio => None,
}
}
Com @, podemos capturar a cauda inteira enquanto também desestruturamos seus elementos:
fn processar(lista: &Lista) {
match lista {
Lista::Cons(primeiro, resto @ Lista::Cons(segundo, _)) => {
println!("Primeiro: {}, Segundo: {}", primeiro, segundo);
// resto ainda é uma referência à cauda
}
Lista::Cons(primeiro, Lista::Vazio) => println!("Apenas {}", primeiro),
Lista::Vazio => println!("Lista vazia"),
}
}
Nesse exemplo, resto @ Lista::Cons(segundo, _) captura a cauda da lista (que é uma Lista) e também desestrutura o segundo elemento. Isso é particularmente útil quando precisamos fazer algo com a subestrutura inteira, como passá-la para outra função, e também com seus componentes.
Outro uso comum é em structs, para capturar o valor inteiro de um campo enquanto também o desestrutura:
struct Ponto {
x: i32,
y: i32,
}
fn descrever(ponto: &Ponto) {
match ponto {
p @ Ponto { x, y } => {
println!("Ponto: ({}, {}) - inteiro: ({}, {})", x, y, p.x, p.y);
}
}
}
Padrões em funções
Além do match, podemos usar padrões diretamente nos parâmetros de funções. Isso é uma forma de desestruturação que torna a assinatura da função mais expressiva e reduz a necessidade de código extra dentro do corpo. Por exemplo, em vez de receber uma struct e acessar seus campos, podemos desestruturá-la diretamente nos parâmetros.
Considere uma função que recebe uma tupla e calcula a soma dos elementos:
fn soma_tupla((a, b): (i32, i32)) -> i32 {
a + b
}
fn main() {
let ponto = (3, 4);
println!("Soma: {}", soma_tupla(ponto)); // 7
}
Da mesma forma, podemos desestruturar structs e enums nos parâmetros. Isso é especialmente útil em closures, onde a desestruturação pode simplificar o código. Por exemplo, ao iterar sobre um vetor de pares, podemos usar um closure que desestrutura cada par:
fn main() {
let pares = vec![(1, 2), (3, 4), (5, 6)];
let soma_total: i32 = pares.iter().map(|(a, b)| a + b).sum();
println!("Soma total: {}", soma_total); // 21
}
Padrões em funções também podem ser usados com enums, permitindo que a função trate apenas uma variante específica. Por exemplo, uma função que recebe Option<i32> e processa apenas o caso Some pode desestruturar diretamente:
fn processar_opcao(Some(valor): Option<i32>) {
println!("Valor: {}", valor);
}
fn main() {
let x = Some(10);
processar_opcao(x); // Valor: 10
// processar_opcao(None); // Isso causaria pânico! Use match para lidar com None.
}
É importante ter cuidado: se o padrão não corresponder, a função entrará em pânico (panic). Portanto, essa técnica deve ser usada somente quando temos certeza de que o argumento sempre corresponde ao padrão, ou quando queremos que um valor inválido cause um erro explícito.
Boas práticas
Ao usar pattern matching avançado, lembre-se de que a clareza do código é fundamental. Prefira padrões que reflitam a intenção do programa, e use _ para ignorar partes que não são necessárias. Evite padrões muito complexos que possam tornar o código difícil de ler; se necessário, divida em funções menores. Além disso, use guardas (if) quando precisar de condições adicionais que não podem ser expressas apenas com padrões.
Outra boa prática é usar @ para evitar duplicação de código quando você precisa tanto do valor inteiro quanto de suas partes. Isso mantém o código DRY (Don't Repeat Yourself) e evita acessos redundantes.
Referências
- The Rust Programming Language - Pattern Syntax
- Rust by Example - Match
- The Rust Reference - Patterns
- Rust Standard Library - match keyword
- Rust by Example - Destructuring
- Rust by Example - Binding with @
Exercícios
- Escreva uma função que receba um número inteiro e retorne uma string indicando se ele é negativo, zero, positivo pequeno (1 a 10), ou positivo grande (maior que 10), usando ranges em um
match. - Dada uma struct
Enderecocom camposrua: Stringenumero: u32, e uma structPessoacom campoendereco: Endereco, escreva uma função que desestruture a pessoa e imprima o número da rua. - Considere uma enum
Resultadocom variantesSucesso(i32)eErro(String). Escreva uma função que use um padrão com@para capturar o valor inteiro deSucessoe também o valor interno, imprimindo ambos. - Escreva uma função que receba uma tupla
(i32, i32)e retorne a soma dos quadrados, usando um padrão de função para desestruturar os parâmetros. - Dada uma lista encadeada simples (como a enum
Listado exemplo), escreva uma função que retorne o segundo elemento, se existir, usando um padrão com@para capturar a cauda.
fn classificar_numero(n: i32) -> &'static str {
match n {
i32::MIN..=-1 => "Negativo",
0 => "Zero",
1..=10 => "Pequeno",
_ => "Grande",
}
}struct Endereco { rua: String, numero: u32 }
struct Pessoa { endereco: Endereco }
fn imprimir_numero(pessoa: Pessoa) {
let Pessoa { endereco: Endereco { numero, .. } } = pessoa;
println!("Número: {}", numero);
}enum Resultado {
Sucesso(i32),
Erro(String),
}
fn processar(resultado: Resultado) {
match resultado {
res @ Resultado::Sucesso(valor) => {
println!("Sucesso: valor = {}, inteiro = {:?}", valor, res);
}
Resultado::Erro(msg) => println!("Erro: {}", msg),
}
}fn soma_quadrados((a, b): (i32, i32)) -> i32 {
a * a + b * b
}enum Lista {
Vazio,
Cons(i32, Box<Lista>),
}
fn segundo_elemento(lista: &Lista) -> Option<i32> {
match lista {
Lista::Cons(_, resto @ Lista::Cons(segundo, _)) => Some(*segundo),
_ => None,
}
}