Skip to Content
RustBooksThe Rust BookCh 9. Error Handling

Ch 9. Error Handling

  • Errors are a natural part of software, and Rust provides tools to manage them effectively.

  • Rust enforces handling of potential errors, making your program more robust by ensuring errors are considered and handled before the code compiles.

  • Types of Errors:

    1. Recoverable Errors:

      • Errors that you can handle and recover from, such as a file not being found.
      • These errors are typically handled by reporting the issue to the user and retrying the operation.
      • Managed using the Result<T, E> type.
    2. Unrecoverable Errors:

      • Errors caused by bugs, such as trying to access an index outside the bounds of an array.
      • These errors stop the program immediately.
      • Managed using the panic! macro.
  • Differences from Other Languages:

    • Many languages use exceptions for all errors (both recoverable and unrecoverable).
    • Rust avoids exceptions and instead provides:
      • Result<T, E> for recoverable errors.
      • panic! macro for unrecoverable errors.
  • Handling Recoverable Errors:

    • The Result<T, E> type is used to handle errors that can be addressed without halting the program. It signifies either:
      • Success (Ok(T)).
      • Failure (Err(E)).
  • Handling Unrecoverable Errors:

    • The panic! macro immediately stops execution when a critical error (often a bug) occurs, helping prevent further damage.

9.1 Unrecoverable Errors with panic!

  • Rust’s panic! macro is used when an error occurs that cannot be recovered from, such as accessing an invalid array index.

  • The panic! macro causes the program to terminate, print an error message, and clean up the stack by default.

  • Example of a panic:

    fn main() { panic!("crash and burn"); }
    • Running this code will terminate the program and output the location of the panic! call along with the custom message.
  • Panic from Out-of-Bounds Access:

    • Trying to access an invalid index in a collection like a vector triggers a panic:

      fn main() { let v = vec![1, 2, 3]; v[99]; // This will cause a panic }
      • Rust prevents undefined behavior by stopping the program when an invalid index is accessed, unlike languages like C, where accessing out-of-bounds memory may lead to security vulnerabilities.
  • Backtraces:

    • When a panic occurs, you can enable a backtrace to see the call stack leading up to the panic.

    • Set the RUST_BACKTRACE environment variable to 1 to view the backtrace:

      RUST_BACKTRACE=1 cargo run
      • A backtrace lists all the functions that were called before the panic occurred. Start reading from the top until you see lines involving your own code to identify where the issue originated.
  • Unwinding vs. Aborting on Panic:

    • Unwinding: Rust walks back up the stack, cleaning up data from each function when a panic occurs (default behavior).

    • Aborting: You can opt to immediately terminate the program without cleanup to save time and reduce binary size. This can be enabled by setting panic = 'abort' in Cargo.toml:

      [profile.release] panic = 'abort'
  • Key Points on Panic Handling:

    • Use panic! for critical errors that should stop the program immediately.
    • Enable backtraces to debug panics by examining the function calls leading to the issue.
    • For performance or binary size optimization, switch to aborting instead of unwinding in production builds.

9.2 Recoverable Errors with Result

  • Most errors in Rust are recoverable, meaning the program can handle them and continue running.

  • The Result<T, E> enum represents the outcome of operations that may succeed or fail:

    enum Result<T, E> { Ok(T), Err(E), }
    • T: Type of the success value (Ok).
    • E: Type of the error value (Err).
  • Example: Opening a File:

    • When you open a file in Rust using File::open, the function returns a Result since the operation may succeed or fail:

      use std::fs::File; fn main() { let greeting_file_result = File::open("hello.txt"); }
    • If the file exists, File::open will return Ok(File), which contains the file handle. If it fails (e.g., the file doesn’t exist), it returns Err(std::io::Error).

  • Handling Result with match:

    • You can use a match expression to handle the Ok and Err variants of Result:

      use std::fs::File; fn main() { let greeting_file_result = File::open("hello.txt"); let greeting_file = match greeting_file_result { Ok(file) => file, // Handle the successful case Err(error) => panic!("Problem opening the file: {error:?}"), // Handle the error case }; }
    • If the file is successfully opened, the file handle is returned. If the file cannot be opened, the program will panic! with an error message.

  • Example Output on Failure:

    • If the file hello.txt does not exist, you might see output like this:

      thread 'main' panicked at src/main.rs:8:23: Problem opening the file: Os { code: 2, kind: NotFound, message: "No such file or directory" }
    • The error message provides information about why the file couldn’t be opened (NotFound).

  • Error Handling Summary:

    • Rust encourages explicit error handling, forcing you to deal with the possibility of errors at compile time.
    • Using Result<T, E>, you can gracefully handle errors or propagate them, making your program more robust and predictable.
  • Recoverable Errors with Result:

    • Most errors in Rust are recoverable, meaning the program can handle them and continue running.

    • The Result<T, E> enum represents the outcome of operations that may succeed or fail:

      enum Result<T, E> { Ok(T), Err(E), }
      • T: Type of the success value (Ok).
      • E: Type of the error value (Err).
  • Example: Opening a File:

    • When you open a file in Rust using File::open, the function returns a Result since the operation may succeed or fail:

      use std::fs::File; fn main() { let greeting_file_result = File::open("hello.txt"); }
    • If the file exists, File::open will return Ok(File), which contains the file handle. If it fails (e.g., the file doesn’t exist), it returns Err(std::io::Error).

  • Handling Result with match:

    • You can use a match expression to handle the Ok and Err variants of Result:

      use std::fs::File; fn main() { let greeting_file_result = File::open("hello.txt"); let greeting_file = match greeting_file_result { Ok(file) => file, // Handle the successful case Err(error) => panic!("Problem opening the file: {error:?}"), // Handle the error case }; }
    • If the file is successfully opened, the file handle is returned. If the file cannot be opened, the program will panic! with an error message.

  • Example Output on Failure:

    • If the file hello.txt does not exist, you might see output like this:

      thread 'main' panicked at src/main.rs:8:23: Problem opening the file: Os { code: 2, kind: NotFound, message: "No such file or directory" }
    • The error message provides information about why the file couldn’t be opened (NotFound).

  • Handling Different Errors with match:

    • You can match on different kinds of errors to take specific actions depending on the reason for failure.

    • Example:

      use std::fs::File; use std::io::ErrorKind; fn main() { let greeting_file_result = File::open("hello.txt"); let greeting_file = match greeting_file_result { Ok(file) => file, Err(error) => match error.kind() { ErrorKind::NotFound => match File::create("hello.txt") { Ok(fc) => fc, Err(e) => panic!("Problem creating the file: {e:?}"), }, other_error => { panic!("Problem opening the file: {other_error:?}"); } }, }; }
    • If the file doesn’t exist, the program tries to create it. If any other error occurs (e.g., permission issues), the program panics.

  • Explanation:

    • File::open("hello.txt") returns a Result<T, E>.
    • The inner match on error.kind() uses ErrorKind::NotFound to handle the missing file case by attempting to create it with File::create.
    • For other errors, the code panics with a custom message.
  • Using unwrap_or_else for Cleaner Error Handling:

    • The unwrap_or_else method can simplify the code by avoiding nested match expressions:

      use std::fs::File; use std::io::ErrorKind; fn main() { let greeting_file = File::open("hello.txt").unwrap_or_else(|error| { if error.kind() == ErrorKind::NotFound { File::create("hello.txt").unwrap_or_else(|error| { panic!("Problem creating the file: {error:?}"); }) } else { panic!("Problem opening the file: {error:?}"); } }); }
    • unwrap_or_else takes a closure that handles the Err case.

    • This version simplifies the structure by removing the need for nested match expressions, making the code more concise and easier to read.

  • Shortcuts for Panic on Error: unwrap and expect:

    • unwrap:

      • A shorthand for handling a Result<T, E>. If the Result is Ok, it returns the value inside Ok. If it’s Err, it triggers a panic.

      • Example:

        use std::fs::File; fn main() { let greeting_file = File::open("hello.txt").unwrap(); }
      • If the file hello.txt does not exist, the program will panic with an error like:

        thread 'main' panicked at src/main.rs:4:49: called `Result::unwrap()` on an `Err` value: Os { code: 2, kind: NotFound, message: "No such file or directory" }
    • expect:

      • Similar to unwrap, but allows you to provide a custom panic message for better debugging context.

      • Example:

        use std::fs::File; fn main() { let greeting_file = File::open("hello.txt") .expect("hello.txt should be included in this project"); }
      • If the file doesn’t exist, the custom message will be part of the panic output:

        thread 'main' panicked at src/main.rs:5:10: hello.txt should be included in this project: Os { code: 2, kind: NotFound, message: "No such file or directory" }
  • When to Use expect Over unwrap:

    • In production-quality code, it’s common to use expect with a detailed error message to provide context for the failure. This makes it easier to debug when things go wrong, as the message helps explain why the operation was expected to succeed.
  • Propagating Errors:

    • Instead of handling errors within a function, you can propagate errors to the calling code using Result<T, E>, allowing the caller to decide how to handle the error.

    • Example:

      use std::fs::File; use std::io::{self, Read}; fn read_username_from_file() -> Result<String, io::Error> { let username_file_result = File::open("hello.txt"); let mut username_file = match username_file_result { Ok(file) => file, Err(e) => return Err(e), // Propagate error to caller }; let mut username = String::new(); match username_file.read_to_string(&mut username) { Ok(_) => Ok(username), Err(e) => Err(e), // Propagate error to caller } }
    • The function returns Result<String, io::Error>, where Ok(String) holds the username if successful, and Err(io::Error) holds the error if something goes wrong.

  • Propagating Errors with the ? Operator:

    • The ? operator simplifies error propagation by reducing boilerplate match expressions.

    • Example using ?:

      use std::fs::File; use std::io::{self, Read}; fn read_username_from_file() -> Result<String, io::Error> { let mut username_file = File::open("hello.txt")?; // Propagate error let mut username = String::new(); username_file.read_to_string(&mut username)?; // Propagate error Ok(username) }
    • The ? operator works as follows:

      • If the Result is Ok, it returns the value inside Ok.
      • If the Result is Err, it propagates the error to the caller.
  • Chaining with ?:

    • You can chain method calls with ? for more concise code:

      use std::fs::File; use std::io::{self, Read}; fn read_username_from_file() -> Result<String, io::Error> { let mut username = String::new(); File::open("hello.txt")?.read_to_string(&mut username)?; // Chained calls Ok(username)//this is the return value }
  • Using fs::read_to_string for Simplicity:

    • The standard library provides fs::read_to_string, which simplifies reading a file directly into a string:

      use std::fs; use std::io; fn read_username_from_file() -> Result<String, io::Error> { fs::read_to_string("hello.txt") // Shortened version }
    • This function opens the file, reads it into a string, and handles the errors internally, making the function even shorter.

  • Key Benefits of the ? Operator:

    • Simplifies error handling: Replaces the need for manual match expressions.
    • Propagates errors automatically: If an error occurs, it is returned from the function without additional code.
    • Cleaner and more ergonomic code: Encourages concise and readable code by reducing boilerplate.
  • Where the ? Operator Can Be Used:

    • The ? operator can only be used in functions whose return type is compatible with the value on which ? is used. This typically means functions returning Result or Option.
    • If a function’s return type is Result<T, E>, you can use ? on a Result.
    • If a function’s return type is Option<T>, you can use ? on an Option.
  • Example: Using ? in a Function that Returns Result:

    • This code does not compile because ? is used in a function with a return type ():

      use std::fs::File; fn main() { let greeting_file = File::open("hello.txt")?; }
    • The error occurs because main returns () by default, which is incompatible with ? used on Result.

      error[E0277]: the `?` operator can only be used in a function that returns `Result` or `Option`
  • Fix: Changing the Return Type of main:

    • To use the ? operator in main, change its return type to Result<(), Box<dyn Error>>:

      use std::error::Error; use std::fs::File; fn main() -> Result<(), Box<dyn Error>> { let greeting_file = File::open("hello.txt")?; Ok(()) }
    • Now, the function will propagate any errors from File::open and handle them appropriately.

    • Box<dyn Error> means “any kind of error,” which allows flexibility in the types of errors the function can return.

  • Using ? with Option:

    • ? can be used on Option types in functions that return Option.

    • Example:

      fn last_char_of_first_line(text: &str) -> Option<char> { text.lines().next()?.chars().last() }
      • This function attempts to return the last character of the first line of the given text. If the first line is empty or there’s no line, it returns None.
  • Restrictions of the ? Operator:

    • You can’t mix Result and Option with ?.
      • ? on a Result can only be used in functions that return Result.
      • ? on an Option can only be used in functions that return Option.
      • Conversion between Result and Option needs to be done manually, for example with .ok() or .ok_or().
  • Returning Result from main:

    • In Rust, the main function can return Result<(), E> where E is typically Box<dyn Error>, allowing the use of the ? operator:

      use std::fs::File; use std::error::Error; fn main() -> Result<(), Box<dyn Error>> { let greeting_file = File::open("hello.txt")?; Ok(()) }
    • This ensures that the program exits with a value of 0 if it succeeds (Ok(())) or a non-zero exit code if an error occurs (Err).

9.3 To panic! or Not to panic!

  • When to Use panic!:

    • Unrecoverable Errors: If an error is truly unrecoverable and there’s no way for the program to proceed, it’s appropriate to call panic!.
    • Examples, Prototypes, and Tests:
      • Examples: Error-handling code can make examples more complex and harder to follow. Using methods like unwrap or expect helps simplify the code and make the example clearer.
      • Prototypes: When writing quick prototype code, it’s acceptable to use unwrap or expect for simplicity. This leaves markers in the code where you’ll later implement robust error handling.
      • Tests: In tests, you generally want failures to stop execution. If a method in a test fails, it should cause the test to panic, which is why using unwrap or expect in tests is appropriate.
  • When to Return Result:

    • Default for Recoverable Errors: Returning Result<T, E> gives the calling code control over how to handle the error. This approach is more flexible and lets the caller choose whether to recover or escalate the error to a panic.
    • Best Practice: If there’s a chance for the calling code to recover or handle an error in some meaningful way, return a Result. This gives flexibility and avoids making assumptions about what the caller should do.
  • Cases Where You Know More Than the Compiler:

    • Sometimes, as a developer, you may know a situation cannot fail, but the compiler requires you to handle the error anyway. In these cases, using unwrap or expect is acceptable, as long as you understand why failure is impossible.

    • Example: Parsing a hardcoded string that you know is valid:

      use std::net::IpAddr; let home: IpAddr = "127.0.0.1" .parse() .expect("Hardcoded IP address should be valid");
      • In this case, you know "127.0.0.1" is a valid IP address, but the parse method still returns a Result because it’s designed to handle any string. Here, expect is acceptable because failure is logically impossible in this context.
      • Tip: When using expect, provide a clear message explaining why you expect the operation to succeed (e.g., “Hardcoded IP address should be valid”).

Guidelines for Error Handling

  • When to Use panic!:

    • Bad State: Use panic! when your code ends up in a bad state, such as when an assumption, contract, or invariant is broken.
    • Unexpected State: If the bad state is unexpected (e.g., invalid or contradictory values), and your program can’t continue safely, panic! is appropriate.
    • External Code Failures: If your code relies on external code (e.g., libraries or system calls) and it returns an invalid state that your program can’t fix, use panic!.
    • Contracts and Invariants: When a function has strict contracts or invariants that cannot be encoded in types, and violating them indicates a bug, use panic!. Document such contracts clearly.
    • Security Risks: If failing to validate inputs could lead to insecure or harmful outcomes (e.g., out-of-bounds memory access), panic early to prevent such vulnerabilities.
  • When to Use Result:

    • Expected Failure: When failure is a normal and expected possibility (e.g., user input errors, network timeouts), return Result<T, E> to let the caller decide how to handle the failure.
    • Recoverable Errors: Use Result to allow the calling code to recover from errors and decide on the appropriate action, such as retrying an operation or providing a fallback value.
    • Examples of Expected Failure:
      • Malformed Input: A parser might fail when it encounters invalid data.
      • HTTP Rate Limits: An HTTP request might fail due to rate limiting, but this could be handled by retrying after some time.

Leveraging Rust’s Type System for Error Handling

  • Type Safety: Using Rust’s type system can reduce runtime error handling by encoding constraints into types.
    • Unsigned Integers: For example, using u32 ensures values are never negative, avoiding the need for runtime checks for negative numbers.
    • Non-Optional Values: A parameter typed as Option<T> means you need to handle the None case at runtime, but if you use T directly, the compiler ensures the value is always present.

Creating Custom Types for Validation

  • Encapsulating Validations in Types:
    • Instead of repeating validation logic in many places, encapsulate checks into a custom type. This ensures that values are always valid when passed to functions, reducing the need for repeated validation.

      pub struct Guess { value: i32, } impl Guess { pub fn new(value: i32) -> Guess { if value < 1 || value > 100 { panic!("Guess value must be between 1 and 100, got {value}."); } Guess { value } } pub fn value(&self) -> i32 { self.value } }
      • The Guess struct encapsulates a number that must be between 1 and 100.
      • The Guess::new function validates that the provided value is within the range. If the value is invalid, it panics.
      • Other functions can safely use Guess without needing to recheck the value range, as they can rely on the new method’s validation.

When panic! is Appropriate in Library Code

  • Library Code: In general, libraries should avoid calling panic!, as it takes control away from the user of the library.
    • Use Result to allow users to handle errors as needed.
    • Use panic! in library code only when:
      • Continuing execution would cause inconsistent or unsafe behavior.
      • A contract violation (e.g., passing invalid arguments) occurs, and the issue represents a bug in the caller’s code, not something the caller should handle at runtime.
Last updated on