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

Exercícios

  1. 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 campo c). O campo a ocupa 1 byte, depois 1 byte de padding para alinhar b (que precisa de alinhamento 2) a um endereço par. b ocupa 2 bytes, totalizando 4 bytes até agora. O campo c precisa 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.
  2. Escreva uma função que use size_of e align_of para imprimir o tamanho e o alinhamento de um tipo genérico T.

    ✓ 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>();
    }
  3. Explique por que Option<&T> tem o mesmo tamanho de &T em 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 representar None. Assim, Option<&T> é representado apenas pelo ponteiro, sem custo extra. Exemplo:
    use std::mem;
    
    fn main() {
        println!("size_of::<Option<&u32>>() = {}", mem::size_of::<Option<&u32>>());
        println!("size_of::<&u32>() = {}", mem::size_of::<&u32>());
    }
    Ambos imprimirão 8 em uma plataforma de 64 bits.
  4. Crie uma struct com campos u8, u64 e u16 usando repr(Rust) e outra usando repr(C). Compare os tamanhos usando size_of. Explique a diferença.

    ✓ Resposta: Com repr(C), os campos são na ordem declarada, resultando em 24 bytes (1 + 7 padding + 8 + 2 + 6 padding). Com repr(Rust), o compilador pode reordenar para minimizar padding, colocando u8, u16 e depois u64, resultando em 16 bytes (1 + 1 padding + 2 + 4 padding + 8). Código:
    #[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>());
    }
    A saída será 24 e 16, respectivamente.
  5. 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 de NonNull<T> porque o ponteiro nulo é inválido para NonNull. Outro exemplo: Option<bool> é otimizado para ocupar 1 byte, usando o valor 2 (inválido para bool) para representar None. Exemplo:
    use std::mem;
    
    fn main() {
        println!("size_of::<Option<bool>>() = {}", mem::size_of::<Option<bool>>());
    }
    Isso imprime 1.