Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Chapter 36: Memory Layout and Zero-Cost Abstractions

Prerequisites

You will understand

  • Struct layout, alignment, and padding rules
  • Zero-cost abstractions: what the compiler actually generates
  • #[repr(C)] vs default Rust layout

Reading time

40 min
+ 20 min exercises
Struct Layout

Field Sizes, Alignment, and Padding

struct Mixed { a: u8, b: u64, c: u16 } a pad b c pad alignment rules force empty bytes so `u64` stays properly aligned
Niche Optimization

Option<&T> Reuses an Impossible Bit Pattern

&u8 non-null ptr Option<&u8> Some(ptr) None = null the null pointer is impossible for a valid reference, so Rust reuses it as the missing-state tag

Step 1 - The Problem

Systems work is constrained by representation.

You are not only writing logic. You are choosing:

  • how many bytes a value occupies
  • how those bytes are aligned
  • whether a branch needs a discriminant
  • whether an abstraction disappears after optimization or leaves indirection behind

In many languages, representation details are deliberately hidden. That improves portability, but it also limits predictable performance engineering. In C and C++, you get raw representation control, but you often lose safety or create layout dependencies accidentally.

Rust tries to give you both discipline and visibility.

Step 2 - Rust’s Design Decision

Rust does not promise a stable layout for arbitrary struct and enum definitions unless you request one with a representation attribute such as repr(C).

At the same time, Rust aggressively optimizes safe, high-level code through:

  • monomorphization
  • inlining
  • dead-code elimination
  • niche optimization

This is what zero-cost abstraction means in Rust:

you should not pay runtime overhead merely for using a higher-level abstraction when the compiler can prove it away.

Rust accepted:

  • longer compile times
  • larger binaries in some generic-heavy designs
  • layout rules that are explicit rather than hidden behind folklore

Rust refused:

  • forcing dynamic dispatch or heap allocation for ordinary abstractions
  • pretending all data layouts are stable just because the language can generate them

Step 3 - The Mental Model

Plain English rule: Rust lets you write abstractions whose cost is mostly the cost of the underlying machine-level work, but only when the abstraction still preserves enough static information for the compiler to optimize it.

Also:

layout is part of the contract only when you make it part of the contract.

Step 4 - Minimal Code Example

use std::mem::size_of;

fn main() {
    assert_eq!(size_of::<&u8>(), size_of::<Option<&u8>>());
}

Step 5 - Line-by-Line Compiler Walkthrough

The compiler knows a reference &u8 can never be null. That means one bit-pattern is unused in normal values. Rust can use that unused pattern as the None tag for Option<&u8>.

So conceptually:

  • Some(ptr) uses the ordinary non-null pointer
  • None uses the null pointer representation

No extra discriminant byte is needed. This is niche optimization.

The invariant is:

the inner type must have invalid bit-patterns Rust can safely reuse as variant tags.

That same idea explains why:

  • Option<Box<T>> is typically the same size as Box<T>
  • Option<NonZeroUsize> is the same size as usize

Step 6 - Three-Level Explanation

Rust can often store extra meaning inside values without making them bigger. It uses impossible values, like a null pointer where a valid reference can never be null, to encode variants like None.

Layout knowledge matters when you design:

  • FFI boundaries
  • memory-dense data structures
  • enums used in hot paths
  • public types whose size affects cache behavior

But “zero-cost” does not mean “free in every dimension.” Generics can increase compile time and binary size. Iterator chains can optimize beautifully, but only if you keep enough static structure for the optimizer to work with.

Rust’s zero-cost claim lives on top of concrete compiler machinery. Generics become specialized code through monomorphization. Trait-object dispatch remains dynamic because you asked for runtime erasure. Slice references and trait objects are fat pointers because unsized values require metadata. Layout is a combination of:

  • field sizes
  • alignment requirements
  • padding
  • variant tagging strategy
  • representation attributes

Understanding those pieces lets you predict when a design is cheap, when it is branch-heavy, and when it leaks abstraction cost into runtime.

Field Ordering, Padding, and repr

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

struct Mixed {
    a: u8,
    b: u64,
    c: u16,
}

fn main() {
    println!("size = {}", size_of::<Mixed>());
    println!("align = {}", align_of::<Mixed>());
}

Padding exists because aligned access matters to hardware and generated code quality.

Important rules:

  • Rust may reorder some layout details internally only to the extent allowed by its layout model; you should not assume a C-compatible field layout unless using repr(C)
  • repr(C) makes layout appropriate for C interop expectations
  • repr(packed) removes padding but can create unaligned access hazards

repr(packed) is not a performance switch. It is a representation promise with sharp edges.

Fat Pointers and ?Sized

Three common fat-pointer cases:

TypePayloadMetadata
&[T]pointer to first elementlength
&strpointer to UTF-8 byteslength
&dyn Traitpointer to datavtable pointer

This is why these types are usually larger than a single machine pointer.

Sized means the compiler knows the size of the type at compile time. Most generic parameters are implicitly Sized. You write ?Sized when your API wants to accept unsized forms too, usually behind pointers or references.

Zero-Cost Abstraction Does Not Mean Zero Tradeoffs

Consider:

#![allow(unused)]
fn main() {
fn sum_iter() -> i32 {
    (0..1000).filter(|x| x % 2 == 0).map(|x| x * x).sum()
}

fn sum_loop() -> i32 {
    let mut sum = 0;
    for x in 0..1000 {
        if x % 2 == 0 {
            sum += x * x;
        }
    }
    sum
}
}

In optimized builds, these can compile to effectively the same machine-level work. That is the win.

But if you box the iterator chain into Box<dyn Iterator<Item = i32>>, you have chosen dynamic dispatch and likely extra indirection. Still valid. Not zero-cost relative to the loop anymore.

Step 7 - Common Misconceptions

Wrong model 1: “Zero-cost means there is never any overhead.”

Correction: it means you do not pay abstraction overhead you did not ask for. Chosen abstraction costs still exist.

Wrong model 2: “Rust layout is always like C layout.”

Correction: only repr(C) gives you that contract.

Wrong model 3: “repr(packed) is a size optimization I should use often.”

Correction: it is a low-level representation choice that can make references and field access unsafe or slower.

Wrong model 4: “If two values print the same size, they have equivalent cost.”

Correction: size is only one part of cost. Branching, alignment, cache locality, and dispatch still matter.

Step 8 - Real-World Pattern

You will see layout-aware design in:

  • dense parsing structures
  • byte-oriented protocol types
  • Option<NonZero*> and Option<Box<T>> representations
  • iterator-heavy APIs that rely on monomorphization instead of boxing

Standard-library and ecosystem code routinely use type structure to preserve static information long enough for LLVM to erase abstraction overhead.

Step 9 - Practice Block

Code Exercise

Measure size_of and align_of for:

  • String
  • Vec<u8>
  • &str
  • Option<&str>
  • Box<u64>
  • Option<Box<u64>>

Then explain every surprise.

Code Reading Drill

What metadata does this pointer carry?

#![allow(unused)]
fn main() {
let s: &str = "hello";
}

Spot the Bug

Why is assuming field offsets here a bad idea?

#![allow(unused)]
fn main() {
struct Header {
    tag: u8,
    len: u32,
}
}

Assume the code later treats this as a C layout without repr(C).

Refactoring Drill

Take an API returning Box<dyn Iterator<Item = T>> everywhere and redesign one hot path with impl Iterator. Explain the tradeoff.

Compiler Error Interpretation

If the compiler says a generic parameter needs to be Sized, translate that as: “this position needs compile-time size information, but I tried to use an unsized form without an indirection.”

Step 10 - Contribution Connection

After this chapter, you can understand:

  • why a public type’s shape affects performance and FFI compatibility
  • why some APIs return iterators opaquely rather than trait objects
  • why layout assumptions are carefully isolated

Good first PRs include:

  • replacing unnecessary boxing in hot iterator paths
  • documenting repr(C) requirements on FFI structs
  • shrinking overly padded internal structs when data density matters

In Plain English

Rust does not make performance a rumor. It lets you inspect how values are shaped in memory and often optimize high-level code down to the same work a lower-level version would do. That matters because systems engineering is about real bytes, real branches, and real cache behavior.

What Invariant Is Rust Protecting Here?

Type representation and optimization must preserve program meaning while exposing layout guarantees only when they are explicit and sound.

If You Remember Only 3 Things

  • Zero-cost means “no hidden abstraction tax you did not ask for,” not “no tradeoffs anywhere.”
  • Layout is only part of the public contract when you make it so, usually with repr(C).
  • Fat pointers and niche optimization explain many of Rust’s seemingly surprising size results.

Memory Hook

Think of Rust abstractions as transparent machine casings. You can add gears without hiding where the shafts still connect.

Flashcard Deck

QuestionAnswer
What is niche optimization?Reusing impossible bit-patterns of a type to encode enum variants without extra space.
Why can Option<&T> often be the same size as &T?Because references cannot be null, so null can represent None.
What does repr(C) do?Gives a C-compatible representation contract for layout-sensitive interop.
What extra data does &str carry?A length alongside the data pointer.
What extra data does &dyn Trait carry?A vtable pointer alongside the data pointer.
What is monomorphization?Generating specialized code for each concrete generic instantiation.
Does zero-cost abstraction guarantee smaller binaries?No. Static specialization can increase code size.
When should you be careful with repr(packed)?Always; it can create unaligned access hazards and sharp low-level constraints.

Chapter Cheat Sheet

NeedTool or conceptWhy
Stable C-facing layoutrepr(C)interop contract
Dense optional pointer-like valueniche optimizationno extra discriminant
Unsized data behind pointerfat pointercarries metadata
Erase abstraction overhead in hot pathgenerics and inliningkeep static structure
Inspect representationsize_of, align_ofmeasure before assuming