Zero-cost abstractions: o que significa na prática

1. O Princípio Fundamental: Você Não Paga pelo Que Não Usa

O conceito de "zero-cost abstraction" foi popularizado por Bjarne Stroustrup no contexto de C++, mas encontrou em Rust seu lar mais natural. A ideia central é simples: abstrações de alto nível não devem impor custos de runtime que você não está utilizando. Em Rust, isso significa que você pode escrever código expressivo e seguro sem sacrificar performance — desde que o compilador consiga eliminar o overhead em tempo de compilação.

Na prática, o compilador Rust realiza otimizações agressivas como monomorfização, inline expansion e eliminação de código morto. Vamos ver isso em ação comparando um loop manual com um iterador:

// Loop manual
fn sum_manual(v: &[i32]) -> i32 {
    let mut sum = 0;
    for i in 0..v.len() {
        sum += v[i];
    }
    sum
}

// Usando iterador
fn sum_iter(v: &[i32]) -> i32 {
    v.iter().sum()
}

Quando compilamos ambos com rustc --emit asm, o assembly gerado é idêntico. O iterador não adiciona nenhuma instrução extra — ele é literalmente zero-cost.

2. Iteradores: A Abstração que Custa Zero

Iteradores são o exemplo clássico de zero-cost abstractions em Rust. A cadeia de adaptadores como .map(), .filter() e .fold() é compilada para o mesmo assembly que um loop manual otimizado.

fn process_with_iterators(v: &[i32]) -> i32 {
    v.iter()
        .filter(|x| x % 2 == 0)
        .map(|x| x * 2)
        .sum()
}

fn process_with_loop(v: &[i32]) -> i32 {
    let mut sum = 0;
    for &x in v {
        if x % 2 == 0 {
            sum += x * 2;
        }
    }
    sum
}

A chave para isso é a monomorfização: cada combinação de adaptadores gera uma função especializada para os tipos concretos. O compilador inlineia todas as chamadas, resultando em código tão eficiente quanto o loop imperativo.

3. Traits e Dynamic Dispatch: O Custo Explícito do Polimorfismo

Rust oferece duas formas de polimorfismo: static dispatch (com impl Trait ou generics) e dynamic dispatch (com dyn Trait). A diferença de custo é explícita.

// Static dispatch — zero-cost
fn process_static<T: Processor>(item: T) {
    item.process();
}

// Dynamic dispatch — custo de vtable
fn process_dynamic(item: Box<dyn Processor>) {
    item.process();
}

Com static dispatch, o compilador gera código separado para cada tipo concreto. Não há overhead de indireção — é como se você tivesse escrito funções separadas para cada tipo. Já dyn Trait usa uma vtable (tabela de métodos virtuais), que adiciona uma indireção em cada chamada de método. O custo é explícito e você paga apenas quando opta por ele.

4. Closures e Captura de Ambiente: Sem Heap Allocation

Closures em Rust são açúcar sintático para structs anônimas que implementam traits como Fn, FnMut ou FnOnce. O compilador gera uma struct contendo apenas os valores capturados, e a closure é alocada na stack — sem heap allocation.

// Closure que não captura nada — equivalente a fn pointer
let add_one = |x: i32| x + 1;

// Closure capturando por referência
let factor = 2;
let multiply = |x: i32| x * factor;

// Closure capturando por valor (move)
let data = vec![1, 2, 3];
let consume = move || {
    println!("{:?}", data);
};

O tamanho da closure na stack é exatamente o tamanho dos dados capturados. Se a closure não captura nada, ela tem tamanho zero e é compilada como uma função normal. Closures move que capturam tipos Copy também são otimizadas para zero-cost.

5. Smart Pointers: Box, Rc, Arc e o Custo Explícito

Smart pointers em Rust são projetados para que você pague apenas pelo que usa. Box<T> é essencialmente um ponteiro raw com garantias de ownership — a única diferença em runtime é o drop check, que o compilador pode otimizar.

struct LargeData {
    values: [u8; 1024],
}

// Stack allocation — sem custo extra
let stack_data = LargeData { values: [0; 1024] };

// Heap allocation com Box — custo explícito
let heap_data = Box::new(LargeData { values: [0; 1024] });

Rc<T> e Arc<T> adicionam contagem de referências, mas esse custo só existe quando você clona o ponteiro. Se você nunca clona um Rc, o contador nunca é incrementado/decrementado — o custo é pago apenas quando usado.

6. Enums e Pattern Matching: Tags e Layout Otimizados

Enums em Rust são muito mais eficientes que em outras linguagens. O compilador otimiza o layout para usar o mínimo de espaço possível, frequentemente colocando o discriminant (tag) em padding ou bits não utilizados.

// Option<&T> é otimizado para um ponteiro nulo
let x: Option<&i32> = None;  // Representado como ponteiro nulo
let y: Option<&i32> = Some(&42);  // Representado como ponteiro normal

// Pattern matching compilado para jump tables
fn process_option(val: Option<i32>) -> i32 {
    match val {
        Some(x) => x * 2,
        None => 0,
    }
}

O pattern matching é compilado para branches condicionais ou jump tables eficientes, sem overhead de boxing ou alocação dinâmica. Option<&T> é um caso especial: o compilador usa o valor nulo do ponteiro para representar None, tornando o enum do mesmo tamanho que um ponteiro raw.

7. Limitações e Quando a Abstração Tem Custo

Nem toda abstração em Rust é zero-cost. Algumas impõem custos explícitos que você deve conhecer:

// Dynamic dispatch — custo de vtable
let handler: Box<dyn Error> = Box::new(MyError);

// Type erasure com Box<dyn Any>
let value: Box<dyn Any> = Box::new(42);

// Vec com realocações implícitas
let mut v = Vec::new();
for i in 0..1000 {
    v.push(i);  // Pode realocar várias vezes
}

dyn Trait, Box<dyn Error> e Box<dyn Any> impõem custos de indireção e alocação heap. Vec pode realocar implicitamente quando cresce, o que quebra a promessa de zero-cost se você precisa de performance previsível.

O trade-off é claro: ergonomia e flexibilidade vs. performance máxima. Rust permite que você escolha conscientemente quando pagar por essas abstrações, em vez de impor custos ocultos.

Conclusão

Zero-cost abstractions em Rust não significam que todas as abstrações são gratuitas, mas sim que você paga apenas pelo que usa. O compilador elimina overhead desnecessário através de técnicas como monomorfização, inline expansion e otimizações de layout. Cabe ao desenvolvedor entender quando uma abstração tem custo (como dynamic dispatch ou heap allocation) e fazer escolhas conscientes baseadas nos requisitos do projeto.

Referências