Null and Option: C++ and Rust
A value that may be absent is a pointer that may be null in C, an empty std::optional in C++ and a None in Rust. The first two let the program read the absent case by mistake; the third makes the read a compile error, because the value has to be matched before it can be used.
The task in C++
The lookup returns std::optional<int> and an if with an initialiser tests it before dereferencing. The second call misses, and the else branch reports that. The dereference is a star, and it is safe only because the test came first.
The program prints the value for the hit and the word nothing for the miss. The optional itself is two words or so, and no allocation is involved.
#include <iostream>
#include <optional>
#include <string>
std::optional<int> find(const std::string& key)
{
if (key == "answer") {
return 42;
}
return std::nullopt;
}
int main()
{
if (auto value = find("answer")) {
std::cout << "found " << *value << '\n';
}
if (auto value = find("missing")) {
std::cout << "found " << *value << '\n';
} else {
std::cout << "nothing" << '\n';
}
}
g++ -std=c++23 -Wall -Wextra option.cpp -o option
./option
found 42
nothing
The test and the dereference are two separate operations, and nothing in the type connects them. A program that skips the test still compiles, which is the next section.
The same task in Rust
The lookup returns Option<i32>. An if let handles the hit, and a match handles both cases. The value inside is only reachable through a pattern, so there is no separate dereference to forget.
The program prints the same two answers. The match arms are exhaustive, and the compiler checks that both are there.
fn find(key: &str) -> Option<i32> {
if key == "answer" {
return Some(42);
}
None
}
fn main() {
if let Some(value) = find("answer") {
println!("found {value}");
}
match find("missing") {
Some(value) => println!("found {value}"),
None => println!("nothing"),
}
}
rustc --edition 2024 option.rs
./option
found 42
nothing
The option is a plain enum and takes no heap space. The lesson on Option adds the combinators that make the handling shorter.
What differs and why
Who owns the value: both wrap the value inline, and both copy it out. The C++ optional can be empty or hold a value, and the dereference is unchecked; the Rust option is an enum with two variants, and the payload only exists inside the Some variant.
What the compiler rejects: C++ accepts *value on an empty optional, which is undefined behaviour. Rust rejects the program that uses the payload without matching, so the absent case has to be handled before the value exists.
A raw pointer adds a third state that neither type has: a pointer that is null, a pointer that points at a destroyed object, and a pointer that points at nothing valid. Rust has references that are always valid for a borrow and an Option for absence, so the two states stay separate. What happens at run time: the C++ program reads a value that was never constructed, and the result is whatever the storage holds. The Rust program either prints the value or the fallback, and there is no third outcome.
The habit that breaks
The habit is the unchecked dereference. The program below builds an empty optional and reads it, which compiles with no warning and runs with undefined behaviour. The output is not shown because it is not a value the program can rely on.
#include <iostream>
#include <optional>
int main()
{
std::optional<int> value;
std::cout << "value " << *value << '\n';
}
g++ -std=c++23 -Wall -Wextra option_wrong.cpp -o option_wrong
echo "status: $?"
status 0
The Rust version of the same mistake is a pattern that cannot fail. A let Some(value) is refutable, so it is E0005, and the fix is the match the lesson teaches. That is the same rule the previous page reads for an enum variant, because Option is an enum.
The C++ lesson for this ground is std::optional, and the Rust lesson is Option and its combinators. The previous page in this set is Enums and variants.
The C++ programs were built with g++ 15.2.0 and the Rust programs with rustc 1.96.1, and both were run on Alpine Linux 3.24.3 (x86_64) in a Docker container.
Article author: Arthur Isaev