14.3 Performance Considerations

C programmers often prioritize performance and low-level control. It’s natural to ask about the runtime and memory costs of using Option<T>.

14.3.1 Memory Layout: Null Pointer Optimization (NPO)

Rust employs a crucial optimization called the Null Pointer Optimization (NPO). When the type T inside an Option<T> has at least one bit pattern that doesn’t represent a valid T value (often, the all-zeroes pattern), Rust uses this “invalid” pattern to represent None.

This optimization frequently applies to types like:

  • References (&T, &mut T) - which cannot be null.
  • Boxed pointers (Box<T>) - which point to allocated memory and thus cannot be null.
  • Function pointers (fn()).
  • Certain numeric types specifically designed to exclude zero (e.g., std::num::NonZeroUsize, std::num::NonZeroI32).

For these types, Option<T> occupies the exact same amount of memory as T itself. None maps directly to the null/invalid bit pattern, and Some(value) uses the regular valid patterns of T. There is no memory overhead.

use std::mem::size_of;

fn main() {
    // References cannot be null, so Option<&T> uses the null address for None.
    assert_eq!(size_of::<Option<&i32>>(), size_of::<&i32>());
    println!("size_of<&i32>: {}, size_of<Option<&i32>>: {}",
             size_of::<&i32>(), size_of::<Option<&i32>>());

    // Box<T> behaves similarly.
    assert_eq!(size_of::<Option<Box<i32>>>(), size_of::<Box<i32>>());

    // NonZero types explicitly disallow zero, freeing that pattern for None.
    assert_eq!(size_of::<Option<std::num::NonZeroU32>>(),
        size_of::<std::num::NonZeroU32>());
}

If T can use all of its possible bit patterns (like standard integers u8, i32, f64, or simple structs composed only of such types), NPO cannot apply. In these cases, Option<T> typically requires a small amount of extra space (usually 1 byte, sometimes more depending on alignment) for a discriminant tag to indicate whether it’s Some or None, plus the space needed for T itself.

use std::mem::size_of;

fn main() {
    // u8 uses all 256 bit patterns. Option<u8> needs extra space for a tag.
    println!("size_of<u8>: {}", size_of::<u8>());             // Typically 1
    println!("size_of<Option<u8>>: {}", size_of::<Option<u8>>());
    // Typically 2 (1 tag + 1 data)

    // bool uses 1 byte (usually), representing 0 or 1. Value 2 might be used as tag.
    println!("size_of<bool>: {}", size_of::<bool>());             // Typically 1
    println!("size_of<Option<bool>>: {}", size_of::<Option<bool>>());
    // Typically 1 (optimized) or 2
}

Even when a discriminant is needed, the memory overhead is minimal and predictable.

14.3.2 Runtime Cost

Checking an Option<T> (e.g., in a match, via methods like is_some(), or implicitly with ?) involves:

  1. If NPO applies: Comparing the value against the known null/invalid pattern.
  2. If a discriminant exists: Checking the value of the discriminant tag.

Both operations are typically very fast on modern CPUs, usually translating to a single comparison and conditional branch. The compiler can often optimize these checks, especially when methods like map or and_then are chained together. The runtime cost compared to a manual NULL check in C is generally negligible, while the safety gain is immense.

14.3.3 Source Code Verbosity vs. Robustness

Handling Option<T> explicitly can sometimes feel more verbose than C code that might ignore NULL checks or assume a sentinel value isn’t present. However, this perceived verbosity is the source of Rust’s safety guarantee. Methods like ?, combinators (map, and_then, etc.), is_some(), is_none(), and unwrap_or_else significantly reduce the boilerplate compared to writing explicit match statements everywhere, allowing for code that is both safe and expressive.