Expected errors

In C++, throw both produces an error (the thrown exception) and initiates non-local control flow (unwinding to the nearest catch block). In Rust, error values (Option::None or Result::Err) are returned as normal values from a function. Rust's return statement can be used to return early from a function.

#include <stdexcept>

double divide(double dividend, double divisor) {
  if (divisor == 0.0) {
    throw std::domain_error("zero divisor");
  }

  return dividend / divisor;
}
#![allow(unused)]
fn main() {
fn divide(
    dividend: f64,
    divisor: f64,
) -> Option<f64> {
    if divisor == 0.0 {
        return None;
    }

    Some(dividend / divisor)
}
}

The requirement to have the return type indicate that an error is possible means that callbacks that are permitted to have errors need to be given an Option or Result return type. Omitting that is like requiring callbacks to be noexcept in C++. Functions that do not need to indicate errors but that will be used as callbacks where errors are permitted will need to wrap their results in Option::Some or Result::Ok.

#include <stdexcept>

int produce_42() {
  return 42;
}

int fail() {
  throw std::runtime_error("oops");
}

int useCallback(int (*func)(void)) {
  return func();
}

int main() {
  try {
    int x = useCallback(produce_42);
    int y = useCallback(fail);

    // use x and y
  } catch (std::runtime_error &e) {
    // handle error
  }
}
fn produce_42() -> i32 {
    42
}

fn fail() -> Option<i32> {
    None
}

fn use_callback(
    f: impl Fn() -> Option<i32>,
) -> Option<i32> {
    f()
}

fn main() {
    // need to wrap produce_42 to match the
    // expected type
    let Some(x) =
        use_callback(|| Some(produce_42()))
    else {
        // handle error
        return;
    };
    let Some(y) = use_callback(fail) else {
        // handle error
        return;
    };
    // use x and y
}

Handling errors

In C++, the only way to handle exceptions is catch. In Rust, all of the features for dealing with tagged unions can be used with Result and Option. The most approach depends on the intention of the program.

The basic way of handling an error indicated by a Result in Rust is by using match.

Using match is the most general approach, because it enables handling additional cases explicitly and can be used as an expression. match connotes equal importance of all branches.

#include <vector>
#include <stdexcept>

int main() {
    std::vector<int> v;
    // ... populate v ...
    try {
        auto x = v.at(0);
        // use x
    } catch (std::out_of_range &e) {
        // handle error
    }
}
fn main() {
    let mut v = Vec::<i32>::new();
    // ... populate v ...
    match v.get(0) {
        Some(x) => {
            // use x
        }
        None => {
            // handle error
        }
    }
}

Because handling only a single variant of a Rust enum is so common, the if let syntax support that use case. The syntax both makes it clear that only the one case is important and reduces the levels of indentation.

if let is less general than match. It can also be used as an expression, but can only distinguish one case from the rest. if let connotes that the else case is not the normal case, but that some default handling will occur or some default value will be produced.

Note that with Result, if let does not enable accessing the error value.

fn main() {
    let mut v = Vec::<i32>::new();
    // ... populate v ...
    if let Some(x) = v.get(0) {
        // use x
    } else {
        // handle error
    }
}

When the error handling involves some kind of control flow operation, like break or return, the let else syntax is even more concise.

Much like normal let statements, let else statements can only be used where statements are expected. let else statements also connote that the else case is not the normal case, and that no further (normal) processing will occur.

fn main() {
    let mut v = Vec::<i32>::new();
    // ... populate v ...
    let Some(x) = v.get(0) else {
        // handle error
        return;
    };
    // use x
}

Result and Option also have some helper methods for handling errors. These methods resemble the methods on std::expected in C++.

#include <expected>
#include <string>

int main() {
  std::expected<int, std::string> res(42);
  auto x(res.transform([](int n) { return n * 2; }));
}
fn main() {
    let res: Result<i32, String> = Ok(42);
    let x = res.map(|n| n * 2);
}

These helper methods and others are described in detail in the documentation for Option and Result.

Borrowed results

In the above examples, the successful results are borrowed from the vector. It common to need to clone or copy the result into an owned copy, and to want to do so without having to match on and reconstruct the value. Result and Option have helper methods for these purposes.

fn main() {
    let mut v = Vec::<i32>::new();
    v.push(42);
    let x: Option<&i32> = v.get(0);
    let y: Option<i32> = v.get(0).copied();

    let mut w = Vec::<String>::new();
    w.push("hello".to_string());
    let s: Option<&String> = w.get(0);
    let r: Option<String> = w.get(0).cloned();
}

Propagating errors

In C++, exceptions propagate automatically. In Rust, errors indicated by Result or Option must be explicitly propagated. The ? operator is a convenience for this. There are also several methods for manipulating Result and Option that have a similar effect to propagating the error.

#include <cstddef>
#include <vector>

int accessValue(std::vector<std::size_t> indices,
                 std::vector<int> values,
                 std::size_t i) {
  // vector::at throws
  size_t idx(indices.at(i));
  // vector::at throws
  return values.at(idx);
}
#![allow(unused)]
fn main() {
fn access_value(
    indices: Vec<usize>,
    values: Vec<i32>,
    i: usize,
) -> Option<i32> {
    // * dereferences the &i32 to copy it
    // ? propagates the None
    let idx = *indices.get(i)?;
    // returns the Option directly
    values.get(idx).copied()
}
}

The above Rust example is equivalent to the following, which does not use the ? operator. The version using ? is more idiomatic.

#![allow(unused)]
fn main() {
fn access_value(
    indices: Vec<usize>,
    values: Vec<i32>,
    i: usize,
) -> Option<i32> {
    // matching through the & makes a copy of the i32
    let Some(&idx) = indices.get(i) else {
        return None;
    };
    // still returns the Option directly
    values.get(idx).copied()
}
}

The following example is also equivalent. It is not idiomatic (using ? here is more readable), but does demonstrate one of the helper methods. Option::and_then is similar to std::optional::and_then in C++23.

#![allow(unused)]
fn main() {
fn access_value(
    indices: Vec<usize>,
    values: Vec<i32>,
    i: usize,
) -> Option<i32> {
    // matching through the & makes a copy of the i32
    indices
        .get(i)
        .and_then(|idx| values.get(*idx))
        .copied()
}
}

These helper methods and others are described in detail in the documentation for Option and Result.

Uncaught exceptions in main

In C++ when an exception is uncaught, it terminates the program with a non-zero exit code and an error message. To achieve a similar result using Result in Rust, main can be given a return type of Result.

#include <stdexcept>

int main() {
  throw std::runtime_error("oops");
}
fn main() -> Result<(), &'static str> {
    Err("oops")
}

The result type must be unit () and the error type can be any type that implements the Debug trait.

#[derive(Debug)]
struct InterestingError {
    message: &'static str,
    other_interesting_value: i32,
}

fn main() -> Result<(), InterestingError> {
    Err(InterestingError {
        message: "oops",
        other_interesting_value: 9001,
    })
}

Running this program produces the output Error: InterestingError { message: "oops", other_interesting_value: 9001 } with an exit code of 1.

Limitations to forcing error handling with Result

Returning Result or Option does not give the usual benefits when used with APIs that pass pre-allocated buffers by mutable reference. This is because the buffer is accessible outside of the Result or Option, and so the compiler cannot force handling of the error case.

For example, in the following example the result of read_line can be ignored, resulting in logic errors in the program. However, since the buffer is required to be initialized, it will not result in memory safety violations or undefined behavior.

fn main() {
    let mut buffer = String::with_capacity(1024);
    std::io::stdin().read_line(&mut buffer);
    // use buffer
}

Rust will produce a warning in this case, because of the #[must_use] attribute on Result.

warning: unused `Result` that must be used
 --> example.rs:3:5
  |
3 |     std::io::stdin().read_line(&mut buffer);
  |     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  |
  = note: this `Result` may be an `Err` variant, which should be handled
  = note: `#[warn(unused_must_use)]` on by default
help: use `let _ = ...` to ignore the resulting value
  |
3 |     let _ = std::io::stdin().read_line(&mut buffer);
  |     +++++++

Option does not have a #[must_use] attribute, so functions that return an Option that must be handled (due to the None case indicating an error) should be annotated with the #[must_use] attribute. For example, the get method on slices returns Option and is annotated as #[must_use].