Option, Result and Error Handling · about 40 min

Option: A Value That Might Not Be There

Why this matters

Many operations may have no result: looking up a missing key, taking the first element of an empty list, finding a character that is not there. Most languages represent "no value" with null, and forgetting to check for null is so common that its inventor, Tony Hoare, called it his "billion-dollar mistake". Rust has no null. Instead, a possibly-missing value has a different type, Option<T>, and the compiler will not let you use it as a T until you have handled the missing case.

The Option enum

Option is an ordinary enum from the standard library:

enum Option<T> {
    None,
    Some(T),
}

<T> makes it generic (module 10): Option<i32>, Option<String> and Option<&str> are all Options. It is so common that Some and None can be used without the Option:: prefix.

fn first_even(values: &[i32]) -> Option<i32> {
    for &v in values {
        if v % 2 == 0 {
            return Some(v);
        }
    }
    None
}

fn main() {
    let a = first_even(&[3, 7, 8, 10]);
    let b = first_even(&[1, 5]);
    println!("{:?} {:?}", a, b);
    match a {
        Some(n) => println!("got {n}"),
        None => println!("nothing"),
    }
}
Some(8) None
got 8

The key point: an Option<i32> is not an i32. You cannot add 1 to it:

fn main() {
    let maybe: Option<i32> = Some(5);
    let n = maybe + 1;
}
error[E0369]: cannot add `{integer}` to `Option<i32>`
 --> main.rs:3:19
  |
3 |     let n = maybe + 1;
  |             ----- ^ - {integer}
  |             |
  |             Option<i32>
  |
note: the foreign item type `Option<i32>` doesn't implement `Add<{integer}>`
 --> /usr/src/rustc-1.85.1/library/core/src/option.rs:572:1
  |
  = note: not implement `Add<{integer}>`

Every place that wants the number must first decide what to do if it is absent. The compiler forces the null check that other languages leave to discipline.

Getting the value out

methodif Some(v)if None
match / if letyour codeyour code
unwrap()vpanic
expect("msg")vpanic with your message
unwrap_or(default)vdefault
unwrap_or_else(|| f())vresult of f(), computed only when needed
unwrap_or_default()vthe type's default (0, empty string...)
is_some() / is_none()true / falsefalse / true
fn main() {
    let words = ["alpha", "beta"];
    let first = words.first();
    let tenth = words.get(10);
    println!("{:?} {:?}", first, tenth);
    println!("{}", tenth.unwrap_or(&"none"));
    println!("{}", first.expect("list is never empty here"));
    let n: Option<u32> = None;
    println!("{}", n.unwrap_or_default());
}
Some("alpha") None
none
alpha
0

words.get(i) is the non-panicking version of words[i]: it returns Option<&T>. Many standard functions come in such pairs.

unwrap and expect are fine when absence would be a bug in your own program (an invariant you know holds), and in quick experiments. Prefer expect with a message saying why it cannot fail; when it does, the message tells you which assumption broke.

Transforming without unwrapping: combinators

Often you want to transform the value if present and keep None otherwise. Option has methods for that, which take small inline functions called closures, written |x| expression (module 11):

fn main() {
    let name: Option<&str> = Some("ada");
    let upper = name.map(|s| s.to_uppercase());
    println!("{:?}", upper);

    let len = name.map(|s| s.len()).unwrap_or(0);
    println!("{len}");

    let text = "17";
    let parsed: Option<i32> = text.parse().ok();
    let halved = parsed.and_then(|n| if n % 2 == 0 { Some(n / 2) } else { None });
    println!("{:?} {:?}", parsed, halved);

    let big = parsed.filter(|&n| n > 10);
    println!("{:?}", big);
}
Some("ADA")
3
Some(17) None
Some(17)

These chains read as a pipeline: "take the first word, parse it, double it", with absence handled once at the end.

The ? operator on Option

Inside a function that returns Option, expr? means "if this is None, return None right now; otherwise give me the value":

fn middle_initial(full_name: &str) -> Option<char> {
    let mut parts = full_name.split_whitespace();
    let _first = parts.next()?;
    let middle = parts.next()?;
    let _last = parts.next()?;
    middle.chars().next()
}

fn main() {
    println!("{:?}", middle_initial("Grace Brewster Hopper"));
    println!("{:?}", middle_initial("Ada Lovelace"));
}
Some('B')
None

Each ? is an early exit. Lesson 3 covers ? in depth for Result, where it is used most.

Borrowing the inside: as_ref and as_mut

Methods like map and unwrap take the Option by value, moving its contents. For an Option<String> you want to keep, borrow first:

fn main() {
    let nickname: Option<String> = Some(String::from("Ada"));
    let len = nickname.as_ref().map(|s| s.len());
    println!("{:?} still have {:?}", len, nickname);
    if let Some(n) = &nickname {
        println!("hello {n}");
    }
}
Some(3) still have Some("Ada")
hello Ada

as_ref() turns &Option<String> into Option<&String>. Matching on &nickname binds a reference, as in the last lesson.

What Option costs

Nothing, often. For references and Box (pointers that can never be null), Rust represents None as the null address, so Option<&T> is a plain pointer:

use std::mem::size_of;

fn main() {
    println!("&i32 {} / Option<&i32> {}", size_of::<&i32>(), size_of::<Option<&i32>>());
    println!("u64 {} / Option<u64> {}", size_of::<u64>(), size_of::<Option<u64>>());
    println!("String {} / Option<String> {}", size_of::<String>(), size_of::<Option<String>>());
}
&i32 8 / Option<&i32> 8
u64 8 / Option<u64> 16
String 24 / Option<String> 24

You get exactly what a C programmer would write (a nullable pointer), except that the compiler checks every use.

Common mistakes

Recap

Going further: look at the long method list on Option in the standard library docs; try zip, take, replace and get_or_insert.

Exercises

Exercise 1 · write code

Return Option instead of a magic value

Write fn position(words: &[&str], target: &str) -> Option<usize> that returns the index of the first word equal to target, or None. The given main reads a target word on the first line of stdin and a list of words on the second, and prints:

found at 2

or not found. Hidden tests check position directly, including an empty list.

File: main.rs

Starting point

use std::io;

fn position(words: &[&str], target: &str) -> Option<usize> {
    None
}

fn main() {
    let mut target = String::new();
    io::stdin().read_line(&mut target).unwrap();
    let mut list = String::new();
    io::stdin().read_line(&mut list).unwrap();
    let words: Vec<&str> = list.split_whitespace().collect();
    match position(&words, target.trim()) {
        Some(i) => println!("found at {i}"),
        None => println!("not found"),
    }
}

Example

input

pear
apple fig pear plum

expected output

found at 2

Example

input

kiwi
apple fig

expected output

not found
Hint 1

Loop with for (i, w) in words.iter().enumerate() and return Some(i); on a match.

Hint 2

After the loop, the function's final expression is None.

Reference solution
use std::io;

fn position(words: &[&str], target: &str) -> Option<usize> {
    for (i, w) in words.iter().enumerate() {
        if *w == target {
            return Some(i);
        }
    }
    None
}

fn main() {
    let mut target = String::new();
    io::stdin().read_line(&mut target).unwrap();
    let mut list = String::new();
    io::stdin().read_line(&mut list).unwrap();
    let words: Vec<&str> = list.split_whitespace().collect();
    match position(&words, target.trim()) {
        Some(i) => println!("found at {i}"),
        None => println!("not found"),
    }
}

Exercise 2 · write code

Chain without unwrap

Write fn first_number_doubled(s: &str) -> Option<i64>: take the first whitespace-separated word of s, parse it as an i64, and return twice its value. Return None if there is no word or it does not parse. Use split_whitespace().next(), then and_then with .parse().ok(), then map; no match, no unwrap.

The given main prints the result for each stdin line with unwrap_or(-1):

84
-1
-1
-6

for the lines 42 is the answer, hello, an empty line, and -3.

File: main.rs

Starting point

use std::io;

fn first_number_doubled(s: &str) -> Option<i64> {
    None
}

fn main() {
    for line in io::stdin().lines() {
        let line = line.unwrap();
        println!("{}", first_number_doubled(&line).unwrap_or(-1));
    }
}

Example

input

42 is the answer
hello

-3

expected output

84
-1
-1
-6
Hint 1

s.split_whitespace().next() is an Option<&str>.

Hint 2

.and_then(|w| w.parse::<i64>().ok()) turns it into an Option<i64> (ok() converts a Result to an Option).

Hint 3

.map(|n| n * 2) doubles the value if there is one.

Reference solution
use std::io;

fn first_number_doubled(s: &str) -> Option<i64> {
    s.split_whitespace()
        .next()
        .and_then(|w| w.parse::<i64>().ok())
        .map(|n| n * 2)
}

fn main() {
    for line in io::stdin().lines() {
        let line = line.unwrap();
        println!("{}", first_number_doubled(&line).unwrap_or(-1));
    }
}

Exercise 3 · quiz

What does Option cost?

On a 64-bit machine, which are true? Select all that apply.

Answer
  • size_of::<Option<&i32>>() is 8, the same as &i32

  • size_of::<Option<Box<i32>>>() is 8

  • size_of::<Option<u8>>() is 2

References and Box can never be null, so Rust uses the all-zero bit pattern to mean None: the "null pointer optimization". Option of a reference is a nullable pointer with compile-time checking, at zero cost. A u64 uses all of its bit patterns, so Option<u64> needs a separate tag and is padded to 16 bytes; Option<u8> is 2 bytes (tag plus value).