Error management in Rust
Rust is a relatively new language that is gaining more and more popularity. Its strength is in having a strict compiler that tries to remove entirely some common development errors you would find in other languages, primarily C and its whole class of memory issues.
One of the interesting things about Rust is the error management. Things are done so that it is (normally) impossible to just ignore an error case and force you to deal with them all. Let’s take this very small C snippet:
#include <fcntl.h>
#include <unistd.h>
#include <string.h>
static char const * const HELLO = "Hello, world!\n";
int main(void) {
int fd = open("foo.bar", O_WRONLY | O_CREAT, 0666);
write(fd, HELLO, strlen(HELLO));
close(fd);
return 0;
}
An experimented C developer would be able to tell you that the open
, write
and close
calls can fail, and that you should check them. In the current case, if the program cannot execute any of these calls, it just fails silently. More annoyingly, even when enabling all compiler warning flags, even the very broad -Weverything
clang provides, there is absolutely no warning on this behavior.
You should, after each of these calls, check if they returned -1
, and in that case, use errno
and other functions to determine what went wrong, manually print a diagnostic message and exit the program, cleaning resources as needed. The error information – or at least, the information of the presence of an error, not the error itself is the same type and saved in the same place as a valid return, which makes it painfully easy to ignore the error and use that invalid value in next calls like we do here. Again, compiler does not even emit a warning on this, making it very easy to forget to check an error.
Also, amusingly and to add to the pile of issues, in my first writing I forgot the mode argument to open
, which created a file with nonsensical permissions. Again, not a warning to be seen.
So, how does Rust handle that?
The Result
type
Let’s check what the prototype of Rust’s equivalent to open
is:
fn create<P: AsRef<Path>>(path: P) -> Result<File>
Rust actually provides, in std::fs::File
, two functions, open
and create
, to easily represent opening in O_RDONLY
and O_WRONLY | O_CREAT
modes respectively, which are the most common. It also provides, for more specific cases, the options
function giving you a builder pattern to set your options safely, but our concern here is the result type. For our example, the equivalent is create
.
The return type of create
is Result<File>
. Actually, Rust takes some shortcuts in its standard library, and in this case, uses an alias to remove an often repeated argument. The actual type is std::io::Result<File>
which expands to Result<File, std::io::Error>
. It is an implementation detail, but we need the expanded type here to understand.
A result is an enum
, which can takes a closed amount of values. Unlike C enums, which are just numerical values with labels, Rust enum variants can be simple tagged values as well as more complex structures. Take a closer look at the Result
type definition:
enum Result<T, E> {
Ok(T),
Err(E),
}
The enum Result can take only two values: Ok
, or Err
. Each of these variants are a 1-item tuple, containing an item of type T
or E
from the generic typing. You cannot access the T
value directly, however: you have in some way or another eliminate the possibility of the result being Err
. The most straightforward way would be with match
:
match File::open("foo.bar") {
Ok(f) => { write!(f, "Hello, world!\n"); }
Err(e) => { println!("Error opening file: {}", e); }
}
You cannot work on the file if an error happens, as you will not be able to get a File
value from Err
, needed to do write operations. You have to at least throw the error away, which is then an active choice.
If you put this code in a Rust file and compile it, you will actually get a warning:
warning: unused `Result` that must be used
--> src/main.rs:7:13
|
7 | write!(f, "Hello, world!\n");
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
= note: this `Result` may be an `Err` variant, which should be handled
= note: `#[warn(unused_must_use)]` on by default
The call to write!
is also returning a Result
, and by default, the compiler will warn you if you call something that returns a Result
and you ignore it. This way, it is way harder to accidentally ignore an error case.
Now, you can add another match
block around write!
to check for error at this point, but doing so gets heavy quickly code-wise. Thankfully, Rust provides tools to ease all of that.
Managing errors
Result
provides some useful functions to deal with errors without having to match
all the time. The first and simplest one is unwrap
. Calling it on a Result
containing an Err
will cause a panic!
, crashing the program instantly, and otherwise just returning the T
value. So, using it, we can reduce our code to this:
let mut f = File::open("foo.bar").unwrap();
write!(f, "Hello, world!\n").unwrap();
The code is still rather small, and unlike the C sample above, an error will actually close the program instantly with both an error code and a message printed explaining what happened:
thread 'main' panicked at src/main.rs:5:42:
called `Result::unwrap()` on an `Err` value: Os { code: 13, kind: PermissionDenied, message: "Permission denied" }
However, while very practical, unwrap
is not really known as best practice. It can be useful for small tests or temporary handling, but you really should not rely on it for long-lasting code. Or put it for cases where you are absolutely sure that you cannot have an error.
First of all, it does not give a lot of information. You do have the source file and line, but it can still make it hard to get good information on what happened. Thankfully, unwrap
has a little brother, expect
, which allows to make the error a bit more informative by adjoining a custom message:
let mut f = File::create("foo.bar").expect("Cannot open file");
write!(f, "Hello, world!\n").expect("Cannot write to file");
If you run this code, you will see that the message in expect
is prefixed to the lower error code. In big code bases, this additional context can save a lot of debugging time, and it is barely longer to write.
It is advised to always favor expect
to unwrap
so you have a bit more context in case of failure.
What if, however, you do not want to treat an error as an exit? Take for example reading an environment variable: if it does not exist, I want to continue my program anyway. Well, match
still works:
let var = match std::env::var("MY_VAR") {
Ok(v) => v,
Err(_) => "foo".to_string(),
};
You can see we completely ignore the error in this case. However, there are shorter ways! Result
provides unwrap_or
and some other functions for these use cases.
let var = std::env::var("MY_VAR").unwrap_or("foo".to_string());
Note that in this case, the value given to unwrap_or
is always calculated to be passed to the function, which is OK for trivial values, but not for expensive to calculate functions or stuff that has side effects. In that case, you have unwrap_or_else
which takes a lambda, executed only if the Err
case is encountered:
let var = std::env::var("MY_VAR").unwrap_or_else(|| "foo".to_string());
In this last example, the "foo"
string copy is allocated only if the environment variable is not found, while on the previous one, it was always allocated, then wasted if you had a Ok
.
Also, if your T
type implements Default
, you can just use unwrap_or_default
. If an empty string is OK for you:
let var = std::env::var("MY_VAR").unwrap_or_default();
And that is only scratching the surface.
map
andmap_err
allow you to call a mapping function inOk
andErr
cases respectively, allowing you to change the types of yourResult
without deconstructing it.or
andand
allow you to combine multiple results into a single one based on their respective values. Their counterpartor_else
andand_then
do the same but with a lambda called in the appropriate case. It allows transformation likemap
andmap_err
but fallible.is_ok
andis_err
are simple methods to check if you have aOk
orErr
without retrieving their inner values. Their siblings,is_ok_and
andis_err_and
, allows to do the same and perform an additional check on the contained value while not restructuring theResult
.
And some more that I did not describe for some more use cases you could have. You can check Result
’s documentation to see all the available methods and a complete description for each.
Going back up
What if I told you that Rust makes it easy to make errors bubble up your stack, and making writing your program to exit gracefully from its main
even in deep error cases much easier?
Let us take our example back, and put it in a separate function returning a Result
:
use std::fs::File;
use std::io::Write;
fn say_hello() -> Result<(), std::io::Error> {
let mut f = match File::create("foo.bar") {
Ok(f) => f,
Err(e) => return Err(e),
};
match write!(f, "Hello, world!\n") {
Ok(_) => Ok(()),
Err(e) => return Err(e),
}
}
fn main() {
say_hello().unwrap();
}
Too verbose. Thankfully, we can do shorter. First, the second match
is completely useless, we can just return the Result
from write!
directly. Easy.
The File::create
part however is a bit more tricky, as we need to do an early return. Well, Rust provides an operator for that: ?
. It is basically the match
seen above, but compacted into a single character. So our code above now reads:
fn say_hello() -> Result<(), std::io::Error> {
let mut f = File::create("foo.bar")?;
write!(f, "Hello, world!\n")
}
Much shorter, but still handing every error case and passing it up in the stack. Also, like in C++ for example, objects with destructors (Drop
trait) will be destroyed with the early return
, clearing resources on the way.
The unwrap
in main
handles all errors coming from the say_hello
function in a single place. But that still triggers a panic!
. You can also match
at this level to print the error cleanly. But Rust has another small trick up its sleeve to ease that too: you are perfectly allowed to make main
return a Result
, and it will automatically take care of printing the error and exiting with an error status code for you.
fn main() -> Result<(), std::io::Error> {
say_hello()
}
Voilà, you have complete error management in your program without having to write much. So while Rust prevents you from just throwing errors away, it also makes it easy to handle them properly.
However, there are rare cases where you do not actually care about a result value, neither its success nor error value. If you just do your call, the compiler will complain that you have an unused Result
(rightfully), and once again, you have to actively tell it you do not actually want to use the value. There are two ways of doing that: either you assign it to a _
variable, dropping it instantly, or you use ok
to transform it to an Option
which will clear the warning.
let _ = say_hello();
say_hello().ok();
I prefer the second solution personally as it makes it more visible you are “OK’ing” an error.
Multiple error types
You have used another function which has a different error type, and you do not know how to manage it. For example:
fn main() -> Result<(), std::io::Error> {
let n = std::env::var("FOOBAR")?;
let mut f = File::open(n)?;
write!(f, "foobar")
}
While std::env::var
does return a Result
, its error part is std::env::VarError
, which is not compatible with std::io::Error
. The compiler will argue that it cannot do the conversion from one to another. We need to manage this.
Thankfully, error types are (normally) implementing the std::error::Error
trait. We can use that to have a generic error return, using a Box
. Let us modify the code a bit:
fn main() -> Result<(), Box<dyn std::error::Error + 'static>> {
let n = std::env::var("FOOBAR")?;
let mut f = File::open(n)?;
write!(f, "foobar")
}
Note that this still does not compile, but we will get back to this later. The error moved to the write!
call, meaning it solved the issue for the ?
calls. You can notice that you do not have to do any conversion using the ?
. The ?
operator will automatically call .into()
on the error value before returning it, and there is a From
conversion to automatically convert any implementor of Error
into a Box<dyn Error>
for convenience. So as long as you have a compatible type, this will work automagically.
For the remaining error, though, we do not have this automatic conversion. Amusingly, the compiler proposes the following solution:
Ok(write!(f, "foobar")?)
It certainly works: if there is an error, it does the early return automatically, otherwise it extracts the Ok
value and puts it in a new Ok
with the right type. Certainly works but, in my option, a bit silly. You can just make the into
call by hand:
write!(f, "foobar").map_err(Into::into)
It is a bit longer to write though, but personally I find it clearer to have into
written rather than a wild Ok
which does not hint at the error conversion happening.
Note however that while using dyn Error
is very practical, it can be more adapted to make your own error type and provide conversions from the “lower” types into yours that you use everywhere, especially for libraries. We will see how to do that in a few paragraphs.
Making your own errors
Sometimes, you want to emit errors on your own. Rust also makes you able to do that, with varying degrees of complexity depending on your needs. The most simple is to use basic strings. Rust provides ways of turning strings into full-fledged errors for your convenience.
fn main() -> Result<(), Box<dyn std::error::Error + 'static>> {
Err("Something's wrong".into())
}
Once again, a From
implementation makes the conversion using into()
trivial. Actually, there are From
implementation for &str
, String
and even Cow
, so you are covered in all string cases.
Since our type supports into()
, it means it also works with ?
:
fn main() -> Result<(), Box<dyn std::error::Error + 'static>> {
let v: Result<(), &'static str> = Err("Aieee");
println!("{:?}", v?);
Ok(())
}
You can see the different Result
type (on the E
side) between v
and the return of main
: ?
does the conversion automatically.
Since you can then use Result<T, &str>
transparently, it also means that it is easy to make Option::ok_or
or Result::map_err
pretty easily:
fn main() -> Result<(), Box<dyn std::error::Error + 'static>> {
let v: Option<()> = None;
v.ok_or("Ouch")?;
Ok(())
}
Implementing Error
Using strings as error is good for quick programming, but if you want to be more robust, you will prefer to make your own, full-fledged error type. To do that, you need to implement the Error
type from the standard library. This is a very basic one to start with:
use std::fmt;
struct Failure;
impl fmt::Display for Failure {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "Something's wrong")
}
}
impl fmt::Debug for Failure {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "Something's wrong")
}
}
impl std::error::Error for Failure {}
You can use the Failure
value as an error anywhere, even with the automatic conversion to Box<dyn Error>
if needed, or use it as a E
for the return value of main
. It all works seamlessly.
Error
requires you to implement both Display
and Debug
so your error can be inspected: you can use them to display basic or complex information at your will. For reference, the value printed by main
’s return comes from Debug
. You can of course put values in your structure, and use them for the error messages to give more information. You can also use an enum.
Wrapping errors
As you make your own error type, you may want to use it as a wrapper for other types of error that can happen. Let’s broaden our type so it can also represent errors of std::io::Error
type that are common in the standard library.
We can write a From
implementation to make the conversion to our type possible:
impl From<std::io::Error> for Failure {
fn from(_: std::io::Error) -> Failure {
Failure
}
}
And voilà, we can now use ?
to convert from std::io::Error
to Failure
automatically and use our own type everywhere. However, we are wasting precious information that we could keep. Let us modify our type so it can represent both our own emitted errors and the I/O errors while keeping information, leveraging an enum.
enum Failure {
Own,
Io(std::io::Error),
}
impl From<std::io::Error> for Failure {
fn from(inner: std::io::Error) -> Failure {
Failure::Io(inner)
}
}
However, we are still not using this information (the compiler will gently inform you that you never use your field), and the error message on runtime is not any more informative. Naturally, we can just modify our printing functions to do so:
impl fmt::Debug for Failure {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
Failure::Own => write!(f, "Something's wrong"),
Failure::Io(e) => write!(f, "IO Error: {:?}", e),
}
}
}
If you try it now, you will see that you can see the inner error. But there is a better way to do so. Rust planned that errors can bubble up and generalize along the way, and provides a mechanism specifically to represent that. In the Error
trait, there is an optional method to implement, source
, made explicitly to give a source error to the current one, cascading down. Let us implement that:
impl std::error::Error for Failure {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Failure::Own => None,
Failure::Io(inner) => Some(inner),
}
}
}
Note however that Rust’s implementation of the main
return does not use this information (it only relies on Debug
implementation), but some other libraries managing errors do take it into account. You can also use it if you write your own generic error handler.
Wrapping up
We have seen some important points about error handling in Rust:
- It is conceived to make it impossible to accidentally ignore an error case. The
Result
type, one of the most important in the language, ensures that you deal with the error appropriately, or at least make by yourself the active choice of ignoring an error instead of silently dropping it. Furthermore, it gives a unified interface for error management, with different types for the return of a success and an error, removing the need for special values like-1
, things likeerrno
or a writable pointer passed to a function to get an error. - While Rust does not have throwable exceptions, it provides an easy way to bubble errors up your stack and get them to elegantly be managed at the right place. The
?
operator is a cornerstone of Rust code, allowing efficient and non-verbose management of all error sources. - It is trivial to create your own error types that integrate into the Rust way of doing things, making managing your custom errors and adding context or generalization while bubbling up trivial.
All of this is one of the most basic foundations of Rust and what makes code written in it more robust. Even if you willingly ignore an error with unwrap
, it will not silently fail with weird side effects but actually crash your program, and finding uses of these methods across a codebase is easy, compared to having to check that every call to open
is followed by a if (fd == -1)
.