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:
- If NPO applies: Comparing the value against the known null/invalid pattern.
- 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.