Things fall apart
(photo by Jeff Jackowski, licensed under Creative Commons)
If you can doubt at points where other people feel no impulse to doubt, then you are making progress.
—Basil Liddell Hart, “Why Don’t We Learn from History?”
Everything that can go wrong will go wrong, as we all know, and that goes double for programmers. With software, everything probably already has gone wrong, but the tests haven’t detected it, because the tests are broken too.
Never mind. We do what we can, and one thing we can do is ensure that our programs expect things to go wrong all the time, and recover gracefully when they do—or, at least, report the problem in a helpful way.
In The magic function, we started
building a little line-counting program in Rust, like the Unix
wc
tool. We imagined some magic function
count_lines
that would do the hard work for us, and we
wrote a simple test for it:
#[test]
fn count_lines_fn_counts_lines_in_input() {
let input = Cursor::new("line 1\nline 2\n");
let lines = count_lines(input);
assert_eq!(lines, 2, "wrong line count");
}
By writing terrible code,
we checked that this really tests something, because we now know it
fails when count_lines
returns the wrong answer. Then we
got the test passing, with the following function that really does count
lines:
pub fn count_lines(input: impl BufRead) -> usize {
.lines().count()
input}
So, do we have a production-ready line counter crate yet? Well, not quite, because in real life (as opposed to programming tutorials) things always seem to go wrong somehow, don’t they?
In my book The Secrets of Rust: Tools, we’ll see that programs can fail in all sorts of ways. Some of them are due to bugs—where the programmer has done something wrong. Others are just due to things going wrong in the environment where the program is running.
Results
These run-time errors are predictable, in the sense that we usually know where they can occur, even if we don’t know exactly when they’ll occur. For example, if we’re trying to open some file, the file might not exist, or we might not have permission to read it. If we’re trying to write to a file, there might be no disk space left. And so on.
As we saw in Rust error
handling is perfect actually, the Result
type conveys a
data value that might exist, or not, because maybe some error happened.
A Result
can be one of two variants: either
Ok(x)
, meaning that we have the value x
, or
Err(e)
, meaning some error e
.
Any time some function returns a Result
, you know that
some kind of error could happen, so it’s wise to be prepared for it.
Readers are fallible
So, what about the Read
trait? Can there be an error
reading from a reader? Let’s have a look:
pub trait Read {
// Required method
fn read(&mut self, buf: &mut [u8]) -> Result<usize>;
// ...
}
Well, of course there can—in our secret hearts, we knew that
anyway. Reading a byte stream from any external source can fail for all
sorts of reasons. But the paperwork confirms it: read
returns a Result<usize>
.
What this means is that if the result is the Ok
variant,
then it will contain a usize
: the number of bytes
successfully read on this call. But if it’s the Err
variant, the error will tell us what went wrong.
How lines
handles
errors
We don’t call read
directly in our
count_lines
function, but we call lines
on
input
, using the BufRead
trait, and
that will call read
to get the data. So you might
be wondering what happens if there is some error from the
underlying reader. What does lines
do in that case?
There are lots of things it could do: for example, it could
panic, or stop iterating, or yield an empty line but keep going, and so
on. None of these seem quite satisfactory. What it actually
does is, I think, exactly right. It doesn’t make any decision at all,
but passes the decision on to us, by yielding a Result
itself:
impl<B: BufRead> Iterator for Lines<B> {
type Item = Result<String>;
// ...
}
In other words, a BufReader
is not an iterator of
lines, but of line results. That is, each item yielded
by lines
is a Result
that either contains the
line, or an error explaining why not.
Does our program handle errors?
So what does our code do when that Result
is
Err
? Let’s take another look:
pub fn count_lines(input: impl BufRead) -> usize {
.lines().count()
input}
Well, “nothing special” is the answer! We just count all the items
yielded by lines
, whether they’re Ok
or
Err
. That doesn’t seem right, does it?
So if there were an error reading the input, it sounds like our program would sit there not printing anything, and never getting anywhere. So, can we test this out? The program reads from standard input, so how could we deliberately trigger an error there?
One way would be to use the shell to pipe something into the program’s standard input. For example, we can pipe the contents of a file, so that the program counts the lines in the file:
cargo run <src/main.rs
8 lines
Fine, that works, but what we want is something that can’t
be read this way, so we can see what the program does on encountering a
read error. Well, depending on your operating system, you usually can’t
read a directory. So attempting to pipe in “.
”, the
shorthand for the current directory, probably won’t work. Let’s try it
with wc
:
wc -l <.
wc: stdin: read: Is a directory
Exactly! That’s what I’m saying. So what does our Rust program do in the same situation? Time to find out:
cargo run <.
(Time passes)
Huh. It’s stuck, which is what we predicted. That’s no good, because
users can’t tell if there’s really a problem, or the program’s just
taking a while, or is waiting for more input. We should be able to
detect the error, print some informative message about it, and stop,
just like wc
does.
Handling errors
Congratulations! We’ve found a bug. Good programmers are always delighted when they find a bug—not because they like bugs, of course, but finding one means there’s now a chance of fixing it. So how should we go about this?
We need to modify the count_lines
function to do
something different if it encounters a read error, but there’s something
even more important we need to do before that. We need to add a
test that proves count_lines
handles errors
correctly.
What’s the point of that, when we already know it doesn’t? Well, that is the point. We knew we’d correctly implemented the “counts lines” behaviour when the “counts lines” test stopped failing and started passing. Similarly, we’ll know we’ve correctly fixed the “handles errors” bug when the “handles errors” test stops failing and starts passing.
A fallible reader
To test this, we want to call count_lines
with something
that implements Read
, but always returns an error. That’s
easy to write:
struct ErrorReader;
impl Read for ErrorReader {
fn read(&mut self, _buf: &mut [u8]) -> Result<usize> {
Err(Error::new(ErrorKind::Other, "oh no"))
}
}
Right? All you need in order to be a reader is to implement the
read
method, and it returns Result
. So we
don’t even need to do any actual reading: we can just return the
Err
variant directly, giving it some Error
made out of a string (“oh no”).
This is useless as a reader, of course: it can’t read
anything. But it’s awfully useful for our test, because as soon as the
lines
iterator tries to read from it, it will blow up, and
our test can check what happens.
To make our useless reader acceptable to count_lines
,
though, we need to wrap it in a BufReader
, so let’s first
import that:
use std::io::BufReader;
And here’s how we use it:
#[test]
fn count_lines_fn_returns_any_read_error() {
let reader = BufReader::new(ErrorReader);
let result = count_lines(reader);
assert!(result.is_err(), "no error returned");
}
If count_lines
returns anything other than
Err
, this test will fail. As a quick thought experiment,
what will happen if we run this test now? Will it fail?
Returning a Result
Well, no, because it doesn’t actually compile yet:
no method named `is_err` found for type `usize`
That’s fair. In order to even see the test fail, we need to
modify count_lines
to return a Result
rather
than a plain old usize
. Let’s do that:
use std::io::{BufRead, Result};
pub fn count_lines(input: impl BufRead) -> Result<usize> {
Ok(input.lines().count())
}
Notice that once we update the function’s return type to
Result<usize>
, we also have to wrap the
count()
iterator expression in Ok()
. That’s
not inferred: we have to explicitly say that the value we’re returning
is Ok
, not Err
.
Now the test compiles, but if our guess is right, it still won’t
fail, because count_lines
just won’t ever return. Let’s
see:
cargo test
(Time passes. Thorin sits down and starts singing about gold.)
All right, then. We’ve reproduced the bug in a test; in the next post, we’ll see if we can figure out how to fix it. Until then, ciao bella, and happy Rusting.