Rust Errors

Elucian MoiseElucian Moise
14 min read

In Rust, exceptions are defined as "errors" and they are classified into two categories: recoverable and unrecoverable.

Recoverable

Recoverable errors are situations that can be handled in the code, such as an error in file I/O, where the programmer can take corrective action to fix the problem. In Rust, these types of errors are usually represented using the Result type, which can hold either a successful result or an error value.

Recoverable errors:

  • io::Error - I/O errors such as file not found or invalid permissions

  • num::ParseIntError - Errors that occur when converting strings to integers

  • serde::de::Error - Errors that occur during serialization and deserialization

Unrecoverable

On the other hand, unrecoverable errors are errors that indicate a severe problem such as a logical error or memory access violation, which can't be recovered from. In Rust, these types of errors are called "panics" and they are signaled by calling the panic! macro. When a panic occurs, the application will typically crash with an error message.

Unrecoverable errors (panics):

  • assert! - Asserts that a condition is true at runtime, and panics if it's false

  • index - Panics that occur when attempting to access an index that's out of bounds

  • unwrap - Panics that occur when the expected value isn't present in a Result or Option type

Developers should consider how to handle the recoverable errors. Unrecoverable errors will always stop the program execution. Developer concern is to unlock the resource if possible before the programm crash.


Assert!

In Rust, the assert! macro is a built-in debugging tool that allows developers to create assertions, which are statements that check whether an expression holds true during runtime. The assert! macro is used to check for logical correctness and help catch errors early in development.

The assert! macro works by evaluating the expression passed to it as an argument. If the expression evaluates to true, the program continues to execute normally. However, if the expression evaluates to false, the assert! macro triggers a panic. Panic is Rust's way of handling situations where the program encounters an unexpected condition, such as division by zero or a memory access violation.

Here is an example of how the assert! macro can be used in Rust:

fn divide(a: i32, b: i32) -> i32 {
    assert!(b != 0, "divide by zero");
    a / b
}

fn main() {
    let result = divide(10, 0);
}

In the above code, we use the assert! macro in the divide() function to check that b is not equal to 0. If b is indeed 0, the assert! macro will trigger a panic with the message "divide by zero".

By using the assert! macro in this way, we can catch errors early in development before they become more difficult to diagnose later on. It provides a way to check the pre and post-conditions of functions and ensure that our code is working as expected.

The assert! macro has several advantages, including:

  1. It provides a simple and standardized way to specify the expected behavior of a program during development.

  2. It helps catch errors early in development, making it easier to debug and diagnose problems.

  3. It produces helpful error messages that include the location of the assertion where the panic occurred, making it easier to track down problems.

  4. It can be disabled in release mode, meaning it doesn't affect the performance of the final product.

Overall, the assert! macro is a useful tool for improving code quality in Rust by providing a simple and standardized way to specify the expected behavior of a program during development and catch errors early.


Panic

In Rust, unrecoverable errors can be created by raising a panic. Panics are unrecoverable because once a panic is raised, the program will immediately terminate, and the error will be returned to the operating system.

There are several reasons why a programmer might want to create an unrecoverable error using a panic:

  1. Fatal errors: If there's an error in the program that cannot be recovered from, it's best to raise a panic and terminate the program. For example, if a critical file cannot be found, or if there's a bug in the code that could cause data corruption, it's better to terminate the program than to risk compromising the user's data.

  2. Invalid input: If the user provides invalid input that cannot be handled by the program, it's better to raise a panic and terminate the program. This can prevent the program from processing data that is out of bounds, potentially causing unexpected behavior or data corruption.

  3. Bad assumptions: If the programmer has made assumptions about the program that are not valid, and the program cannot continue without those assumptions, it's better to raise a panic and terminate the program. This can prevent the program from continuing and outputting incorrect data or behavior.

Here's an example of using a panic to create an unrecoverable error:

fn divide_by_zero(n: u32, d: u32) -> u32 {
    if d == 0 {
        panic!("Division by zero error.");
    }
    n / d
}

In this example, if the divisor (d) is zero, the program will raise a panic with the message Division by zero error.. This is an unrecoverable error because the program cannot proceed with the calculation, and it's better to terminate the program than to output invalid data.

It's important to note that panics should be used sparingly in Rust, and they should only be used for unrecoverable errors. For recoverable errors, it's better to use the Result type to handle errors explicitly.


User definer errors

In Rust, user-defined errors are error types that the programmer defines. Rust provides a built-in error type std::error::Error that can be used to define custom error types.

Define:

To define a custom error type in Rust, the programmer must implement the Error trait for their struct or enum. The Error trait requires the implementation of a description method that returns a string describing the error, and an cause method that returns an optional reference to another error that caused this error.

Here's an example of defining a custom error type in Rust:

use std::error::Error;
use std::fmt;

#[derive(Debug)]
struct MyError {
    details: String
}

impl MyError {
    fn new(msg: &str) -> MyError {
        MyError{details: msg.to_string()}
    }
}

impl fmt::Display for MyError {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        write!(f,"{}",self.details)
    }
}

impl Error for MyError {
    fn description(&self) -> &str {
        &self.details
    }
}

In this example, we defined a custom error type MyError that has a single field details that contain a message describing the error. We implemented the Display trait to be able to print a human-readable string representation of our error, and we implemented the Error trait to use our error in error handling.

Raise:

We can raise our custom error using the Result type:

fn do_something_that_might_fail() -> Result<(), MyError> {
    // ... some code that might fail ...
    Err(MyError::new("Something went wrong."))
}

In this example, we define a function that might fail, and use the Result type to return either () if the function succeeds or a MyError if an error occurs. We raise our custom error using the Err constructor.

Note that it's important to define a meaningful message in your error type because this message will be used to inform the user what went wrong.

What is Err()

In Rust, Err is a variant of the Result enum that represents an error or failure case. The Result enum is commonly used in Rust to handle errors and recover from them.

The Err variant of the Result enum represents a failure case and can contain an associated value of any type. Typically, this is used to store an error message that describes the failure.

Example

Here's an example of how to use Err:

fn divide(x: i32, y: i32) -> Result<i32, String> {
    if y == 0 {
        return Err("Cannot divide by zero".to_string());
    }
    Ok(x / y)
}

fn main() {
    let result = divide(10, 0);
    match result {
        Ok(value) => println!("Result: {}", value),
        Err(error) => eprintln!("Error: {}", error),
    }
}

In the above code, the divide() function returns a Result<i32, String> which indicates that the function can either succeed and return an i32 value or fail and return a String error message.

In this case, if the divisor y is equal to zero, the function will return an Err variant containing a String error message that says "Cannot divide by zero". If the division succeeds, the function will return an Ok variant containing the quotient.

In the main() function, we use the match statement to pattern match on the Result and handle the success and failure cases separately.

When using Err in Rust, it's considered a best practice to provide an informative error message that describes the cause of the failure. This helps with debugging and tracing the source of the error.


Best practice

Error handling is a crucial part of writing robust and reliable code. In Rust, error handling is done using the Result type, which represents a value or an error.

The Result type has two generic types: Result<T, E> where T is the OK type, which represents the successful result of a computation, and E is the error type, which represents the unsuccessful result or an error. The Result type can be used in four ways in Rust:

Catch the error

In this case, the function returns a Result type, and the caller checks the result and handles the error appropriately.

fn do_something_that_might_fail() -> Result<(), String> {
    // ... some code that might fail ...
    Err("Something went wrong.".to_string())
}

fn do_something_else() -> Result<(), String> {
    let result = do_something_that_might_fail();
    match result {
        Ok(()) => { /*...*/ },
        Err(e) => { /* handle e */ }
    }
    result
}

Panic on errors

In this case, the function returns () if successful, but if an error occurs, the program panics. This approach is useful when it is impossible or undesirable to recover from the error. For example:

fn do_something_that_might_fail() -> Result<(), String> {
    // ... some code that might fail ...
    Err("Something went wrong.".to_string())
}

fn do_something_panic() {
    do_something_that_might_fail().unwrap();
}

In the above code, unwrap() will panic and terminate the program in case an error occurs.

Handle errors

In this case, the function handles the error within the function without propagating it. This approach is useful when the error is not critical, and it is possible to perform a fallback action or provide a default value. For example:

fn do_something_that_might_fail() -> Result<(), String> {
    // ... some code that might fail ...
    Err("Something went wrong.".to_string())
}

fn do_something_with_fallback() {
    match do_something_that_might_fail() {
        Ok(()) => { /* ... */ },
        Err(e) => { 
            eprintln!("Error: {}", e);
            // provide a fallback action or default value ...
        }
    }
}

Propagate errors

In Rust, you can propagate errors using the ? operator. When you use the ? operator after calling a function that returns a Result, the operator will automatically handle the Ok and Err variants. Here's an example:

use std::fs::File;
use std::io::{self, BufRead};

fn read_file_lines(filename: &str) -> io::Result<Vec<String>> {
    let file = File::open(filename)?;
    let lines = io::BufReader::new(file)
        .lines()
        .collect::<Result<Vec<String>, io::Error>>()?;
    Ok(lines)
}

fn main() {
    let filename = "example.txt";
    let lines = read_file_lines(filename).unwrap_or_else(|error| {
        panic!("Failed to read {}: {}", filename, error);
    });
    for line in lines {
        println!("{}", line);
    }
}

In the above code, the read_file_lines() function reads the lines from a file and returns a Result<Vec<String>, io::Error> type.

We've used ? operator inside the function to handle errors. If the File::open() function returns an error, the ? operator will propagate the error up to the calling function.

Similarly, if the lines() method on the BufReader struct returns an error, the ? operator will also propagate that error.

In the main() function, we call the read_file_lines() function and handle the possible error with .unwrap_or_else(), which prints an informative error message if the read_file_lines() call returned an Err variant.

Propagation of errors is considered a best practice in Rust. It can simplify error handling by allowing errors to be handled at the appropriate level of abstraction.

Summary

The best practices for error handling in Rust include:

  • Always provide a meaningful error message.

  • Use the Result type to handle errors.

  • Propagate errors where possible.

  • Use the match expression to handle the Result type appropriately.

  • Only use unwrap() where it is impossible or undesirable to handle errors.

  • Consider using the ? operator to propagate errors automatically.

  • Use panic!() only when it is impossible or undesirable to recover from the error.


Expect method:

The expect() method in Rust is a convenient way to provide an error message when calling the unwrap() method on a result that fails. It allows developers to add an error message that is more informative and clear than the default panic message. This can be a great way to improve code quality by making it easier for developers to diagnose and debug errors.

The expect() method is similar to the unwrap() method in that it will panic if the Result is an Err. However, it additionally allows you to provide a custom error message that is included in the panic output.

Here is an example of how the expect() method can be used in Rust:

fn divide(a: i32, b: i32) -> i32 {
    if b == 0 {
        panic!("divide by zero");
    }
    a / b
}

fn main() {
    let result = divide(10, 0);
    let value = result.expect("failed to divide");
}

In the above code, the divide() function will panic with the message "divide by zero" if b is equal to zero. In the main() function, we call divide() with 10 and 0, which will result in an error. Instead of using unwrap(), we use the expect() method to provide a more descriptive error message.

When run, the output will be:

thread 'main' panicked at 'divide by zero', src/main.rs:3:9
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

As we can see, the error message is the same as the panic message when using unwrap(), but with an additional message that we provided. This can be helpful when diagnosing the cause of an error.

In summary, the expect() method can be a useful tool for improving code quality in Rust by allowing developers to provide custom error messages when a result is Err. By providing more informative and descriptive error messages, it can make it easier for developers to diagnose and debug errors.


Troubleshooting Rust Errors

Rust is a systems programming language designed for safe, concurrent, and practical programming. However, Rust can be quite strict in its static type checking and borrow checking, causing some common errors and pitfalls for new users. In this answer, we will explore some common Rust errors, troubleshooting techniques, and how to avoid pitfalls.

  1. Understanding Rust’s Ownership Rules: Rust's ownership rules play a critical role in ensuring memory safety and avoiding common errors such as null pointer exceptions, but it can be challenging to understand at first. The key idea in ownership is that every value has an owner, and there is only one owner at a time. When a value's owner goes out of scope, it gets dropped, and its memory gets cleaned up.

  2. Rust's Error Messages: Rust's error messages can be quite verbose and intimidating, but they help to identify where the error occurred and suggest possible solutions. The error messages include the file and line number where the error occurred, the error type, and a message explaining the error.

  3. Use Crates.io: Rust has a great package manager called Cargo, which is integrated with Crates.io, the Rust community's central repository for libraries. When starting a new project, it's best to check if there's a crate that solves a particular problem you're trying to address before writing code from scratch.

  4. Use Rust's Standard Library: Rust's standard library provides a lot of built-in functionality, including data structures, I/O, networking, and more. It is well-written, well-documented, and optimized for performance.

  5. Use Rustfmt: Rustfmt is a tool that automatically formats Rust code to conform to Rust's style guidelines. Using Rustfmt saves you from having to spend time checking your code's style manually.

  6. Read Rust Documentation: Rust has excellent documentation on its official website, Rust book, blogs, and other resources. It is essential to read the documentation to familiarize yourself with Rust's syntax, types, modules, and more.

  7. Avoid Copying Large Data Structures: Copying large data structures in Rust can be slow and memory-intensive. If a variable is being passed to a function, it is often more efficient to pass it by reference or to use a pointer (such as &str, &mut T, or Box).

Understanding Rust's ownership rules, learning to read Rust's error messages, using crates.io and Rust's standard library, using Rustfmt, and reading Rust documentation can help avoid common errors and pitfalls when working with Rust. Also, avoiding copying large data structures can improve performance.


Disclaim: This article was created using ChatGPT. I have ask some questions and I have goten this response. I do this to study Rust using AI and verify AI capability to generate content. My contribution is minimal. If you descover errors blame ChatGPT. I would appreciate some suggestions to ask more questions and improve this article with your prompts.


Thank you for reading. Have fun!

0
Subscribe to my newsletter

Read articles from Elucian Moise directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Elucian Moise
Elucian Moise

Software engineer instructor, software developer and community leader. Computer enthusiast and experienced programmer. Born in Romania, living in US.