293: The ? Operator
Tutorial Video
Text description (accessibility)
This video demonstrates the "293: The ? Operator" functional Rust example. Difficulty level: Intermediate. Key concepts covered: Functional Programming. Chaining fallible operations with `and_then()` is composable but visually noisy when there are many sequential steps. Key difference from OCaml: 1. **Auto
Tutorial
The Problem
Chaining fallible operations with and_then() is composable but visually noisy when there are many sequential steps. The ? operator provides syntactic sugar for early return on failure: expr? desugars to match expr { Ok(v) => v, Err(e) => return Err(e.into()) }. This makes error propagation code read like imperative code while retaining the type safety of explicit Result types. It is Rust's equivalent of OCaml's let* syntax and Haskell's do notation.
🎯 Learning Outcomes
? as desugaring to early-return on Err (or None)? calls From::from(e) on the error — enabling automatic type conversion? in functions returning Result to chain multiple fallible operations? vs and_then(): sequential steps vs branching/nested logicCode Example
fn parse_and_add(s1: &str, s2: &str) -> Result<i32, ParseError> {
let a = s1.parse::<i32>()?;
let b = s2.parse::<i32>()?;
Ok(a + b)
}Key Differences
? calls From::from() on the error automatically; OCaml's let* requires the error type to already unify.? works on both Result and Option in the same function returning Option; OCaml has separate bind for each.? is a postfix operator in Rust; let* is a prefix binding form in OCaml.? always returns from the enclosing function; and_then can propagate within an expression without returning.OCaml Approach
OCaml's let* binding (4.08+) is the exact equivalent — it desugars let* x = expr in rest to Result.bind expr (fun x -> rest):
let process s divisor =
let* n = int_of_string_opt s |> Option.to_result ~none:`NotANumber in
if n < 0 then Error `Negative
else Ok (n / divisor * 2)
OCaml does not perform automatic error type conversion at let* — the error type must already match.
Full Source
#![allow(clippy::all)]
//! # The ? Operator
//!
//! `?` desugars to match + return Err(e.into()), enabling clean error propagation.
use std::fmt;
use std::num::ParseIntError;
#[derive(Debug, PartialEq)]
pub enum AppError {
Parse(String),
DivByZero,
NegativeInput,
}
impl fmt::Display for AppError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
AppError::Parse(e) => write!(f, "parse error: {}", e),
AppError::DivByZero => write!(f, "division by zero"),
AppError::NegativeInput => write!(f, "negative input"),
}
}
}
impl From<ParseIntError> for AppError {
fn from(e: ParseIntError) -> Self {
AppError::Parse(e.to_string())
}
}
/// Parse a string as a positive integer
pub fn parse_positive(s: &str) -> Result<u32, AppError> {
let n: i32 = s.parse()?; // ? auto-converts ParseIntError via From
if n < 0 {
Err(AppError::NegativeInput)
} else {
Ok(n as u32)
}
}
/// Safe division returning error on division by zero
pub fn safe_div(a: u32, b: u32) -> Result<u32, AppError> {
if b == 0 {
Err(AppError::DivByZero)
} else {
Ok(a / b)
}
}
/// Compute using chain of ? operators
pub fn compute(a_str: &str, b_str: &str) -> Result<u32, AppError> {
let a = parse_positive(a_str)?;
let b = parse_positive(b_str)?;
let result = safe_div(a, b)?;
Ok(result * 2)
}
/// ? on Option - returns None early
pub fn find_double(v: &[i32], target: i32) -> Option<i32> {
let idx = v.iter().position(|&x| x == target)?;
let val = v.get(idx)?;
Some(val * 2)
}
/// Chain multiple optional operations
pub fn parse_and_lookup(s: &str, map: &std::collections::HashMap<i32, &str>) -> Option<String> {
let n = s.parse::<i32>().ok()?;
let value = map.get(&n)?;
Some(value.to_uppercase())
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
#[test]
fn test_compute_success() {
assert_eq!(compute("20", "4"), Ok(10));
}
#[test]
fn test_compute_parse_error() {
assert!(matches!(compute("abc", "4"), Err(AppError::Parse(_))));
}
#[test]
fn test_compute_div_zero() {
assert!(matches!(compute("20", "0"), Err(AppError::DivByZero)));
}
#[test]
fn test_compute_negative() {
assert!(matches!(compute("-5", "2"), Err(AppError::NegativeInput)));
}
#[test]
fn test_question_mark_option_found() {
let v = [1i32, 2, 3];
assert_eq!(find_double(&v, 2), Some(4));
}
#[test]
fn test_question_mark_option_not_found() {
let v = [1i32, 2, 3];
assert_eq!(find_double(&v, 9), None);
}
#[test]
fn test_parse_and_lookup() {
let mut map = HashMap::new();
map.insert(1, "hello");
map.insert(2, "world");
assert_eq!(parse_and_lookup("1", &map), Some("HELLO".to_string()));
assert_eq!(parse_and_lookup("99", &map), None);
assert_eq!(parse_and_lookup("abc", &map), None);
}
}#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
#[test]
fn test_compute_success() {
assert_eq!(compute("20", "4"), Ok(10));
}
#[test]
fn test_compute_parse_error() {
assert!(matches!(compute("abc", "4"), Err(AppError::Parse(_))));
}
#[test]
fn test_compute_div_zero() {
assert!(matches!(compute("20", "0"), Err(AppError::DivByZero)));
}
#[test]
fn test_compute_negative() {
assert!(matches!(compute("-5", "2"), Err(AppError::NegativeInput)));
}
#[test]
fn test_question_mark_option_found() {
let v = [1i32, 2, 3];
assert_eq!(find_double(&v, 2), Some(4));
}
#[test]
fn test_question_mark_option_not_found() {
let v = [1i32, 2, 3];
assert_eq!(find_double(&v, 9), None);
}
#[test]
fn test_parse_and_lookup() {
let mut map = HashMap::new();
map.insert(1, "hello");
map.insert(2, "world");
assert_eq!(parse_and_lookup("1", &map), Some("HELLO".to_string()));
assert_eq!(parse_and_lookup("99", &map), None);
assert_eq!(parse_and_lookup("abc", &map), None);
}
}
Deep Comparison
OCaml vs Rust: The ? Operator
Pattern 1: Early Return on Error
OCaml
let ( let* ) = Result.bind
let parse_and_add s1 s2 =
let* a = int_of_string_opt s1 |> Option.to_result ~none:"bad" in
let* b = int_of_string_opt s2 |> Option.to_result ~none:"bad" in
Ok (a + b)
Rust
fn parse_and_add(s1: &str, s2: &str) -> Result<i32, ParseError> {
let a = s1.parse::<i32>()?;
let b = s2.parse::<i32>()?;
Ok(a + b)
}
Pattern 2: Option Early Return
OCaml
let ( let* ) = Option.bind
let lookup env key =
let* value = List.assoc_opt key env in
let* n = int_of_string_opt value in
Some (n * 2)
Rust
fn lookup(map: &HashMap<&str, &str>, key: &str) -> Option<i32> {
let value = map.get(key)?;
let n = value.parse::<i32>().ok()?;
Some(n * 2)
}
Key Differences
| Concept | OCaml | Rust |
|---|---|---|
| Syntax | let* x = expr in | let x = expr?; |
| Desugars to | Result.bind / Option.bind | match + return Err(e.into()) |
| Error conversion | Manual | Automatic via From trait |
| Works on Option | Yes (with binding ops) | Yes, returns None early |
| In closures | Yes | Limited (must return Result/Option) |
Exercises
and_then() calls to use ? instead, and verify they produce identical results.? to convert between them automatically via impl From.? in a main() returning Result<(), Box<dyn Error>> to demonstrate the top-level error propagation pattern.