- Rust divides errors into two types: recoverable and unrecoverable
- Recoverable errors are those where itâs reasonable to report to the user and get them to retry the operation (i.e. a FileNotFound error)
- These return the type
Result<T, E>
- These return the type
- Unrecoverable errors are largely outside of a userâs control and are the result of program bugs, like an out-of-bounds access in an array
- These have the type
panic!
and cause the program to halt execution
- These have the type
- Recoverable errors are those where itâs reasonable to report to the user and get them to retry the operation (i.e. a FileNotFound error)
Unrecoverable errors with panic!
panic!
is a function thatâs explicitly called by library functions (and can in fact be called by any developer in their code) to notify us of software bugs- By default when a panic occurs, Rust walks back up the stack and cleans up data from each function it encounters. This is obviously compute-intensive and may slow down our program, so we can edit our
Cargo.toml
file to turn abort mode on and prevent the stack cleanup
- By default when a panic occurs, Rust walks back up the stack and cleans up data from each function it encounters. This is obviously compute-intensive and may slow down our program, so we can edit our
- As developers, we want to figure out whatâs causing our code to panic. We can do so with a backtrace
- We can run the command
RUST_BACKTRACE=1 cargo run
, which gives us the âhistoryâ of where our problem originated, starting with the most direct culprit - We want to scroll down this history until we see files that weâve created/edited, and further inspect those files to figure out whatâs going on
- We can run the command
Recoverable errors with Result
- The
Result
enum is defined as having two variants:Ok(T)
, where T represents the type of value thatâll be returned in the success case, andErr(E)
, where E represents the type of error thatâll be sent back otherwise
enum Result<T, E> {
Ok(T),
Err(E),
}
- Certain functions (like
File::open
) need to tell us whether they succeeded or failed and at the same time deliver us return values or error codes- The
Result
enumâs purpose is to convey this information. Under the hood,File::open
does actually return aResult
object, which we have to handle (likely with amatch
orif let
)
- The
- Hereâs an example of how we can handle a return value of
Result
type with a match statement:
use std::fs::File; // Result enum/variants imported before prelude, so can call 'Ok' and 'Err' without any extra work
use std::io::ErrorKind
fn main() {
let f = File::open("hello.txt");
let f = match f {
Ok(file) => file,
Err(ref error) => if error.kind() == ErrorKind::NotFound => {
match File::create("hello.txt") {
Ok(fc) => fc,
Err(e) => {
panic!("Tried to create but problem! {:?}", e)
},
}
},
Err(error) => {
panic!(
"There was a problem opening the file: {:?}",
error
)
}
}
}
- In the code above, the second arm of our
match
expression includes whatâs called a match guard- This is an extra condition on a match arm such that we only process the code in the arm if itâs true
ErrorKind
has several types which indicate the errors that can come about in Rust; we must import these explicitly to use them- And we pass in a
ref
object to our match guard such that it doesnât take ownership, and useerror.kind()
to extract the type of error
- Ultimately, match expressions are verbose and we donât always need them
- So we introduce another syntax for error handling, which is useful in the case that we want to panic when an
Err(E)
type is returned - The
.unwrap()
operator allows us to set a variable to the result ifOk
, else callspanic!
with a pre-defined error message - The
.expect(error_msg)
operator does the same thing, except returnserror_msg
as the message in the panic- This is oftentimes useful for tracking where our errors originated from
- So we introduce another syntax for error handling, which is useful in the case that we want to panic when an
use std::fs::File;
fn main() {
// using unwrap
let f = File::open("the_code.rs").unwrap();
// using expect
let f = File::open("the_code.rs").expect("error: the code hasn't been received yet!");
}
Propagating errors in Rust
- Sometimes, we want our functions to return a
Result
type and give their callers discretion over handling any errors - The following code snippet is a useful example of how to do so:
use std::io;
use std::io::Read;
use std::fs::File;
fn read_username_from_file() -> Result<String, io::Error> {
let f = File::open("pwd.txt");
let mut f = match f {
Ok(file) => file,
Err(e) => return Err(e), // returns the error early
};
let mut s = String::new();
match f.read_to_string(&mut s) {
Ok(_) => Ok(s), // on success, returns the username as a String
Err(e) => Err(e), // on failure, returns an error
}
}
- Again, this seems exceedingly verbose - thankfully, Rust provides us with another mechanism to cut down on the verbosity of our match expressions
- This is reminiscent of TypeScript; we can specify a
?
at the end of function calls that return aResult
type (this last point is important - we canât use the?
operator in functions that donât return a value ofResult
type)- If these calls return an error, then itâs converted to the error type defined in the return type of the current function and then returned
- Else, we proceed as normal
- This is reminiscent of TypeScript; we can specify a
- Letâs shorten our function from earlier with this new trick:
use std::io;
use std::io::Read;
use std::fs::File;
fn read_username_from_file() -> Result<String, io::Error> {
let mut s = String::new();
File::open("pwd.txt")?.read_to_string(&mut s)?;
Ok(s)
}
General guidelines for error handling
- Our compiler isnât that smart, and doesnât realize that something like
"127.0.0.1".parse()
wouldnât return an error in any case- Here, itâs good practice to use
unwrap
because theparse
function returns aResult
item by default, and we have to handle this somehow
- Here, itâs good practice to use
- In cases where a failure is an expected possibility, we should return
Result
types rather than explicitly panicking. These errors are expected and recoverable, and we want to convey this to our consumers - Each of the functions that we expose has a contract that guarantees certain behavior if callers follow that contract
- If the contract is violated (i.e. by an out-of-bounds access for indexing a vector in Rust), we should panic
- But we donât need to be super verbose in our checks for input validity - Rustâs type-checking system gets us most of the way there
- We can be smart here - i.e. using the
u32
integer type in our function definition to ensure that parameters are never negative
- We can be smart here - i.e. using the
- Now, letâs say that we wanted our input to be an integer between 1 and 100. We donât want to check this explicitly every time a user calls our function, so we define a struct that we can use as our input type:
pub struct Guess {
value: u32,
}
impl Guess {
// setter
pub fn new(value: u32) -> Guess {
if value < 1 || value > 100 {
panic!("guess value must be in range [1, 100], got: {}", value);
}
Guess {
value
}
}
// getter
pub fn value(&self) {
// this is private by default, exposing it here
self.value
}
}
References
- Chapter 9 of The Rust Programming Language by Steve Nichols and Nicole Klabnick.