rust programming

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:

  1. Recoverable errors (handled with Result<T, E>)
  2. 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:

  1. Print the error message
  2. Unwind and clean up the stack
  3. 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

  1. 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
  2. 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.