Introdução: O enigma do compilador Rust
Imagine: você programa em Rust, é um verdadeiro fã de segurança de memória. Você sabe que duas mutable references (&mut T) para os mesmos dados nunca podem existir simultaneamente. Esta é uma das principais regras de empréstimo (borrowing rules). O compilador Rust fiscaliza isso rigorosamente.
Aparentemente, se o compilador tem certeza de que dois &mut não se sobrepõem (não são aliases), ele deveria usar esse conhecimento para uma otimização agressiva. Por exemplo, reordenar operações, armazenar valores em cache nos registradores, não reler a memória. Mas eis o paradoxo: o compilador Rust (LLVM) frequentemente não faz isso. Por quê? Afinal, parece tão lógico!
Esta pergunta acumulou milhares de visualizações no Stack Overflow. E hoje, como verdadeiros detetives, vamos desvendar esse enigma. Não vamos apenas ler a resposta — vamos entender as razões profundas, relacionadas à herança do C, ao LLVM e a como a otimização funciona. Vamos nessa!
1. A regra de ouro do Rust: Dois &mut são sempre dados diferentes
Vamos relembrar o básico. Em Rust, existem dois tipos de referências:
- Imutável (
&T): pode haver quantas referências quiser, mas não é possível modificar os dados. - Mutável (
&mut T): só pode haver uma referência, mas é possível modificar os dados.
Esta regra é o noalias para &mut. O compilador, em tese, pode supor: "Se vejo dois &mut i32, eles com certeza apontam para lugares diferentes na memória".
Mas vejamos um exemplo simples que demonstra o problema:
fn example(x: &mut i32, y: &mut i32) -> i32 { *x = 42; *y = 10; *x // Esperamos 42, mas...}Aqui tudo é óbvio: x e y são variáveis diferentes. O Rust garante que elas não se sobrepõem. O compilador poderia simplesmente retornar 42, sem executar as atribuições. Mas será que é realmente assim?
2. O problema: LLVM e sua "cegueira" para as garantias do Rust
O protagonista da nossa história é o LLVM (Low Level Virtual Machine). O Rust usa o LLVM como backend para gerar código de máquina. O LLVM é um otimizador poderoso, mas não conhece as regras do Rust. Ele só conhece as regras do C/C++.
Em C/C++, existe a palavra-chave restrict (no C99), que diz ao compilador: "Esta referência é única, ninguém mais aponta para estes dados". Mas em C/C++, restrict é uma promessa do programador, não uma garantia do compilador. Se o programador quebrar a promessa — undefined behavior.
O Rust, por outro lado, oferece uma garantia de ferro. Mas para transmitir essa garantia ao LLVM, o front-end do Rust deve marcar explicitamente &mut com o atributo noalias no IR do LLVM (representação intermediária).
E é aqui que a coisa fica interessante. O Rust marca &mut como noalias apenas em certos casos. E em alguns — não. Por quê?
2.1. Problema histórico: UnsafeCell e código Unsafe
Internamente, o Rust possui o tipo UnsafeCell<T>. Este é o bloco de construção para Cell, RefCell, Mutex e outros tipos que permitem mutabilidade interior (interior mutability).
Se você tem um &mut UnsafeCell<i32>, através dele é possível obter um ponteiro bruto e modificar os dados mesmo através de uma referência imutável. Isso quebra a garantia de noalias!
Para evitar undefined behavior, o compilador Rust não coloca noalias em &mut se por trás dele pode haver um UnsafeCell. Mas o problema é mais profundo.
3. Exemplo real: por que a otimização não funciona
Voltemos à função example