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:
-
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.
-
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)).
- Success (
- The
-
Handling Unrecoverable Errors:
- The
panic!macro immediately stops execution when a critical error (often a bug) occurs, helping prevent further damage.
- The
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.
- Running this code will terminate the program and output the location of the
-
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_BACKTRACEenvironment variable to1to 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'inCargo.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.
- Use
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 aResultsince 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::openwill returnOk(File), which contains the file handle. If it fails (e.g., the file doesn’t exist), it returnsErr(std::io::Error).
-
-
Handling
Resultwithmatch:-
You can use a
matchexpression to handle theOkandErrvariants ofResult: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.txtdoes 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 aResultsince 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::openwill returnOk(File), which contains the file handle. If it fails (e.g., the file doesn’t exist), it returnsErr(std::io::Error).
-
-
Handling
Resultwithmatch:-
You can use a
matchexpression to handle theOkandErrvariants ofResult: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.txtdoes 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 aResult<T, E>.- The inner
matchonerror.kind()usesErrorKind::NotFoundto handle the missing file case by attempting to create it withFile::create. - For other errors, the code panics with a custom message.
-
Using
unwrap_or_elsefor Cleaner Error Handling:-
The
unwrap_or_elsemethod can simplify the code by avoiding nestedmatchexpressions: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_elsetakes a closure that handles theErrcase. -
This version simplifies the structure by removing the need for nested
matchexpressions, making the code more concise and easier to read.
-
-
Shortcuts for Panic on Error:
unwrapandexpect:-
unwrap:-
A shorthand for handling a
Result<T, E>. If theResultisOk, it returns the value insideOk. If it’sErr, it triggers a panic. -
Example:
use std::fs::File; fn main() { let greeting_file = File::open("hello.txt").unwrap(); } -
If the file
hello.txtdoes 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
expectOverunwrap:- In production-quality code, it’s common to use
expectwith 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.
- In production-quality code, it’s common to use
-
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>, whereOk(String)holds the username if successful, andErr(io::Error)holds the error if something goes wrong.
-
-
Propagating Errors with the
?Operator:-
The
?operator simplifies error propagation by reducing boilerplatematchexpressions. -
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
ResultisOk, it returns the value insideOk. - If the
ResultisErr, it propagates the error to the caller.
- If the
-
-
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_stringfor 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
matchexpressions. - 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.
- Simplifies error handling: Replaces the need for manual
-
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 returningResultorOption. - If a function’s return type is
Result<T, E>, you can use?on aResult. - If a function’s return type is
Option<T>, you can use?on anOption.
- The
-
Example: Using
?in a Function that ReturnsResult:-
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
mainreturns()by default, which is incompatible with?used onResult.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 inmain, change its return type toResult<(), 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::openand handle them appropriately. -
Box<dyn Error>means “any kind of error,” which allows flexibility in the types of errors the function can return.
-
-
Using
?withOption:-
?can be used onOptiontypes in functions that returnOption. -
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.
- 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
-
-
Restrictions of the
?Operator:- You can’t mix
ResultandOptionwith?.?on aResultcan only be used in functions that returnResult.?on anOptioncan only be used in functions that returnOption.- Conversion between
ResultandOptionneeds to be done manually, for example with.ok()or.ok_or().
- You can’t mix
-
Returning
Resultfrommain:-
In Rust, the
mainfunction can returnResult<(), E>whereEis typicallyBox<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
0if 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
unwraporexpecthelps simplify the code and make the example clearer. - Prototypes: When writing quick prototype code, it’s acceptable to use
unwraporexpectfor 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
unwraporexpectin tests is appropriate.
- Examples: Error-handling code can make examples more complex and harder to follow. Using methods like
- Unrecoverable Errors: If an error is truly unrecoverable and there’s no way for the program to proceed, it’s appropriate to call
-
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.
- Default for Recoverable Errors: Returning
-
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
unwraporexpectis 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 theparsemethod still returns aResultbecause it’s designed to handle any string. Here,expectis 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”).
- In this case, you know
-
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.
- Bad State: Use
-
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
Resultto 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.
- Expected Failure: When failure is a normal and expected possibility (e.g., user input errors, network timeouts), return
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
u32ensures 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 theNonecase at runtime, but if you useTdirectly, the compiler ensures the value is always present.
- Unsigned Integers: For example, using
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
Guessstruct encapsulates a number that must be between 1 and 100. - The
Guess::newfunction validates that the provided value is within the range. If the value is invalid, it panics. - Other functions can safely use
Guesswithout needing to recheck the value range, as they can rely on thenewmethod’s validation.
- The
-
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
Resultto 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.
- Use