Error Handling in Rust
Introduction
Rust takes a unique approach to error handling that differs from traditional try-catch mechanisms found in other languages. It distinguishes between two types of errors:
- Recoverable errors (handled with
Result<T, E>
) - Unrecoverable errors (handled with the
panic!
macro)
This distinction helps developers make conscious decisions about error handling and leads to more robust code.
Unrecoverable Errors with panic!
Basic panic! Usage
fn main() {
panic!("crash and burn");
}
Stack Unwinding
When a panic occurs, Rust will:
- Print the error message
- Unwind and clean up the stack
- Exit the program
fn main() {
let v = vec![1, 2, 3];
v[99]; // This will cause a panic!
}
Controlling Stack Traces
// Set the RUST_BACKTRACE environment variable
// Windows PowerShell:
// $env:RUST_BACKTRACE=1
// Unix-like systems:
// export RUST_BACKTRACE=1
fn main() {
panic!("show me the backtrace");
}
Recoverable Errors with Result
The Result Enum
enum Result<T, E> {
Ok(T), // Success case containing a value
Err(E), // Error case containing an error
}
Basic Result Usage
use std::fs::File;
fn main() {
let file_result = File::open("hello.txt");
match file_result {
Ok(file) => println!("File opened successfully"),
Err(error) => println!("Error opening file: {:?}", error),
}
}
Chaining Results
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)?;
Ok(username)
}
Error Handling Methods
The ? Operator
fn parse_and_multiply(input: &str) -> Result<i32, ParseIntError> {
let x: i32 = input.parse()?;
Ok(x * 2)
}
unwrap and expect
// Using unwrap (panics on Err)
let f = File::open("hello.txt").unwrap();
// Using expect (panics with custom message)
let f = File::open("hello.txt").expect("Failed to open hello.txt");
Custom Error Types
#[derive(Debug)]
enum AppError {
IoError(std::io::Error),
ParseError(std::num::ParseIntError),
Custom(String),
}
impl From<std::io::Error> for AppError {
fn from(error: std::io::Error) -> Self {
AppError::IoError(error)
}
}
impl From<std::num::ParseIntError> for AppError {
fn from(error: std::num::ParseIntError) -> Self {
AppError::ParseError(error)
}
}
fn process_data(path: &str) -> Result<i32, AppError> {
let content = std::fs::read_to_string(path)?;
let number: i32 = content.trim().parse()?;
if number < 0 {
return Err(AppError::Custom("Number cannot be negative".to_string()));
}
Ok(number)
}
Best Practices
1. Error Context
use std::error::Error;
use std::fmt;
#[derive(Debug)]
struct ContextError {
context: String,
source: Box<dyn Error>,
}
impl fmt::Display for ContextError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}: {}", self.context, self.source)
}
}
impl Error for ContextError {
fn source(&self) -> Option<&(dyn Error + 'static)> {
Some(&*self.source)
}
}
fn add_context<E: Error + 'static>(
context: &str,
error: E,
) -> ContextError {
ContextError {
context: context.to_string(),
source: Box::new(error),
}
}
2. Result Combinators
use std::fs::File;
fn process_file() -> Result<String, std::io::Error> {
File::open("data.txt")
.map_err(|e| {
println!("Error opening file: {}", e);
e
})
.and_then(|mut file| {
let mut content = String::new();
file.read_to_string(&mut content)
.map(|_| content)
})
}
3. Error Propagation
fn function1() -> Result<(), Error1> {
// ... implementation
}
fn function2() -> Result<(), Error2> {
// ... implementation
}
fn process() -> Result<(), Box<dyn Error>> {
function1()?;
function2()?;
Ok(())
}
Common Patterns
1. Fallible Construction
#[derive(Debug)]
struct Person {
name: String,
age: u32,
}
impl Person {
fn new(name: String, age: u32) -> Result<Person, &'static str> {
if name.is_empty() {
return Err("Name cannot be empty");
}
if age > 150 {
return Err("Age is invalid");
}
Ok(Person { name, age })
}
}
2. Multiple Error Types
use std::error::Error;
use std::fs;
use std::num::ParseIntError;
fn read_and_parse(file_path: &str) -> Result<i32, Box<dyn Error>> {
let contents = fs::read_to_string(file_path)?;
let number = contents.trim().parse::<i32>()?;
Ok(number)
}
Performance Considerations
-
Stack Unwinding
- Panic unwinding can be expensive
- Use
panic = "abort"
in Cargo.toml for smaller binaries - Consider using
std::panic::catch_unwind
for FFI boundaries
-
Error Types
Box<dyn Error>
has runtime overhead- Consider using specific error types for better performance
- Use enums for known error sets
Common Pitfalls
1. Overuse of unwrap()
// Bad: Potential panic
let number: i32 = "10".parse().unwrap();
// Better: Handle the error
let number: i32 = match "10".parse() {
Ok(n) => n,
Err(e) => {
eprintln!("Failed to parse number: {}", e);
return Err(e);
}
};
2. Ignoring Errors
// Bad: Silently ignoring errors
let _ = file.write_all(data);
// Better: Handle or propagate
file.write_all(data)?;
Next Steps
After mastering error handling:
- Learn about error handling libraries like anyhow and thiserror
- Study error handling in async/await contexts
- Explore testing error conditions
- Understand logging and error reporting best practices
Remember: Rust's error handling is designed to make errors explicit and ensure they're handled appropriately. Take time to design your error types and handling strategies for robust applications.