You’ll run into errors eventually, it’s just how it is. I often find that what makes one language more enjoyable than another one to work with is how it handles errors.
Rust’s approach is very interesting (i ran into this in my first project before reaching this chapter) and I can only compare it to golang in that regard.
There are Two Categories:
Recoverable -> informs, logs, retries, etc.
Unrecoverable -> something went wrong, stop the program.
Rust’s Errors
Rust doesn’t treat the two categories the same and offers different mechanisms, and has no Exceptions.
Instead, it has the type Result<T, E> for recoverable errors and the panic! macro that stops execution.
Unrecoverable Errors with panic!
Program’s panic when a certain action causes it to panic, for example accessing an array out of bounds. We can also explicitly cause a program to panic using the panic! macro.
When a program panics it prints a failure message, cleans up the stack, and quits. In the printed message is the call stack, so we can track the error’s source.
Unwinding The Stack
When a program panics, Rust will walk back up the stack and clean up data for each function.
However, this is a lot of work, so you’re offered the option of immediately aborting the program without the clean up.
This leaves the responsibility of memory clearing up to the OS.
To switch from unwinding to immediate abort, add the following to your Cargo.toml
[profile.release]panic = 'abort'
Crashing On Purpose
If you need to
Example of how to throw a panic.
fn main() { panic!("crash and burn");}
Then macro will show the location of the panic, a back-trace, and where the program dies, along with the message passed to it.
Another Example
Accessing an array index that doesn’t exist won’t cause a Buffer Overread like it does in C.
fn main() { let v = vec![1, 2, 3]; v[99];}
This won’t be a security issue because Rust will panic the program to prevent any risk of accessing memory that isn’t meant to be accessed.
Here’s the backtrace and panic message:
thread 'main' panicked at src/main.rs:4:6:index out of bounds: the len is 3 but the index is 99note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
“The note: line tells us that we can set the RUST_BACKTRACE environment variable to get a backtrace of exactly what happened to cause the error.”
Backtraces
Like in other languages, these are lists of all the functions called up to the crash point.
The trick is to read the list from top to bottom until you see the files you’ve written. Everything before then is code that your code has called, and everything after is core Rust code.
Recoverable Errors with Result
We don’t always want to crash our program just because we encounter an error.
Most errors are mundane and can be handled without aborting. For example, creating a file when you try to open it and it’s not found…
This where the Result type, discussed in chapter 2 and chapter 6, comes in.
enum Result<T, E> { Ok(T), Err(E),}
By checking the variant of the returned Result type in our logic flow, we can handle both the success and failures.
The match expression lends itself well to this process, just like it did with the Option<T> enum type.
Example of controlling the program’s flow based on the result:
fn main() { let some_op_result = do_something(String::from("parameter_value")); let some_op_value = match some_op_result { Ok(v) => v, Err(e) => panic!("panicked because {e:?}"), };}
Note: we don't *have* to panic in the second arm of the match.
Matching Different Errors
Sometimes the Result can produce more than just one error type, and we may want the handling can be specific per error.
Simply add an inner match expression within the error arm.
Ex:
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:?}"), }, _ => { panic!("Problem opening the file: {error:?}"); } }, };}
The File::open returns an io::Error inside its Err variant. This is a struct that provides a method .kind() which returns io::ErrorKind which is provided by the standard library and we can check for it in our match arm.
A Better Way - Closures
Having multiple match expressions like this isn’t wrong but it’s not the most elegant way of doing things.
In Learning Rust - Ch. 13 - Functional Programming, Iterators, and Closures we’ll cover Closures and the unwrap_or_else method which simplify this process.
A sneak peak, here’s the previous example but with closures:
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:?}”);
>}
});
}
Shortcut Methods
unwrap
Since the Result type is an enum with variants, we can open it up without resolving to a match.
The unwrap method returns the value inside the Ok(v) variant, but if it’s an Err it’ll panic instead!
Example:
use std::fs::File;fn main() { let greeting_file = File::open("hello.txt").unwrap();}
This function will panic if there’s no hello.txt file in the current directory.
expect
This method lets us define the panic! message but it’s used the same way as unwrap: to return the value or call the panic! macro.
Example:
use std::fs::File;fn main() { let greeting_file = File::open("hello.txt") .expect("hello.txt should be included in this project");}
Shortcut Cont.
The choice between expect or unwrap conveys your intent with how the program’s expected to behave, by using the prior and giving it a good error message you make debugging a better experience.
Be Advised
“In production-quality code, most Rustaceans choose expect rather than unwrap and give more context about why the operation is expected to always succeed. That way, if your assumptions are ever proven wrong, you have more information to use in debugging.”
Propagating Errors
errorpropagation is the process of returning the error encountered to the code that called the failing function, instead of handling the error in the failing function.
It lends more control over the error to the calling code, where there may be more context and information for it to be resolved.
Examine this example, which can be simplified but for the sake of learning they do so slowly.
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), }; let mut username = String::new(); match username_file.read_to_string(&mut username) { Ok(_) => Ok(username), Err(e) => Err(e), }}
Breakdown:
Return type is a concrete Result<String, io::Error> instead of generic Result<T, E>
If all is well and the file exists and is readable, the first match expression returns the file handle.
Whereas in the Err case, instead of calling panic!, it uses the return keyword to return early out of the function entirely and pass the error value to the calling code.
Then if there is a handle, a new string is created, and the username is read from username_file using the read_to_string method which takes a variable to write to.
If success, the function returns Ok(username) which can then be unwrapped
Otherwise it returns an Err to the caller, and it's up to it to handle the error.
Since there’s no context on what the caller is trying to do, it’s best to propagate the error until it reaches a spot where it makes sense to handle.
Shortcut: The ? Operator
The ? op works the same way as the match expressions do with the Result types, but there is a difference between the two.
It also works on Option<Some<k>, None> type.
The ? sends error values through the from function which is defined in the From trait, which converts values from one type to another. In this case, the ? operator calls from function and the error is converted from error type received to the error type of the current function.
“This is useful when a function returns one error type to represent all the ways a function might fail, even if parts might fail for many different reasons.”
The beauty of the ? operator shines more when we start daisychaining methods after it.
The file opening example becomes very simplified:
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)}
Or even more so using the read_to_string method:
use std::fs::File;use std::io::{self, Read};fn read_username_from_file() -> Result<String, io::Error> { fs::read_to_string("bob.txt")}
When to Use ? Operator
It can only be used on function that have a return type compatible with the values the ? works on.
This is because it performs an early return of the value out of the function… // Don't use it with something that doesn't return the function's return type!
If you’re code panics, there’s no recovery, the whole thing comes to a halting stop!
When returning Result types you give the calling code a chance to actually respond and handle the situation.
You could just be a maniac and let every error be cause for a panic!…
But a Result that’s actually an Err gives the calling code a chance to behave appropriately and recover the program’s execution flow.
However, there are times when it’s better to panic! instead.
Examples, Prototypes, and Testsing
When showing an example of how something works, error handling can make the example less clear.
“In examples, it’s understood that a call to a method like unwrap that could panic is meant as a placeholder for the way you’d want your application to handle errors, which can differ based on what the rest of your code is doing.”
With regards to prototyping, the unwrap and expect methods come in handy because you might not have decided on how to handle errors. Giving you a chance to change your approach later on…
In testing, a failed method call should fail the whole test, so panicking is expected.
Having More Info Than Compiler…
When you know you’ve other logic in place that will ensure that a Result has an Ok<t> value, it’s appropriate to call expect. This isn’t something the compiler will understand.
They use an example of creating an IP address from a hardcoded string, here.
Return an error when receiving values that don’t make sense, such as with user I/O. But only when it’s safe to do so, sometimes it’s not the case…
If failure is expected within a certain context then a Result is more appropriate than a panic!.
If code may put user at risk due to bad values, your code must validate them first then panic if they’re not valid. This is also for safety.
Custom Types for Validation
Thanks to Rust’s type system, we can create custom type with the express purpose of validation.
In another, dedicated module, we create a new type, and place validations in a function that creates an instance of this new type. // This decreases the number of validations in our logic.
Take the guessing game we did in chapter 2, this is an example of how we can make a custom type that checks the user’s value is positive and within range.
pub struct Guess{ value: i32,}impl Guess{ pub fn new(value: i32) -> Guess{ if value < 1 || value > 100{ panic!("Value is not within range! Guess must be integer bewteen 1 and 100!"); } Guess{ value } } // getter method because the member of the struct is private pub fn get_value(&self) -> i32{ self.value }}
Summary
The panic! tells the process to stop execution when your program hits a state it can’t recover from.
Rust’s Result<T, E> type helps use decide how to respond to errors and resume the flow of execution.
The Result type also informs readers of your code that you need to handle the case of failure as well as success.
Guidelines on when to panic and when to return a Result exist.