Genéricos são um recurso fundamental em Rust que permite escrever código flexível e reutilizável sem sacrificar a segurança de tipos. Em vez de escrever funções ou estruturas separadas para cada tipo, você pode definir uma versão genérica que funciona com muitos tipos diferentes. O sistema de genéricos de Rust é poderoso e, combinado com traits, permite criar abstrações de alto desempenho graças à monomorfização em tempo de compilação.

Nesta aula, veremos como declarar e usar genéricos em funções, structs e enums, entenderemos o processo de monomorfização que gera código especializado para cada tipo concreto, e aprenderemos a impor restrições com limites (bounds) para garantir que os tipos genéricos tenham comportamentos específicos.

Funções genéricas

Uma função genérica é definida com um parâmetro de tipo entre colchetes angulares (<T>) antes da lista de parâmetros. Esse parâmetro pode ser usado no tipo dos argumentos e no tipo de retorno. Por exemplo, uma função que retorna o maior de dois valores pode ser genérica para qualquer tipo que suporte comparação:

fn maior<T: PartialOrd>(a: T, b: T) -> T {
    if a > b { a } else { b }
}

fn main() {
    println!("{}", maior(10, 20));       // 20
    println!("{}", maior(10.5, 2.3));    // 10.5
    println!("{}", maior('a', 'z'));     // 'z'
}

No exemplo, T é um tipo genérico que deve implementar o trait PartialOrd (para usar >). A função funciona com inteiros, floats e caracteres. O compilador gera uma versão especializada para cada tipo chamado em tempo de compilação.

Você pode ter múltiplos parâmetros de tipo, separados por vírgula:

fn converter<T, U>(valor: T) -> U
where
    T: Into<U>,
{
    valor.into()
}

Structs e enums genéricos

Assim como funções, structs e enums podem ser genéricos. Uma struct genérica tem um ou mais parâmetros de tipo que podem ser usados em seus campos:

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

fn main() {
    let inteiro = Ponto { x: 5, y: 10 };
    let flutuante = Ponto { x: 1.0, y: 4.0 };
}

Também é possível ter diferentes tipos para cada campo:

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

let par = Par { primeiro: "Olá", segundo: 42 };

Enums genéricos funcionam de forma análoga. O exemplo clássico é Option<T> e Result<T, E>:

enum Option<T> {
    Some(T),
    None,
}

enum Result<T, E> {
    Ok(T),
    Err(E),
}

Esses enums são amplamente utilizados na biblioteca padrão para lidar com valores opcionais e tratamento de erros.

Monomorfização

Monomorfização é o processo pelo qual o compilador Rust substitui cada parâmetro de tipo genérico pelo tipo concreto usado em cada chamada, gerando código específico para cada combinação. Isso elimina a sobrecarga de despacho dinâmico, resultando em código tão eficiente quanto se você tivesse escrito versões separadas manualmente.

Por exemplo, considere a função genérica maior definida anteriormente. Se você chamá-la com i32 e depois com f64, o compilador gera duas funções: uma para i32 e outra para f64. O código resultante é equivalente a:

fn maior_i32(a: i32, b: i32) -> i32 {
    if a > b { a } else { b }
}

fn maior_f64(a: f64, b: f64) -> f64 {
    if a > b { a } else { b }
}

Essa especialização acontece em tempo de compilação, sem custo em tempo de execução. A desvantagem é o aumento no tamanho do binário (bloat) se muitas instanciações forem geradas, mas na prática isso é gerenciável.

Limites

Limites (bounds) são restrições impostas aos parâmetros de tipo usando traits. Eles garantem que o tipo genérico suporte as operações necessárias. A sintaxe mais comum é T: Trait ou a cláusula where para maior clareza quando há múltiplos limites.

fn imprimir<T: std::fmt::Display>(valor: T) {
    println!("{}", valor);
}

fn imprimir_where<T>(valor: T)
where
    T: std::fmt::Display,
{
    println!("{}", valor);
}

Você pode combinar múltiplos limites com +:

fn clonar_e_imprimir<T: Clone + std::fmt::Display>(valor: T) {
    let copia = valor.clone();
    println!("{}", copia);
}

Limites também podem ser usados em structs e enums para restringir implementações de métodos:

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
        }
    }
}

Nesse exemplo, o método maior só está disponível quando T implementa PartialOrd.

Boas práticas

Ao usar genéricos, prefira limites específicos em vez de Any ou sem limites, para garantir que o código seja seguro e expressivo. Use a cláusula where para melhorar a legibilidade quando houver muitos limites. Evite genéricos desnecessários; se um tipo é sempre o mesmo, não o torne genérico. Lembre-se de que a monomorfização pode aumentar o tamanho do binário, mas geralmente é um bom custo-benefício para desempenho.

Referências

Exercícios

  1. Escreva uma função genérica chamada menor que retorna o menor de dois valores. Use um limite apropriado.

    ✓ Resposta:
    fn menor<T: PartialOrd>(a: T, b: T) -> T {
        if a < b { a } else { b }
    }
  2. Crie uma struct genérica Caixa que armazena um único valor de qualquer tipo. Implemente um método obter que retorna uma referência ao valor interno.

    ✓ Resposta:
    struct Caixa<T> {
        valor: T,
    }
    
    impl<T> Caixa<T> {
        fn obter(&self) -> &T {
            &self.valor
        }
    }
    
    fn main() {
        let c = Caixa { valor: 42 };
        println!("{}", c.obter());
    }
  3. Defina um enum Resultado<T, E> com variantes Sucesso(T) e Falha(E). Escreva uma função que aceita um Resultado e imprime uma mensagem para cada caso (use match).

    ✓ Resposta:
    enum Resultado<T, E> {
        Sucesso(T),
        Falha(E),
    }
    
    fn processar<T: std::fmt::Display, E: std::fmt::Display>(res: Resultado<T, E>) {
        match res {
            Resultado::Sucesso(valor) => println!("Sucesso: {}", valor),
            Resultado::Falha(erro) => println!("Falha: {}", erro),
        }
    }
    
    fn main() {
        processar(Resultado::Sucesso(10));
        processar(Resultado::Falha("erro"));
    }
  4. Explique o que é monomorfização e cite uma vantagem e uma desvantagem.

    ✓ Resposta:

    Monomorfização é o processo de substituir parâmetros de tipo genérico por tipos concretos em tempo de compilação, gerando código especializado para cada combinação. Vantagem: desempenho máximo, sem custo de despacho dinâmico. Desvantagem: pode aumentar o tamanho do binário (bloat) se muitas instanciações forem geradas.

  5. Escreva uma função genérica somar que aceita dois valores do mesmo tipo e retorna sua soma. Dica: use o trait std::ops::Add.

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