Memory layout, repr and niches

Rust · Expert · 16 min

Layout knowledge matters for FFI, unsafe code, cache-friendly data structures and understanding why Option<&T> is free.

Every type has a size and an alignment; the size is always a multiple of the alignment so that arrays work. std::mem::size_of::<T>() and align_of::<T>() report them.

With the default representation, `repr(Rust)`, the compiler may reorder fields to minimise padding, and the layout is unspecified: it can change between compiler versions. Never assume field order or offsets for a plain struct.

Other representations give guarantees:

  • `#[repr(C)]`: fields in declaration order with C padding rules. Required for FFI and for any layout you rely on from unsafe code.
  • `#[repr(transparent)]`: a struct with one non-zero-sized field has exactly that field's layout and ABI. Ideal for newtypes passed across FFI.
  • `#[repr(u8)]` (or another integer) on an enum fixes the discriminant type and values.
  • `#[repr(packed)]` removes padding, but fields may be misaligned, so taking references to them is an error; read them by copy or through &raw const.
  • `#[repr(align(64))]` raises alignment, e.g. to keep hot atomics on separate cache lines.

Niche optimisation: when a type has invalid bit patterns, enums use them to store the discriminant. References and Box are never null, so Option<&T> and Option<Box<T>> are the size of a pointer, with None represented as null. NonZeroU32 gives the same benefit for integers, and bool and char have niches too.

Zero-sized types ((), unit structs, PhantomData) take no space, and a Vec<()> never allocates. Dynamically sized types (str, [T], dyn Trait) can only be used behind pointers, which become fat: pointer plus length or pointer plus vtable.

Enums are as large as their largest variant plus a discriminant (unless a niche absorbs it). Boxing a rarely used large variant keeps the whole enum small; clippy's large_enum_variant lint flags this.

Example

use std::mem::{align_of, size_of};
use std::num::NonZeroU32;

#[allow(dead_code)] struct Loose { a: u8, b: u32, c: u16 }         // repr(Rust): reordered
#[allow(dead_code)] #[repr(C)] struct CLike { a: u8, b: u32, c: u16 } // declaration order
#[repr(transparent)] struct UserId(#[allow(dead_code)] u64);
#[allow(dead_code)] enum Big { Small(u8), Huge([u8; 1024]) }
#[allow(dead_code)] enum Slim { Small(u8), Huge(Box<[u8; 1024]>) }

fn main() {
    println!("Loose {} / CLike {} (align {})", size_of::<Loose>(), size_of::<CLike>(), align_of::<CLike>());
    // Loose 8 / CLike 12 (align 4)
    println!("UserId {}", size_of::<UserId>());                         // 8
    println!("&u8 {} / Option<&u8> {}", size_of::<&u8>(), size_of::<Option<&u8>>()); // 8 / 8
    println!("Option<u32> {} / Option<NonZeroU32> {}",
             size_of::<Option<u32>>(), size_of::<Option<NonZeroU32>>()); // 8 / 4
    println!("&str {} / &dyn Fn() {}", size_of::<&str>(), size_of::<&dyn Fn()>()); // 16 / 16
    println!("() {} / Big {} / Slim {}", size_of::<()>(), size_of::<Big>(), size_of::<Slim>()); // 0 / 1025 / 16
}

Remember

  • Default layout is unspecified and may reorder fields; use repr(C) when layout matters
  • Niche optimisation makes Option<&T>, Option<Box<T>>, Option<NonZeroU32> free
  • DST pointers are fat; ZSTs take no space; box large rare enum variants

Watch out for

  • Transmuting or casting between repr(Rust) structs that look identical; their layouts are not guaranteed to match.

Check yourself

  1. Why is `size_of::<Option<&u64>>()` equal to `size_of::<&u64>()`?
  2. You pass a struct to a C library by pointer. Which attribute do you need?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: doc.rust-lang.org/reference/type-layout.html