Layout de memória
LidoEsta aula explora o layout de memória em Rust, abordando a organização interna de structs, enums e tipos compostos. Você aprenderá sobre os atributos repr(C) e repr(Rust), alinhamento, as funções size_of e align_of, e como o compilador otimiza o layout para reduzir o uso de memória.
O layout de memória em Rust define como os dados são organizados na memória RAM. Entender esse conceito é crucial para escrever código eficiente, interoperar com outras linguagens (como C) e prever o consumo de memória dos seus tipos. Nesta aula, vamos explorar os mecanismos que controlam o layout, desde as regras padrão do Rust até as otimizações que o compilador aplica automaticamente.
O Rust, por padrão, não garante uma ordem específica para os campos de uma struct, permitindo que o compilador reorganize os campos para minimizar o espaço ocupado. No entanto, em situações como FFI (Foreign Function Interface) ou quando você precisa de um layout previsível, é possível usar o atributo repr para controlar explicitamente a organização.
repr(C) e repr(Rust)
O atributo repr é usado para especificar como o layout de um tipo deve ser representado na memória. Existem várias opções, mas as mais comuns são repr(Rust) (o padrão) e repr(C).
Com repr(Rust), o compilador tem total liberdade para ordenar os campos, escolher tamanhos e alinhamentos, e aplicar otimizações. Isso pode resultar em layouts menores, mas também significa que o layout não é estável entre versões do compilador ou entre plataformas. Já repr(C) garante que o layout siga as regras da linguagem C: os campos são dispostos na ordem em que foram declarados, e o alinhamento é baseado no maior alinhamento dos campos. Isso é essencial para interoperabilidade com código C e para comunicação com hardware.
#[repr(C)]
struct PointC {
x: f32,
y: f32,
}
#[repr(Rust)]
struct PointRust {
x: f32,
y: f32,
}
fn main() {
println!("size_of::<PointC>() = {}", size_of::<PointC>());
println!("size_of::<PointRust>() = {}", size_of::<PointRust>());
}No exemplo acima, para uma struct com dois campos f32, ambos os layouts terão o mesmo tamanho (8 bytes), pois não há espaço para otimização. Mas em structs com campos de tamanhos mistos, a diferença pode ser significativa.
Alinhamento
Alinhamento refere-se à restrição de endereço de memória onde um valor pode ser colocado. Por exemplo, um valor de 8 bytes geralmente precisa estar em um endereço múltiplo de 8. O alinhamento é importante para o desempenho do processador e pode afetar o tamanho total de uma struct devido ao preenchimento (padding).
Cada tipo tem um alinhamento natural, que é o maior valor que o compilador usa para garantir acesso eficiente. Para tipos primitivos, o alinhamento é igual ao tamanho (exceto para alguns casos como i32 em x86). Para structs, o alinhamento é o maior alinhamento entre seus campos. O compilador insere bytes de preenchimento entre os campos para garantir que cada campo esteja alinhado corretamente.
#[repr(C)]
struct Exemplo {
a: u8, // 1 byte
b: u64, // 8 bytes
c: u16, // 2 bytes
}
fn main() {
println!("size_of::<Exemplo>() = {}", size_of::<Exemplo>());
println!("align_of::<Exemplo>() = {}", align_of::<Exemplo>());
}Neste exemplo, a struct tem campos de 1, 8 e 2 bytes. Para alinhar o campo b a um endereço múltiplo de 8, o compilador insere 7 bytes de padding após a. Depois de b (que ocupa 8 bytes), o campo c precisa de alinhamento 2, mas como já está em um endereço múltiplo de 2, não precisa de padding adicional. O tamanho total é 24 bytes (1 + 7 + 8 + 2 + 6 bytes de padding final para alinhar a struct inteira a 8).
size_of e align_of
As funções std::mem::size_of e std::mem::align_of são usadas para consultar o tamanho e o alinhamento de um tipo em tempo de execução. Elas são úteis para depuração, para verificar o impacto de otimizações e para implementar estruturas de dados que dependem do tamanho exato dos tipos.
O tamanho de um tipo é o número de bytes necessários para armazenar um valor daquele tipo, incluindo padding. O alinhamento é o número de bytes a partir do qual o endereço de um valor deve ser múltiplo. Esses valores são determinados em tempo de compilação, mas podem ser acessados em tempo de execução com essas funções.
use std::mem;
fn main() {
println!("u8: size = {}, align = {}", mem::size_of::<u8>(), mem::align_of::<u8>());
println!("u64: size = {}, align = {}", mem::size_of::<u64>(), mem::align_of::<u64>());
println!("f64: size = {}, align = {}", mem::size_of::<f64>(), mem::align_of::<f64>());
println!("String: size = {}, align = {}", mem::size_of::<String>(), mem::align_of::<String>());
}Note que o tamanho de String é 24 bytes em uma plataforma de 64 bits, pois contém um ponteiro (8), um tamanho (8) e uma capacidade (8). O alinhamento é 8, pois o ponteiro precisa de alinhamento 8.
Otimizações
O Rust aplica várias otimizações para reduzir o tamanho dos tipos. Uma das mais importantes é a reordenação de campos: o compilador pode reordenar os campos de uma struct para minimizar o padding. Isso é feito automaticamente com repr(Rust), mas não com repr(C).
Outra otimização é a enumeração discriminada: para enums, o Rust usa técnicas como usar o espaço de um campo para armazenar o discriminante quando possível (como em Option<NonNull<T>>). Isso é conhecido como "niche optimization". Por exemplo, Option<&T> é do mesmo tamanho que &T porque o ponteiro nulo é usado para representar None.
use std::mem;
fn main() {
println!("size_of::<Option<&u32>>() = {}", mem::size_of::<Option<&u32>>());
println!("size_of::<&u32>() = {}", mem::size_of::<&u32>());
println!("size_of::<Option<u32>>() = {}", mem::size_of::<Option<u32>>());
}No exemplo, Option<&u32> tem o mesmo tamanho de &u32 (8 bytes em 64 bits), enquanto Option<u32> ocupa 8 bytes (4 para o valor, 4 para o discriminante, com padding).
Boas práticas e observações finais
Ao projetar structs, considere a ordem dos campos para reduzir o padding, especialmente se você estiver usando repr(C) ou se a struct for usada em grandes quantidades. Use repr(C) apenas quando necessário para FFI ou protocolos binários. Lembre-se de que o layout repr(Rust) pode mudar entre versões do compilador, então não confie nele para serialização persistente.
Para enums com dados, o Rust pode otimizar o layout usando o espaço do discriminante. Isso é especialmente útil em tipos como Option e Result. Sempre que possível, use referências ou ponteiros não nulos para aproveitar a otimização de nicho.
Referências
- The Rust Reference: Type Layout
- std::mem::size_of - Rust Documentation
- std::mem::align_of - Rust Documentation
- The Rustonomicon: repr(Rust)
- The Rustonomicon: Other reprs
- Unsafe Code Guidelines: Layout
Exercícios
- Considere a seguinte struct com
repr(C):struct S { a: u8, b: u16, c: u32 }. Qual é o tamanho total da struct? Justifique com base no alinhamento e padding.✓ Resposta: O alinhamento máximo é 4 (do campoc). O campoaocupa 1 byte, depois 1 byte de padding para alinharb(que precisa de alinhamento 2) a um endereço par.bocupa 2 bytes, totalizando 4 bytes até agora. O campocprecisa de alinhamento 4, então não há padding extra, e ocupa 4 bytes. O tamanho total é 8 bytes (1 + 1 + 2 + 4). Nenhum padding final é necessário porque 8 é múltiplo de 4. - Escreva uma função que use
size_ofealign_ofpara imprimir o tamanho e o alinhamento de um tipo genéricoT.✓ Resposta:use std::mem; fn print_size_align<T>() { println!("size_of::<T>() = {}", mem::size_of::<T>()); println!("align_of::<T>() = {}", mem::align_of::<T>()); } fn main() { print_size_align::<u64>(); } - Explique por que
Option<&T>tem o mesmo tamanho de&Tem Rust. Dê um exemplo de código que demonstre isso.✓ Resposta: Isso ocorre porque o Rust utiliza a otimização de nicho: o ponteiro nulo é inválido para&T, então o compilador pode usar o valor nulo para representarNone. Assim,Option<&T>é representado apenas pelo ponteiro, sem custo extra. Exemplo:
Ambos imprimirão 8 em uma plataforma de 64 bits.use std::mem; fn main() { println!("size_of::<Option<&u32>>() = {}", mem::size_of::<Option<&u32>>()); println!("size_of::<&u32>() = {}", mem::size_of::<&u32>()); } - Crie uma struct com campos
u8,u64eu16usandorepr(Rust)e outra usandorepr(C). Compare os tamanhos usandosize_of. Explique a diferença.✓ Resposta: Comrepr(C), os campos são na ordem declarada, resultando em 24 bytes (1 + 7 padding + 8 + 2 + 6 padding). Comrepr(Rust), o compilador pode reordenar para minimizar padding, colocandou8,u16e depoisu64, resultando em 16 bytes (1 + 1 padding + 2 + 4 padding + 8). Código:
A saída será 24 e 16, respectivamente.#[repr(C)] struct C { a: u8, b: u64, c: u16 } struct R { a: u8, b: u64, c: u16 } fn main() { println!("C size = {}", size_of::<C>()); println!("R size = {}", size_of::<R>()); } - Pesquise e explique o que é uma "niche optimization" e dê um exemplo além de
Option<&T>.✓ Resposta: A otimização de nicho é uma técnica onde o compilador usa valores inválidos de um tipo para representar variantes adicionais de um enum, sem aumentar o tamanho. Por exemplo,Option<NonNull<T>>tem o mesmo tamanho deNonNull<T>porque o ponteiro nulo é inválido paraNonNull. Outro exemplo:Option<bool>é otimizado para ocupar 1 byte, usando o valor 2 (inválido para bool) para representarNone. Exemplo:
Isso imprime 1.use std::mem; fn main() { println!("size_of::<Option<bool>>() = {}", mem::size_of::<Option<bool>>()); }