2024-10-02

003 - Error Handling

Learning Rust as a Pythonista: Error Handling

In this article, we’ll explore how error handling in Rust differs from Python, focusing on Rust’s Result and Option types. While Python uses exceptions for error management, Rust takes a more explicit, compile-time approach to handling potential errors. By comparing these mechanisms, we’ll highlight how Rust’s error handling encourages more robust, predictable code.

1. Error Handling in Python: try-except Blocks

In Python, you handle errors with try-except blocks. Python uses exceptions to indicate that something went wrong, and you can handle these exceptions as needed:

def divide(a, b):
    try:
        result = a / b
    except ZeroDivisionError:
        return "Cannot divide by zero"
    return result

print(divide(10, 2))  # 5.0
print(divide(10, 0))  # Cannot divide by zero

Key Points in Python:

  • Python’s error handling is done at runtime using exceptions.
  • If an exception occurs and isn’t caught, the program will crash.
  • Error types like ZeroDivisionError, FileNotFoundError, etc., are part of Python’s standard exceptions.

2. Error Handling in Rust: Result and Option Types

In contrast to Python, Rust doesn’t use exceptions. Instead, it uses the Result and Option types to handle potential errors at compile time. These types ensure that the programmer handles errors explicitly, making it impossible to ignore them.

The Result Type

In Rust, functions that can fail return a Result<T, E>, where T is the type of the successful value and E is the type of the error. Here’s how to handle division in Rust, similar to the Python example:

fn divide(a: f64, b: f64) -> Result<f64, String> {
    if b == 0.0 {
        Err(String::from("Cannot divide by zero"))
    } else {
        Ok(a / b)
    }
}

fn main() {
    match divide(10.0, 2.0) {
        Ok(result) => println!("Result: {}", result),
        Err(e) => println!("Error: {}", e),
    }

    match divide(10.0, 0.0) {
        Ok(result) => println!("Result: {}", result),
        Err(e) => println!("Error: {}", e),
    }
}

Key Differences:

  • Explicit Handling: Rust forces you to handle errors explicitly. You can’t ignore the Result type—if a function returns a Result, you must deal with both success (Ok) and failure (Err).
  • Compile-time Safety: Rust’s approach guarantees at compile-time that errors are not left unchecked, which reduces the chances of bugs that arise from unhandled exceptions.

3. Using Option for Nullable Values

Another key type in Rust’s error-handling model is Option<T>. It’s used for cases where a value might or might not exist, similar to None in Python. In Python, you can return None to indicate the absence of a value, but there is no enforced handling of None values.

def find_value(dictionary, key):
    return dictionary.get(key)

data = {"a": 1, "b": 2}
print(find_value(data, "a"))  # 1
print(find_value(data, "c"))  # None

In Rust, you would use the Option<T> type for the same purpose:

fn find_value(dictionary: &std::collections::HashMap<&str, i32>, key: &str) -> Option<i32> {
    dictionary.get(key).copied() // Use copied() to convert Option<&i32> to Option<i32>
}

fn main() {
    let mut data = std::collections::HashMap::new();
    data.insert("a", 1);
    data.insert("b", 2);

    match find_value(&data, "a") {
        Some(value) => println!("Found: {}", value),
        None => println!("Key not found"),
    }

    match find_value(&data, "c") {
        Some(value) => println!("Found: {}", value),
        None => println!("Key not found"),
    }
}

Key Differences:

  • Optional Values: In Python, using None for missing values is implicit and can sometimes lead to NoneType errors if not checked properly. In Rust, Option<T> forces you to handle the case where the value may not be present.
  • Pattern Matching: Rust uses match to destructure Option and Result types, which makes it clear whether a value exists or not, or whether an operation succeeded or failed.

4. Propagating Errors with the ? Operator

Rust provides a convenient way to propagate errors without having to write explicit match statements for each error scenario. The ? operator can be used to return an error if one occurs, propagating it upwards in the call stack. This simplifies the code when handling multiple potential error points.

Rust Example Using ? Operator:

Here’s a simple example of how the ? operator can be used in Rust to handle errors:

fn read_file_content(path: &str) -> Result<String, std::io::Error> {
    std::fs::read_to_string(path)
}

fn main() -> Result<(), std::io::Error> {
    let content = read_file_content("my_file.txt")?;
    println!("{}", content);
    Ok(());
}

Key Differences:

  • Error Propagation: Rust’s ? operator is similar to Python’s raise in that it passes the error to the calling function. However, Rust enforces the handling of errors at compile-time, ensuring safer and more predictable error management.

5. Custom Error Types

In Rust, you can define your own custom error types to better reflect the kinds of errors your application might encounter. This is similar to creating custom exception classes in Python.

Python Example:

class CustomError(Exception):
    pass

def raise_custom_error():
    raise CustomError("This is a custom error")

try:
    raise_custom_error()
except CustomError as e:
    print(f"Caught custom error: {e}")

Rust Example:

#[derive(Debug)]
struct CustomError(String);

fn raise_custom_error() -> Result<(), CustomError> {
    Err(CustomError(String::from("This is a custom error")))
}

fn main() {
    match raise_custom_error() {
        Ok(_) => println!("No error"),
        Err(e) => println!("Caught custom error: {:?}", e),
    }
}

Key Differences:

  • Custom Errors: Both Rust and Python allow you to define custom error types, but in Rust, you’ll likely use the Result type to wrap these errors, while in Python, you’d throw and catch exceptions.

Conclusion

Error handling in Rust is more explicit and rigorous than in Python, providing compile-time guarantees that errors are properly addressed. While Python’s exception-based system is flexible, Rust’s Result and Option types ensure that error handling is never left to chance. These differences reflect Rust’s focus on safety and robustness, especially when developing performance-critical or system-level applications.

In the next article, we’ll take a closer look at Structs and Enums in Rust, and compare them to Python’s namedtuple and dataclass. Stay tuned!

Running the Complete Rust Example

use std::collections::HashMap;
use std::fs;

fn divide(a: f64, b: f64) -> Result<f64, String> {
    if b == 0.0 {
        Err(String::from("Cannot divide by zero"))
    } else {
        Ok(a / b)
    }
}

// Adding lifetime annotations
fn find_value<'a>(dictionary: &'a HashMap<&'a str, i32>, key: &'a str) -> Option<&'a i32> {
    dictionary.get(key)
}

// Here we use copied since we have not covered lifetime annotations yet
// fn find_value(dictionary: &std::collections::HashMap<&str, i32>, key: &str) -> Option<i32> {
//     dictionary.get(key).copied() // Use copied() to convert Option<&i32> to Option<i32>
// }

fn read_file_content(path: &str) -> Result<String, std::io::Error> {
    fs::read_to_string(path)
}

#[derive(Debug)]
struct CustomError(String);

fn raise_custom_error() -> Result<(), CustomError> {
    Err(CustomError(String::from("This is a custom error")))
}

fn main() {
    // Division examples
    match divide(10.0, 2.0) {
        Ok(result) => println!("Result: {}", result),
        Err(e) => println!("Error: {}", e),
    }

    match divide(10.0, 0.0) {
        Ok(result) => println!("Result: {}", result),
        Err(e) => println!("Error: {}", e),
    }

    // Find value in HashMap
    let mut data = HashMap::new();
    data.insert("a", 1);
    data.insert("b", 2);

    match find_value(&data, "a") {
        Some(value) => println!("Found: {}", value),
        None => println!("Key not found"),
    }

    match find_value(&data, "c") {
        Some(value) => println!("Found: {}", value),
        None => println!("Key not found"),
    }

    // Read file content (replace "my_file.txt" with an existing file path)
    match read_file_content("my_file.txt") {
        Ok(content) => println!("{}", content),
        Err(e) => println!("Error reading file: {}", e),
    }

    // Raise custom error
    match raise_custom_error() {
        Ok(_) => println!("No error"),
        Err(e) => println!("Caught custom error: {:?}", e),
    }
}

Instructions to Run

  1. Save the Code: Copy the above code into a new file named main.rs.

  2. Create a Sample File: Ensure you have a file named my_file.txt in the same directory as your Rust file, or change the filename in the code to an existing file.

  3. Run the Code: In your terminal, navigate to the directory containing the main.rs file and run:

    cargo run
    

Join the Journey Ahead!

If you're eager to continue this learning journey and stay updated with the latest insights, consider subscribing. By joining our mailing list, you'll receive notifications about new articles, tips, and resources to help you seamlessly pick up Rust by leveraging your Python skills.

Other articles in the series:

000 - Learning Rust as a Pythonista: A Suggested Path

001 - Learning Rust as a Pythonista: How to Create and Run a Rust File

002 - Learning Rust as a Pythonista: Basic Syntax and Structure

004 - Structs and Enums

005 - Iterators and Closures

006 - Rust Traits vs. Python Duck Typing: A Comparison for Pythonistas

007 - Concurrency in Rust for Python Developers

008 - Pattern Matching in Rust for Python Developers

009 - Macros in Rust for Python Developers