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.