Error Handling
In many occasions, Rust requires you to acknowledge the possibility of an error and take some action before your code will compile.
Rust groups errors into 2 main categories:
- recoverable - errors you can report to the user and retry the operation
- unrecoverable - usually bugs. (e.g. index out of bounds)
Rust doesn't have exceptions. It has the type Result<T, E>
for recoverable errors and the panic!
macro that stops execution when the program encounters an unrecoverable error.
Unrecoverable Errors with panic!
When the panic!
macro executes, your program will print a failure message, unwind and clean up the stack, and then quit.
By default, when a panic occurs, the programs starts unwinding - Rust walks back up the stack and cleans up the data from each funciton it encounters. This is a a lot of work. An alternative is to immediately abort - which ends the program without cleaning it up. The memory that the program uses will need to be cleaned up by the operating system. If you need the binary to be as small as possible, you can switch from unwinding to aborting upon a panic by adding
panic = 'abort'
to the apporiate[profile]
sections.[profile.release] panic = 'abort'
An example of panic!
fn main() {
panic!("crash and burn");
}
The error message will print the location of the panic
.
$ cargo run
Compiling panic v0.1.0 (file:///projects/panic)
Finished dev [unoptimized + debuginfo] target(s) in 0.25s
Running `target/debug/panic`
thread 'main' panicked at 'crash and burn', src/main.rs:2:5
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace.
Using a panic!
Backtrace
Let's take a look at an example where we do not throw the panic
.
fn main() {
let v = vec![1, 2, 3];
v[99];
}
This is essentially an index out of bounds exception in Java.
$ cargo run
Compiling panic v0.1.0 (file:///projects/panic)
Finished dev [unoptimized + debuginfo] target(s) in 0.27s
Running `target/debug/panic`
thread 'main' panicked at 'index out of bounds: the len is 3 but the index is 99', /rustc/5e1a799842ba6ed4a57e91f7ab9435947482f7d8/src/libcore/slice/mod.rs:2806:10
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace.
The location in the error message points to a file that we don't own. This is because the implementation of the vector is throwing the panic.
How do we get the stacktrace (or backtrace)? The last line tells us.
A backtrace is a list of all the functions that hav ebeen caleed to get to this point.
If we set the environment variable, we can view this:
thread 'main' panicked at 'index out of bounds: the len is 3 but the index is 99', main.rs:4:5
stack backtrace:
0: _rust_begin_unwind
1: core::panicking::panic_fmt
2: core::panicking::panic_bounds_check
3: <usize as core::slice::SliceIndex<[T]>>::index
4: core::slice::<impl core::ops::index::Index<I> for [T]>::index
5: <alloc::vec::Vec<T> as core::ops::index::Index<I>>::index
6: main::main
7: core::ops::function::FnOnce::call_once
note: Some details are omitted, run with `RUST_BACKTRACE=full` for a verbose backtrace.
Recoverable Errors with Result
All errors do not need a program to stop completely.
Result
is an enum is defined as having two variants:
enum Result<T, E> {
Ok(T),
Err(E),
}
T
and E
are generics, representing the type for a success and error, respectively.
An example of a function that results in a possible failure.
use std::fs::File;
fn main() {
let f = File::open("hello.txt");
}
We know this returns a Result
by looking at the the API documentation or asking the compiler.
Looking at the API we know that File::open
returns std::io::Result<std::fs::File>
. Not what we expect? If we look at the defintion of std::io::Result
, we learn that is is the same as std::result::Result<std::fs::File, std::io::Error>
.
So if File::open
succeeds, the result will have File
, else Error
.
We can then handle each case:
use std::fs::File;
fn main() {
let f = File::open("hello.txt");
let f = match f {
Ok(file) => file,
Err(error) => panic!("Problem opening the file: {:?}", error),
};
}
Note that, like the
Option
enum, theResult
enum and its variants have been brought into scope by the prelude, so we don’t need to specifyResult::
before theOk
andErr
variants in the match arms.
Matching on Different Errors
We may not want to panic!
for every failure. Let's say we want to create a file if the file doesn't exist and panic!
for other cases, like permission issue.
use std::fs::File;
use std::io::ErrorKind;
fn main() {
let f = File::open("hello.txt");
let f = match f {
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),
},
other_error => {
panic!("Problem opening the file: {:?}", other_error)
}
},
};
}
std::io::Error
has a kind method, which returns ErrorKind
, an enum containing variants representing the different kinds of errors that might result from an io
operation.
The above matches on NotFound
, meaning we didn't find the file.
That is a lot of match
- we'll learn about closures that will allow us to make this more concise:
use std::fs::File;
use std::io::ErrorKind;
fn main() {
let f = 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);
}
});
}
(This may not make complete sense until we get to closures.) unwrap_or_else
and other methods will help us get rid of nested match
expressions when handling errors.
Shortcuts for Panic on Error: unwrap
and expect
match
is fine and dandy, but it gets verbose. There are helper methods to define various tasks.
unwrap
is a shortcut method that is implemented just like the match
above. It will return the value inside Ok
or panic!
for Err
:
use std::fs::File;
fn main() {
let f = File::open("hello.txt").unwrap();
}
If we run this with a hello.txt
file:
thread 'main' panicked at 'called `Result::unwrap()` on an `Err` value: Os { code: 2, kind: NotFound, message: "No such file or directory" }', main.rs:4:37
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
expect
is similar to unwrap
, but it also let's us choose the panic!
error message:
use std::fs::File;
fn main() {
let f = File::open("hello.txt").expect("Failed to open hello.txt");
}
The error we see on running:
thread 'main' panicked at 'Failed to open hello.txt: Os { code: 2, kind: NotFound, message: "No such file or directory" }', main.rs:4:37
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
This allows us to give meaningful error messages.
Propagating Errors
Sometimes, you want to handle the error outside of the function. This gives the callers of the function more control.
use std::fs::File;
use std::io;
use std::io::Read;
fn read_username_from_file() -> Result<String, io::Error> {
let f = File::open("hello.txt");
let mut f = match f {
Ok(file) => file,
Err(e) => return Err(e),
};
let mut s = String::new();
match f.read_to_string(&mut s) {
Ok(_) => Ok(s),
Err(e) => Err(e),
}
}
(This method can be done in a shorter way, we'll get to that.)
This method will return the file string on success, but an error if it fails in either opening the file or reading the file. Both errors are covered by the return type.
We propagate the errors because we do not know the context of reading a file. Do people want a default string if the reading failed? Do we want to panic!
? Who knows. We let the callers of the function decide this.
The concept of propagating is so common that Rust provides the ?
operator to make it easier.
A Shortcut for Propagating Erros: the ?
Operator
Below is the same implementation as our previous example:
use std::fs::File;
use std::io;
use std::io::Read;
fn read_username_from_file() -> Result<String, io::Error> {
let mut f = File::open("hello.txt")?;
let mut s = String::new();
f.read_to_string(&mut s)?;
Ok(s)
}
What placing ?
after Result
does:
- If the value of
Result
is anOk
, the value insideOk
will get returned from this expression. - If the value of
Result
is anErr
, theErr
will be returned from the whole function as if we can used thereturn
keyword.
One main difference from using ?
and the previous match
expressions - the errors that have ?
called on them go through the from
function defined in theFrom
trait, which converts errors from one type into another.
The returned error type is converted to the error type defined in the return type of the current function. This is useful when a function returns one error type to represent all the ways a function can fail, even if specific parts fails for different reasons. As long as the error types define the from
function to convert itself to the returned error type, the ?
operator handles that conversion.
This helps us get rid of a lot of boilerplate code. We can simplify it even more by chaining:
use std::fs::File;
use std::io;
use std::io::Read;
fn read_username_from_file() -> Result<String, io::Error> {
let mut s = String::new();
File::open("hello.txt")?.read_to_string(&mut s)?;
Ok(s)
}
This example is fairly common, so Rust provides an even more convenient way to implement this:
use std::fs;
use std::io;
fn read_username_from_file() -> Result<String, io::Error> {
fs::read_to_string("hello.txt")
}
Rust provides a fs::read_to_string
function that creates a new String
, read the contents of a file, puts it into the String
, and returns it. (This didn't give us the chance of explaining the error handling obviously so we didn't go with this as our working example.)
The ?
Operator Can Be Used in Functions That Return Result
The ?
operator can be used in functions that have a return type of Result
. This is because the ?
operator works in the same way as the match
expression - specifically return Err(e)
logic. Therefore, the function using ?
must define Err
as a return type to be compatible.
Because of this, the following will fail:
use std::fs::File;
fn main() {
let f = File::open("hello.txt")?;
}
with an error message containing:
the `?` operator can only be used in an async function that returns `Result` or `Option` (or another type that implements `std::ops::Try`)
If you encounter this error, you either need to change the return type of the function or handle the Result
in another way.
The main
method is special and has restrictions on what the return type must be. One return type that is valid is ()
and another is Result<T,E>
:
use std::error::Error;
use std::fs::File;
fn main() -> Result<(), Box<dyn Error>> {
let f = File::open("hello.txt")?;
Ok(())
}
The Box<dyn Error>
type is called a trait object. We'll talk more about that later. Basically it just means "any kind of error".
To panic!
or Not To panic!
If you call panic!
, you're making the decision on behalf of the code calling your code that a situation is unrecoverable, regardless of context. If you choose to return Result
, you are giving the calling code options rather than making the decision for them. They can choose to panic!
themselves, or handle the Err
, or to propagate the Err
. Any case, it provides flexibility. Returning Result
is a good default choice.
There are rare situations where panic!
is more appropriate.
(To Be Continued)