Genéricos são uma ferramenta poderosa em Rust que permite escrever código flexível e reutilizável sem sacrificar a segurança de tipos. Eles permitem que funções, structs e enums operem sobre múltiplos tipos concretos, enquanto o compilador garante a correção em tempo de compilação. Esta aula explora os conceitos fundamentais de genéricos em Rust, desde a sintaxe básica até tópicos avançados como monomorfização e limites.

Dominar genéricos é essencial para criar bibliotecas e aplicações robustas em Rust, pois eles são a base para abstrações como traits, iterators e closures. Vamos começar com funções genéricas e depois expandir para tipos genéricos e restrições.

Funções genéricas

Funções genéricas permitem que uma única função trabalhe com diferentes tipos de dados. A sintaxe usa <T> antes dos parâmetros para declarar um parâmetro de tipo. Por exemplo, uma função que encontra o maior valor em um slice pode ser escrita genericamente:

fn maior<T: PartialOrd>(lista: &[T]) -> &T {
    let mut maior = &lista[0];
    for item in lista {
        if item > maior {
            maior = item;
        }
    }
    maior
}

fn main() {
    let numeros = vec![3, 5, 1, 8, 2];
    println!("Maior número: {}", maior(&numeros));
    
    let chars = vec!['a', 'z', 'm', 'b'];
    println!("Maior char: {}", maior(&chars));
}

Neste exemplo, T é um parâmetro de tipo que deve implementar a trait PartialOrd (para comparação). O compilador irá gerar código específico para cada tipo concreto usado (monomorfização).

Structs e enums genéricos

Structs e enums também podem ser genéricos, permitindo que seus campos ou variantes armazenem valores de tipos arbitrários. Por exemplo, uma struct Ponto que representa coordenadas pode ser genérica sobre o tipo dos eixos:

struct Ponto<T> {
    x: T,
    y: T,
}

fn main() {
    let ponto_int = Ponto { x: 5, y: 10 };
    let ponto_float = Ponto { x: 1.5, y: 3.2 };
    println!("Ponto int: ({}, {})", ponto_int.x, ponto_int.y);
    println!("Ponto float: ({}, {})", ponto_float.x, ponto_float.y);
}

Enums também podem ser genéricos, como o famoso Option<T> e Result<T, E> da biblioteca padrão. Você pode definir seus próprios enums genéricos:

enum Resultado<T, E> {
    Sucesso(T),
    Erro(E),
}

fn main() {
    let sucesso: Resultado<i32, String> = Resultado::Sucesso(42);
    let erro: Resultado<i32, String> = Resultado::Erro("Algo deu errado".to_string());
}

Monomorfização

Monomorfização é o processo pelo qual o compilador Rust gera código específico para cada tipo concreto usado com um genérico. Isso significa que não há custo de desempenho em tempo de execução para usar genéricos; o código resultante é tão eficiente quanto se você tivesse escrito funções separadas para cada tipo. Por exemplo, a função maior acima, quando chamada com &[i32] e &[char], gera duas versões distintas da função, otimizadas para cada tipo.

A monomorfização é uma das razões pelas quais Rust é conhecido por oferecer abstrações de custo zero. No entanto, ela pode aumentar o tamanho do binário final, pois cada combinação de tipos gera seu próprio código. Em geral, isso é aceitável, mas em sistemas com restrições de espaço, pode ser necessário considerar alternativas como trait objects (dynamic dispatch).

Limites

Limites (trait bounds) são usados para restringir quais tipos podem ser usados com um genérico. Eles especificam que o tipo deve implementar uma ou mais traits. Por exemplo, a função maior exige PartialOrd. Você pode especificar múltiplos limites usando o sinal + ou a sintaxe where:

fn imprimir_e_maior<T: std::fmt::Display + PartialOrd>(a: T, b: T) {
    println!("Comparando {} e {}", a, b);
    if a > b {
        println!("{} é maior", a);
    } else {
        println!("{} é maior", b);
    }
}

// Alternativa com where
fn imprimir_e_maior<T>(a: T, b: T) 
where
    T: std::fmt::Display + PartialOrd,
{
    println!("Comparando {} e {}", a, b);
    if a > b {
        println!("{} é maior", a);
    } else {
        println!("{} é maior", b);
    }
}

Limites também podem ser aplicados a structs e enums, mas note que a declaração do limite no struct não é herdada automaticamente pelos métodos; você precisa repetir o limite nos métodos que precisam dele:

struct Par<T> {
    primeiro: T,
    segundo: T,
}

impl<T: PartialOrd> Par<T> {
    fn maior(&self) -> &T {
        if self.primeiro > self.segundo {
            &self.primeiro
        } else {
            &self.segundo
        }
    }
}

Boas práticas

Ao usar genéricos, prefira limites claros e específicos para evitar restrições desnecessárias. Use a sintaxe where para limites complexos, pois ela melhora a legibilidade. Lembre-se de que genéricos são resolvidos em tempo de compilação, então não há custo em tempo de execução. No entanto, evite usar genéricos quando uma trait object é mais apropriada (por exemplo, quando você precisa de heterogeneidade em uma coleção).

Referências

Exercícios

  1. Escreva uma função genérica chamada menor que recebe dois valores do mesmo tipo (que implementam PartialOrd) e retorna o menor deles.

    ✓ Resposta:
    fn menor<T: PartialOrd>(a: T, b: T) -> T {
        if a < b { a } else { b }
    }
    
    fn main() {
        println!("{}", menor(3, 5));   // 3
        println!("{}", menor('a', 'z')); // 'a'
    }
  2. Crie uma struct Caixa genérica que armazena um valor de tipo T. Implemente um método obter que retorna uma referência ao valor.

    ✓ Resposta:
    struct Caixa<T> {
        valor: T,
    }
    
    impl<T> Caixa<T> {
        fn obter(&self) -> &T {
            &self.valor
        }
    }
    
    fn main() {
        let caixa = Caixa { valor: 42 };
        println!("{}", caixa.obter());
    }
  3. Defina um enum Opcao genérico que possa representar um valor presente (Algum(T)) ou ausente (Nenhum). Em seguida, implemente uma função que retorna um Opcao a partir de um índice em um vetor.

    ✓ Resposta:
    enum Opcao<T> {
        Algum(T),
        Nenhum,
    }
    
    fn obter_elemento<T: Clone>(v: &[T], indice: usize) -> Opcao<T> {
        if indice < v.len() {
            Opcao::Algum(v[indice].clone())
        } else {
            Opcao::Nenhum
        }
    }
    
    fn main() {
        let v = vec![10, 20, 30];
        match obter_elemento(&v, 1) {
            Opcao::Algum(val) => println!("Valor: {}", val),
            Opcao::Nenhum => println!("Índice inválido"),
        }
    }
  4. Explique o que é monomorfização e por que ela é benéfica para o desempenho em Rust. Dê um exemplo conceitual.

    ✓ Resposta: Monomorfização é o processo de geração de código específico para cada tipo concreto usado com um genérico em tempo de compilação. Isso elimina a necessidade de dispatch dinâmico, resultando em código tão eficiente quanto se você tivesse escrito funções separadas para cada tipo. Por exemplo, se você usa Vec<i32> e Vec<String>, o compilador gera duas versões de Vec e seus métodos, otimizadas para cada tipo.
  5. Escreva uma função genérica somar que aceita dois valores do mesmo tipo e retorna sua soma. O tipo deve implementar a trait std::ops::Add. Use a sintaxe where para o limite.

    ✓ Resposta:
    use std::ops::Add;
    
    fn somar<T>(a: T, b: T) -> T
    where
        T: Add<Output = T>,
    {
        a + b
    }
    
    fn main() {
        println!("{}", somar(2, 3));      // 5
        println!("{}", somar(1.5, 2.5));  // 4.0
    }