๐Ÿฆ€ Functional Rust

705: Null Pointer Handling with NonNull<T>

Difficulty: 4 Level: Expert Encode the non-null invariant into the type system using `NonNull<T>` and get the null-pointer optimisation for free.

The Problem This Solves

When writing custom collections in Rust โ€” linked lists, trees, arena allocators โ€” you often need optional pointers: "this node has a next node, or it doesn't." The obvious choice is `Option<mut Node<T>>`, but that carries two problems. First, it gives up nothing: the compiler doesn't help you avoid null dereferences any more than a raw pointer would. Second, it wastes memory โ€” `Option<mut T>` is two words wide on most platforms because the compiler can't prove the pointer is non-null. C FFI brings the same problem from the other direction. A C function returning `*T` uses NULL to signal absence or error. Rust needs to model that contract precisely โ€” not just "here's a raw pointer" but "this might be null, and you must check before using it." `NonNull<T>` solves both sides. It's a `*mut T` that is statically guaranteed to be non-null. The compiler knows this guarantee, so `Option<NonNull<T>>` compresses to pointer size โ€” exactly like C's nullable pointer, but type-safe. Null handling moves to `NonNull::new()`, which returns `Option<NonNull<T>>`, forcing explicit handling at construction time rather than silently at dereference time.

The Intuition

Think of `NonNull<T>` as a `mut T` with a pinky-swear to the compiler: "this pointer is never null." The compiler takes that promise seriously enough to let `Option<NonNull<T>>` use the null bit pattern for `None` โ€” the same trick C++ `std::optional<T>` uses, but guaranteed at the type level. You pay for the guarantee once, at `NonNull::new(ptr)` โ€” which returns `Option<NonNull<T>>` and forces you to handle the null case. After that, every use of the `NonNull` is null-free with no runtime cost.

How It Works in Rust

use std::ptr::NonNull;

// Construct from a raw pointer โ€” null check happens here, once.
fn from_raw<T>(ptr: *mut T) -> Option<NonNull<T>> {
 NonNull::new(ptr)  // None if ptr is null
}

// Dereference โ€” still unsafe, but null is ruled out by the type.
unsafe fn read_nonnull<T: Copy>(p: NonNull<T>) -> T {
 // SAFETY: NonNull guarantees non-null.
 // Caller guarantees: aligned, initialized, no exclusive alias.
 *p.as_ptr()
}

// The null-pointer optimisation in action:
assert_eq!(
 std::mem::size_of::<Option<NonNull<u8>>>(),
 std::mem::size_of::<*mut u8>()  // same size โ€” no overhead!
);
For a linked list node:
struct Node<T> {
 value: T,
 next: Option<NonNull<Node<T>>>,  // None = end of list
}
`next` is pointer-sized. `None` is represented as a null pointer. Traversal: `while let Some(p) = cursor { let node = unsafe { p.as_ref() }; ... cursor = node.next; }`.

What This Unlocks

Key Differences

ConceptOCamlRust
Absence of value`None` (option type)`Option<T>` or null raw pointer
Null-optimised option`option` always pointer-sized`Option<NonNull<T>>` = pointer size
Guaranteed non-nullEvery value is non-null`NonNull<T>` wrapper
Null check locationNot needed`NonNull::new(ptr)` at construction
FFI null handlingNot applicable`NonNull::new(ptr).ok_or(err)`
Dereference safetyGC-managedStill `unsafe`, but null is ruled out
//! 705 โ€” Null Pointer Handling: NonNull<T>
//! NonNull<T> = non-null *mut T; Option<NonNull<T>> has pointer size.

use std::ptr::NonNull;
use std::mem::size_of;

/// A simple bump-allocator-style linked list using NonNull<Node<T>>.
struct Node<T> {
    value: T,
    next:  Option<NonNull<Node<T>>>,
}

/// Traverse a NonNull-linked list and collect values.
fn collect_list<T: Copy>(head: Option<NonNull<Node<T>>>) -> Vec<T> {
    let mut result = Vec::new();
    let mut cursor = head;
    while let Some(ptr) = cursor {
        let node = unsafe {
            // SAFETY: NonNull guarantees non-null; Node was allocated via Box
            // and is still live; no &mut alias exists during this traversal.
            ptr.as_ref()
        };
        result.push(node.value);
        cursor = node.next;
    }
    result
}

fn main() {
    // โ”€โ”€ NonNull::new returns Option<NonNull<T>> โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
    let mut x: i32 = 42;
    let raw: *mut i32 = &mut x;
    let nn = NonNull::new(raw);
    println!("NonNull::new(valid): {:?}", nn);

    let null: *mut i32 = std::ptr::null_mut();
    println!("NonNull::new(null):  {:?}", NonNull::new(null));

    // โ”€โ”€ Null-pointer optimisation โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
    println!(
        "size_of Option<NonNull<i32>> = {} (== *mut i32 = {})",
        size_of::<Option<NonNull<i32>>>(),
        size_of::<*mut i32>()
    );
    assert_eq!(
        size_of::<Option<NonNull<u128>>>(),
        size_of::<*mut u128>()
    );

    // โ”€โ”€ Build a short linked list โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
    let mut n3 = Box::new(Node::<i32> { value: 30, next: None });
    let mut n2 = Box::new(Node::<i32> { value: 20, next: NonNull::new(n3.as_mut()) });
    let mut n1 = Box::new(Node::<i32> { value: 10, next: NonNull::new(n2.as_mut()) });
    let head = NonNull::new(n1.as_mut());

    println!("List: {:?}", collect_list(head));
    drop(n1); drop(n2); drop(n3);

    // โ”€โ”€ Dangling pointer prevented by type system โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
    // NonNull::new does not prevent dangling โ€” the programmer must ensure
    // the pointee outlives the NonNull.
    println!("NonNull does not prevent dangling โ€” only non-null.");
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_nonnull_some() {
        let mut v: u32 = 7;
        assert!(NonNull::new(&mut v as *mut u32).is_some());
    }

    #[test]
    fn test_nonnull_none() {
        assert!(NonNull::new::<u32>(std::ptr::null_mut()).is_none());
    }

    #[test]
    fn test_size_opt() {
        assert_eq!(
            size_of::<Option<NonNull<u64>>>(),
            size_of::<*mut u64>()
        );
    }

    #[test]
    fn test_collect_list() {
        let mut n2 = Box::new(Node::<i32> { value: 2, next: None });
        let mut n1 = Box::new(Node::<i32> { value: 1, next: NonNull::new(n2.as_mut()) });
        let h = NonNull::new(n1.as_mut());
        assert_eq!(collect_list(h), vec![1, 2]);
        drop(n1); drop(n2);
    }
}
(* OCaml: no nulls โ€” every value is non-null by definition.
   We model nullable vs non-nullable using option types. *)

(** NonNull is the default in OCaml โ€” every int is non-null. *)
let make_nonnull (x : 'a) : 'a = x   (* identity: all values are non-null *)

(** Nullable via option. *)
let to_option (x : 'a option) : 'a option = x

let () =
  let nn = make_nonnull 42 in
  Printf.printf "NonNull value: %d\n" nn;
  let some_v = to_option (Some 99) in
  let none_v = to_option None in
  Printf.printf "Some: %s\n" (match some_v with Some v -> string_of_int v | None -> "null");
  Printf.printf "None: %s\n" (match none_v with Some v -> string_of_int v | None -> "null");
  (* Option size hint: OCaml options are always pointer-size *)
  Printf.printf "Simulating null-ptr optimisation: present in OCaml by default\n"