Skip to content

Null dereference

Level: 201 · for C and C++ programmers

One line: NULL is a value that fits in a pointer type but answers none of the questions that type promises, and Rust's reply is not a better pointer — it is a different type for "there might be nothing here".

The program

#include <stdio.h>
#include <string.h>

/* Returns the ballot for a voter, or NULL when there is none. */
static const char *ballot_for(int voter) {
    if (voter == 1) return "Ada";
    return NULL;
}

int main(void) {
    const char *choice = ballot_for(2);
    printf("%zu\n", strlen(choice));    /* nothing said choice was there */
    return 0;
}

The comment above ballot_for is the entire specification of when the result may be NULL. It is a comment, so nothing checks it, nothing propagates it to the caller's caller, and it does not survive the function being moved to another file.

What it did

Real runs — Apple clang 21.0.0, x86_64 macOS, -Wall (no warnings), -O0 and -O2, three runs each
exit 139 (SIGSEGV), no output

A segfault is the good outcome, and it is good by accident: page zero happens to be unmapped on this operating system, so the hardware objects. The same dereference on a small offset from NULL — reading a field twenty bytes into a struct through a null pointer — can land in mapped memory on an embedded target, where it reads a number and carries on.

AddressSanitizer reports it, but notice what it is reporting:

Real output — clang -fsanitize=address -g -O0, abridged
==58444==ERROR: AddressSanitizer: SEGV on unknown address 0x000000000000
==58444==The signal is caused by a READ memory access.
==58444==Hint: address points to the zero page.
    #2 0x00010a53c7c5 in main null_dereference.c:12

It caught the signal and told you where. It did not prevent anything — the program had already dereferenced the pointer. Unlike the use-after-free case, the sanitizer adds a stack trace to a crash you were getting anyway.

Why the standard allows it

Dereferencing a null pointer is undefined behaviour ↗. The consequence people underrate is the optimizer's half: because the standard says a dereferenced pointer is never null, a compiler that sees *p may delete a later if (p != NULL) as dead code. The check you wrote can be removed by the assumption your dereference made two lines earlier.

Tony Hoare called the null reference his billion-dollar mistake, and his diagnosis was about types: it was allowed because it was easy to put a null into every reference type, not because any reference type wanted it.

What Rust does instead

Absence gets its own type, and the type is not a pointer:

fn ballot_for(voter: u32) -> Option<&'static str> {
    match voter {
        1 => Some("Ada"),
        _ => None,
    }
}

match ballot_for(2) {
    Some(choice) => println!("{}", choice.len()),
    None => println!("no ballot"),
}

Verified output of null_dereference.rs — regenerated by tools/run_examples.py, never hand-typed.

match on ballot_for(2)       -> no ballot
ballot_for(1).unwrap_or("")  -> 3
ballot_for(2).map_or(0, len) -> 0

Option<&T> costs nothing over &T: the null pointer bit-pattern, which is illegal for a reference, is reused as the None discriminant, so both are one word wide. This is the null pointer optimization — the same representation C uses, with the difference that the compiler will not let you read it without saying what happens when it is empty.

The refusal

Hand the Option to something that wants a &str:

Real rustc output — option_as_str.rs, --edition 2024
error[E0308]: mismatched types
  --> option_as_str.rs:13:26
   |
13 |     println!("{}", shout(ballot_for(2)));
   |                    ----- ^^^^^^^^^^^^^ expected `&str`, found `Option<&str>`
   |
   = note: expected reference `&str`
                   found enum `Option<&'static str>`
help: consider using `Option::expect` to unwrap the `Option<&'static str>` value, panicking if the value is an `Option::None`
   |
13 |     println!("{}", shout(ballot_for(2).expect("REASON")));
   |                                       +++++++++++++++++

The help is the honest part of the story, and it is worth reading as a warning rather than a fix. .expect("REASON") and .unwrap() say "I claim this is never empty, end the program if I am wrong" — which is a real answer, and it is what most null dereferences should have been all along, since a clean abort with a message beats reading address zero. What it is not is a way of ignoring the question. See what a panic costs.

If you are coming from another language

  • C++std::optional<T> is the same idea and arrived in C++17, so this row is one where the languages have converged. Two differences remain. optional::operator* on an empty optional is undefined behaviour, so the unchecked path is still a silent one rather than a panic. And std::optional cannot hold a reference at all — instantiating one is ill-formed ↗ — which rules out exactly the case C reaches for a raw pointer to express: a maybe-reference to something you do not own. Option<&T> is the ordinary case here, not the missing one.
  • PythonNone is a value of every type, and AttributeError: 'NoneType' object has no attribute … is this bug, caught at run time on the branch that reached it. What transfers: Optional[str] in a type hint is Option<&str> here. What changes is that mypy is a tool you may skip and the compiler is not.
  • ABAP — an unbound TYPE REF TO is exactly NULL, and dereferencing it dumps with CX_SY_REF_IS_INITIAL at run time. IS BOUND is the check you already write; Option is that check made the only way to reach the value at all, moved from ST22 to the build. sy-subrc after a READ TABLE is the same shape once more — a second variable carrying the "was there anything" answer, which is the arrangement Option collapses into one value.

Practice

Not a better pointer. Write a lookup that may find nothing, and handle both outcomes. Say why Option<Config> is not a Config that might be null.

Then the part that surprises people: predict size_of::<&Config>() and size_of::<Option<&Config>>() before printing them, explain the result, and say why Option<u32> does not get the same treatment. Finish with three habits that replace the null check.

Solution

null_dereference_kata.rs in full — pasted here by tools/run_examples.py from the file CI compiles and runs.

//! Kata solution: not a better pointer -- a different type.
//!
//!   rustc --edition 2024 null_dereference_kata.rs -o /tmp/ndk && /tmp/ndk

use std::mem::size_of;

#[derive(Debug)]
struct Config { retries: u32 }

fn find(name: &str) -> Option<Config> {
    if name == "known" { Some(Config { retries: 3 }) } else { None }
}

fn main() {
    println!("THE C SHAPE");
    println!("  Config *c = find(name);   /* may return NULL */");
    println!("  return c->retries;        /* and here it is dereferenced */");
    println!("  The type `Config *` promises to point at a Config. NULL fits in");
    println!("  it and answers none of the questions the type promises, so the");
    println!("  promise is checked by a convention in a comment.");
    println!();

    println!("RUST'S ANSWER IS A DIFFERENT TYPE");
    for name in ["known", "missing"] {
        match find(name) {
            Some(c) => println!("  find({name:?}) -> Some({c:?}), retries {}", c.retries),
            None => println!("  find({name:?}) -> None -- and there is no field to reach for"),
        }
    }
    println!();
    println!("  Option<Config> is not a Config that might be null. It is a");
    println!("  two-variant enum, and the ONLY way to the Config inside is a");
    println!("  match, an if-let, or a method that names what to do when there");
    println!("  is nothing. Forgetting the None case is E0004, not a segfault.");
    println!();

    println!("AND IT COSTS NOTHING");
    println!("  size_of::<&Config>()          {} bytes", size_of::<&Config>());
    println!("  size_of::<Option<&Config>>()  {} bytes", size_of::<Option<&Config>>());
    println!("  Identical. A reference can never be null, so the compiler uses");
    println!("  the all-zero bit pattern to mean None -- the NICHE optimisation.");
    println!("  The safety and the C representation are the same eight bytes,");
    println!("  which is also why passing Option<&T> across an FFI boundary is");
    println!("  sound and idiomatic.");
    println!();
    println!("  It works for anything with a spare pattern:");
    println!("  size_of::<Option<Box<u32>>>()   {} bytes", size_of::<Option<Box<u32>>>());
    println!("  size_of::<Option<u32>>()        {} bytes  <- no niche in a u32,",
             size_of::<Option<u32>>());
    println!("                                    so this one pays for a tag");
    println!();

    println!("THE HABITS THAT REPLACE THE NULL CHECK");
    let c = find("missing");
    println!("  unwrap_or_default()   {:?}", c.as_ref().map(|c| c.retries).unwrap_or_default());
    println!("  map + unwrap_or       {}", find("known").map(|c| c.retries).unwrap_or(1));
    println!("  the ? operator        propagates the None to the caller");
    println!("  Each one is a decision about what 'nothing' means HERE, written");
    println!("  where it is made -- which is the thing a null check never says.");

    assert_eq!(size_of::<&Config>(), size_of::<Option<&Config>>());
    assert!(find("missing").is_none());
}

Verified output of null_dereference_kata.rs — regenerated by tools/run_examples.py, never hand-typed.

THE C SHAPE
  Config *c = find(name);   /* may return NULL */
  return c->retries;        /* and here it is dereferenced */
  The type `Config *` promises to point at a Config. NULL fits in
  it and answers none of the questions the type promises, so the
  promise is checked by a convention in a comment.

RUST'S ANSWER IS A DIFFERENT TYPE
  find("known") -> Some(Config { retries: 3 }), retries 3
  find("missing") -> None -- and there is no field to reach for

  Option<Config> is not a Config that might be null. It is a
  two-variant enum, and the ONLY way to the Config inside is a
  match, an if-let, or a method that names what to do when there
  is nothing. Forgetting the None case is E0004, not a segfault.

AND IT COSTS NOTHING
  size_of::<&Config>()          8 bytes
  size_of::<Option<&Config>>()  8 bytes
  Identical. A reference can never be null, so the compiler uses
  the all-zero bit pattern to mean None -- the NICHE optimisation.
  The safety and the C representation are the same eight bytes,
  which is also why passing Option<&T> across an FFI boundary is
  sound and idiomatic.

  It works for anything with a spare pattern:
  size_of::<Option<Box<u32>>>()   8 bytes
  size_of::<Option<u32>>()        8 bytes  <- no niche in a u32,
                                    so this one pays for a tag

THE HABITS THAT REPLACE THE NULL CHECK
  unwrap_or_default()   0
  map + unwrap_or       3
  the ? operator        propagates the None to the caller
  Each one is a decision about what 'nothing' means HERE, written
  where it is made -- which is the thing a null check never says.

See also

Po polsku

NULL mieści się w typie wskaźnikowym, ale nie odpowiada na żadne z pytań, które ten typ obiecuje. Sedno warto powiedzieć wprost: Osoba* twierdzi, że wskazuje na osobę, a NULL znaczy „nie wskazuję na nic" — czyli typ kłamie, legalnie, na każdym wskaźniku w programie.

Odpowiedzią Rusta nie jest lepszy wskaźnik ani sprawdzanie w czasie działania, tylko inny typ. Option<T> mówi „może tu czegoś nie być" w samej sygnaturze, więc informacja przestaje być konwencją i komentarzem, a staje się czymś, co kompilator umie wyegzekwować: &T nigdy nie jest puste, a jeśli może być — ma to napisane. Zdanie Tony'ego Hoare'a o „błędzie wartym miliard dolarów" krąży również po polsku; warto tylko wiedzieć, że alternatywa nie jest żadną nowinką, lecz zwykłym wyliczeniem o dwóch wariantach.

Praktyczna uwaga, która zaskakuje przy przejściu z C: Option<&T> nie zajmuje ani bajtu więcej niż &T, bo kompilator wykorzystuje niemożliwy wzorzec zerowego wskaźnika jako znacznik wariantu None. Bezpieczeństwo nie kosztuje tu pamięci ani cyklu — kosztuje jedno match, które w C i tak trzeba było napisać, tylko nikt nie sprawdzał, czy je napisałeś.

Szukaj po polsku: wskaźnik pusty · błąd wart miliard dolarów · optymalizacja zerowego wskaźnika · rust Option null pointer optimization