14.4 Performance Considerations

14.4.1 Memory Representation of Option<T>

One might assume that wrapping a type T in an Option<T> would require additional memory to represent the None variant. However, Rust employs a powerful optimization known as null-pointer optimization (NPO), allowing Option<T> to have the same size as T in many cases.

Understanding the Optimization:

  • Non-Nullable Types: If T is a type that cannot be null (e.g., references in Rust cannot be NULL), Rust can represent None using an invalid bit pattern. Thus, Option<&T> occupies the same space as &T.

    #![allow(unused)]
    fn main() {
    let some_ref: Option<&i32> = Some(&10);
    let none_ref: Option<&i32> = None;
    // Both occupy the same amount of memory as `&i32`
    }
  • Enums with Unused Variants: For enums with unused discriminant values, Rust can use one of those values to represent None, so Option<Enum> can be the same size as Enum.

    #![allow(unused)]
    fn main() {
    enum Direction {
        Left,
        Right,
    }
    // Both `Direction` and `Option<Direction>` occupy the same amount of memory
    }
  • Types with Unused Bit Patterns: When a type T does not use all possible bit patterns, Rust can designate an unused bit pattern to represent None. For types like char, String, and Rust’s NonZero integer types, there are unused bit patterns, so Option<T> has the same memory footprint as T itself.

However, for types that occupy all possible bit patterns, such as u8 (which can be any value from 0 to 255) or i64, Option<T> cannot rely on an invalid bit pattern to represent None and thus requires extra space.

If you’re unsure whether an Option type needs additional storage, you can verify it with the size_of() function:

use std::mem::size_of;
fn main() {
assert_eq!(size_of::<Option<String>>(), size_of::<String>());
}

Key Takeaways:

  • Efficient Memory Usage: Rust often optimizes Option<T> to have the same memory size as T when possible, utilizing unused bit patterns or invalid states to represent None.
  • Optimization Dependency: The ability to optimize Option<T> without additional memory depends on whether T has unused bit patterns.
  • Minimal Overhead: For types where such optimizations are not possible, Option<T> may require additional memory. However, Rust's compiler strives to minimize this overhead wherever feasible.

14.4.2 Computational Overhead of Option Types

Despite the additional layer of abstraction, Option types usually translate to conditional checks, which modern CPUs handle efficiently, minimizing runtime overhead.

Example:

fn get_first_even(numbers: Vec<i32>) -> Option<i32> {
    for num in numbers {
        if num % 2 == 0 {
            return Some(num);
        }
    }
    None
}
fn main() {
    let nums = vec![1, 3, 4, 6];
    if let Some(even) = get_first_even(nums) {
        println!("First even number: {}", even);
    } else {
        println!("No even numbers found");
    }
}

In this example, the Option type introduces no significant computational overhead. The compiler efficiently translates the Option handling into straightforward conditional checks.

14.4.3 Verbosity in Source Code

Handling Option types can introduce additional verbosity compared to languages that use implicit NULL checks. Developers must explicitly handle both Some and None cases, which can lead to more code.

Example:

fn get_username(user_id: u32) -> Option<String> {
    // Simulate a lookup that might fail
    if user_id == 1 {
        Some(String::from("Alice"))
    } else {
        None
    }
}
fn main() {
    let user = get_username(2);
    match user {
        Some(name) => println!("User: {}", name),
        None => println!("User not found"),
    }
}

While this adds verbosity, it enhances code clarity and safety by making all possible cases explicit.