For your eyes only
(photo by Jeff Jackowski, licensed under Creative Commons)
Keyboard not found. Press F1 to continue.
—Error message
“It doesn’t work” is the least helpful bug report you could ever get, because it tells you something’s wrong, but not what. And that goes both ways: when our programs report errors to users, they need to say more than just something like “error” or ”failed”.
Oddly enough, though, most programmers don’t give a great deal of thought to error messages, or how they’re presented to users. Worse, they often don’t even anticipate that an error could happen, and so the program does something even worse than printing a meaningless error: it prints nothing at all.
In Things fall apart, we realised that our line-counting program doesn’t cope well with errors reading its input: they make it hang forever. Instead, we’d like the program to stop and report the error, so that the user can try something different.
Handling errors in
count_lines
Here’s the count_lines
function we have right now:
use std::io::{BufRead, Result};
pub fn count_lines(input: impl BufRead) -> Result<usize> {
Ok(input.lines().count())
}
Clearly we can’t always return Ok()
from
count_lines
, because it’s not always okay.
Sometimes the result yielded by lines
will be an error, in
which case we need to stop counting and return it.
One way to do that is to unroll the iterator expression into a
for
loop, like this:
pub fn count_lines(input: impl BufRead) -> Result<usize> {
let mut count = 0;
for line in input.lines() {
?;
line+= 1;
count }
Ok(count)
}
If the result yielded by lines()
is an error,
we’ll catch it using the question mark operator (?
):
?; line
That is, if line
is the Err
variant, the
function returns it immediately. Otherwise, this statement has no
effect, and we move on to increment the counter and continue the
loop.
This passes the test that simulates a read error, so let’s see if the
real program now also behaves correctly. We’ll need to update
main
for the new signature of count_lines
,
because right now we have a compile error:
`std::result::Result<usize, std::io::Error>` doesn't implement
`std::fmt::Display`
In other words, we tried to print the return value of
count_lines
using the println!
macro, which
uses the Display
trait to ask the value how it should be
formatted as a string. Result
doesn’t implement
Display
, because there isn’t really a sensible
default way to print a Result
, so Rust requires us to
implement the trait manually if that’s what we want to do.
Displaying results to users
That seems like overkill for this simple program, so let’s just use
the ?
formatting parameter for now, which will give us the
“programmer’s view” of the value, using the Debug
trait:
fn main() {
let lines = count_lines(stdin().lock());
println!("{lines:?} lines");
}
What does the output look like when there’s no error? Let’s see:
cargo run <src/main.rs
Ok(8) lines
Hmm. We really want just the number, not the surrounding
Ok()
, but let’s come back to that. Right now we’re
interested in what users see when an error happens, so let’s trigger
one:
cargo run <.
Err(Os { code: 21, kind: IsADirectory, message: "Is a directory" })
A bit wonky, but this is the debug format, which is intended for the programmer’s eyes only, so that’s fine.
A slightly nicer presentation
Let’s go ahead and spare the user the more excruciating details:
fn main() {
let res = count_lines(stdin().lock());
match res {
Ok(lines) => println!("{lines} lines"),
Err(e) => println!("{e}"),
}
}
Unlike Result
, Err
is
Display
, meaning it knows how to format itself in a
(relatively) user-friendly way. Here’s the output:
Is a directory (os error 21)
That’s good enough. It tells the user what’s wrong, without too much wonky syntax or detail: “Hey, it looks like you’re trying to count lines in a directory, and that doesn’t really make sense. Try something else.”
In my book The Secrets of Rust: Tools, we’ll pursue this idea further, learning how to write Rust programs that are not only robust and durable, but also friendly, useful, and flexible. The first step in that direction is knowing when to expect errors, and the second is figuring out what to do when they happen.
Standard error, and exit status
Let’s add one more refinement before we move on. It’s a useful convention for command-line programs to print their error messages to the standard error stream, not the standard output. That way, if the normal output is being redirected to some file, error messages will still go to the user’s terminal, not the file.
Also, when a program fails for some reason, it’s helpful to set the exit status to some non-zero value, indicating an error. If the program is being used in a shell script, for example, the abnormal exit status will stop the script, instead of having it continue with missing data.
So let’s make those changes to the match
arm for the
error case:
use std::{io::stdin, process};
use count::count_lines;
fn main() {
let res = count_lines(stdin().lock());
match res {
Ok(lines) => println!("{lines} lines"),
Err(e) => {
eprintln!("{e}");
process::exit(1);
}
}
}
eprintln!
is exactly like println!
, as you
might have guessed, except that it writes to standard error.
process::exit
causes the program to exit immediately, with
the given status value, instead of waiting until it reaches the end of
main
.
We’ll run the command again to see if that works:
cargo run <.
Is a directory (os error 21)
echo $?
1
The shell variable $?
contains the exit status of the
last program you ran, so we can see that the line counter is doing the
right thing here. Now we can detect problems automatically when running
the program in a shell script:
cargo run <. || echo "Whoops, something went wrong"
Is a directory (os error 21)
Whoops, something went wrong
And any other kind of I/O error we encounter when reading input should also trigger the same behaviour: stop counting, print a friendly message, and exit with status 1. Very nice!
Resilience matters
You might think this is a lot of fussing for something that’s pretty unlikely to happen in real life. But, in a sense, error handling is the most important part of any program. Anyone can write the happy path! It’s what the program does when something weird, unexpected, or awkward happens that really distinguishes well-engineered software from janky hacks.
And we can’t imagine all the possible errors that could happen, so we’ve no business deriving a probability distribution for them. The best plan is to assume that, sooner or later, anything that can go wrong will go wrong, and when our software is used at scale, that will assuredly be the case.
Handling invalid input
For example, the lines
iterator yields Rust strings,
which are guaranteed to be valid UTF-8. But our input is just a
bunch of raw, undisciplined bytes that we picked up on the street
(“Don’t put those bytes in your mouth! You don’t know where they’ve
been!”)
So what would happen if the user fed the program some text that isn’t UTF-8, or even some random binary file that isn’t any kind of text at all? Will it hang, crash horribly, or produce the wrong result?
We’d like to think that Rust has our back here. If the
BufReader
can’t parse the input bytes as UTF-8-encoded
text, we feel it should yield Err
. And if that’s the case,
then our program should already handle that situation correctly, and
terminate with an informative error message. Let’s see what happens when
we pipe in a bogus byte, then:
echo '\x80' | cargo run
stream did not contain valid UTF-8
That’s a win for Rust, and a win for us! Even though we didn’t envisage this specific error situation when writing the code, we nonetheless assumed that errors of some kind are bound to happen, and allowed for them. We’ve made a good start on writing durable software.