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¶
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:
==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:
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. Andstd::optionalcannot 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. - Python —
Noneis a value of every type, andAttributeError: '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 isOption<&str>here. What changes is thatmypyis a tool you may skip and the compiler is not. - ABAP — an unbound
TYPE REF TOis exactlyNULL, and dereferencing it dumps withCX_SY_REF_IS_INITIALat run time.IS BOUNDis the check you already write;Optionis that check made the only way to reach the value at all, moved from ST22 to the build.sy-subrcafter aREAD TABLEis the same shape once more — a second variable carrying the "was there anything" answer, which is the arrangementOptioncollapses 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¶
- Nullable pointers — why
Option<&T>is the same size as&T SomeandNone— the type itself, from the beginning- Partial functions — the idea underneath: "no answer" as one of the answers
- The bugs Rust is a reply to — the other eight
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