On this page

Primitive Types and Memory Layout

Every type in Rust has a precise memory layout—integers and floats are aligned by bit width, structs have padding, zero-sized types (ZSTs) occupy 0 bytes, and niche optimization makes Option<bool> the same size as bool (since bool only has 0/1, the compiler uses 2 bit patterns to represent None). Understanding layout is the prerequisite for understanding why Rust has no "boxing overhead".

Scalar Types: More Than Just "Like C"

Rust's integer types look like C's—i32, u64, etc.—but there are important semantic differences:

let x: u8 = 255;
// x + 1: debug mode → panic!("attempt to add with overflow")
//        release mode → wraps to 0 (two's complement, no undefined behavior)

// Three explicit overflow handling methods:
255u8.checked_add(1)      // None (returns Option, does not panic)
255u8.wrapping_add(1)     // 0 (explicitly wants to wrap)
255u8.saturating_add(1)   // 255 (saturating arithmetic)

In C, signed integer overflow is UB. In Rust, even wrapping in release mode is defined behavior—it is not UB. However, the panic in debug mode helps you expose overflow bugs during testing.

Type Size and Alignment

Each type has a deterministic size_of and align_of, independent of the platform (except for isize/usize):

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

size_of::<u8>()     // 1
size_of::<i64>()    // 8
size_of::<bool>()   // 1 (not 1 bit! The minimum addressable unit of memory is a byte)
size_of::<char>()   // 4 (Unicode scalar value, not code point)
size_of::<()>()     // 0 (unit type, ZST)
size_of::<&i32>()   // 8 (64-bit) or 4 (32-bit)

The reason bool occupies 1 byte is that the minimum addressable unit of CPU instruction sets is a byte. Rust does not allow taking the address of a single bit within a bool. If you need compact storage for boolean arrays, use the bitvec crate or manually bit-pack using Vec<u64>.

char: 4 bytes = One Unicode Scalar Value

let c: char = '';                  // U+3042, 3 bytes in UTF-8, 4 bytes as char
size_of::<char>()                    // 4

// Relationship between char and string:
let s = "";                        // &str, 3 bytes: [E3, 81, 82]
let c = '';                        // char, 4 bytes: 0x00003042

char represents a Unicode scalar value (0x0000~0xD7FF, 0xE000~0x10FFFF), not "a character" (a user-perceived character might be composed of multiple scalar values, such as é = e + ́). This is similar to Go's rune (=int32).

Memory Layout of Composite Types

#[repr(C)]
struct C { a: u8, b: u32, c: u16 }
// memory: [a:1B][padding:3B][b:4B][c:2B][padding:2B] = 12 bytes
// C ABI: fields are in declaration order, with automatic padding to satisfy alignment requirements

struct Rust { a: u8, b: u32, c: u16 }
// Default layout: the compiler may reorder fields to optimize size!
// Possible arrangement: [b:4B][c:2B][a:1B][padding:1B] = 8 bytes

The default Rust layout does not guarantee order—the compiler may reorder fields to reduce padding. If you need interoperability with C or a guaranteed stable layout, you must use #[repr(C)].

Arrays: Size Is Part of the Type

let arr1: [i32; 3] = [1, 2, 3];     // Fixed size at compile time
let arr2: [i32; 1000] = [0; 1000];   // 4000 bytes on the stack!

fn take_arr(arr: [i32; 1000]) { ... }  // Passed by value: copies the entire 4000 bytes
fn take_slice(arr: &[i32]) { ... }     // Passed by reference: only copies the fat pointer (16 bytes)

Passing an array by value copies the entire array—because Rust's move semantics perform element-wise copying for arrays. For large arrays, always pass &[T] or &[T; N].

ZST (Zero-Sized Types): Invisible Magic at Compile Time

struct NoData;                       // 0 bytes
let x = NoData;                      // Occupies no stack space
let arr: [NoData; 1_000_000];        // 0 bytes! No memory is allocated

// Practical value of ZST: HashMap keys
use std::collections::HashSet;
let set: HashSet<NoData> = ...;      // Distinguishes only between "present" and "absent"

The compiler completely omits the storage and passing of ZSTs when generating code—they do not exist anywhere at runtime, having meaning only at the type level.

Niche Optimization: Zero-Cost Option

The Rust compiler leverages "impossible bit patterns" to save enum space:

size_of::<Option<bool>>()            // 1 byte, not 2!
// bool has only two valid values: 0x00 (false) and 0x01 (true)
// The compiler uses 0x02 to represent None → no extra discriminant field needed

size_of::<Option<&i32>>()            // 8 bytes, not 16!
// &T is guaranteed to be non-null → the compiler encodes None as nullptr
// When dereferencing Option<&T>: first check if null → None → panic/return None
// → Some is just a normal reference

size_of::<Option<Box<i32>>>()        // 8 bytes — Box internally uses a non-null ptr
size_of::<Option<Option<&i32>>>()    // 8 bytes — two niches: nullptr and ...?
// Actually, Option<&T> has only one niche (null)
// Option<Option<&T>> requires 2 niche values → 16 bytes (two words)

This optimization is key to Rust maintaining zero overhead in systems-level safety—you pay no extra memory or runtime cost for Option<&T>.

References

  • Rust Reference: Type Layout (section "Data Layout")
  • Rustonomicon: Exotic Sizes, repr, alignment
  • RFC: RFC 139 (niche optimization)

Keywords: integer types, alignment, repr(C), ZST, niche optimization, Option<&T>, layout, size_of, align_of