Writing terrible code
There is an art to designing just enough to get feedback, and then using that feedback to improve the design enough to get the next round of feedback.
—Kent Beck, “Extreme Programming Explained”
The secret of being a great coder is to write terrible code. Wait, wait. Let me back up a little. How do we know if code is good, anyway? Surely one of the most important signs is that it’s correct. If your code does the wrong thing, practically nothing else about it matters.
That’s why it’s very helpful to write tests. A good test tells you that your function is correct, but a great test tells you when your function is wrong. And the only way to know if it will do that is to write the wrong function. Let’s do just that.
In The magic function, we wrote
this test for an imaginary count_lines
function in Rust
that, yes, counts lines in its input:
use std::io::Cursor;
#[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");
}
Even if you don’t know Rust, I bet you can see what this is testing.
We create something called a Cursor
, which simulates input
to the program, and we put a string containing two lines into it.
Calling count_lines
with this input should return the
answer 2, shouldn’t it? And that’s exactly what the test asserts.
More importantly, any other answer should cause the test to
fail, and because we’re sceptical by nature, we want to see that
happening. So, to verify the test, let’s go ahead and write a
simple-minded version of count_lines
that deliberately
returns the wrong answer: zero, for instance.
In order to write the function at all, though, we have to work out what its signature is: what type of parameter it takes, and what it returns:
pub fn count_lines(input: ???) -> ???
So what type should count_lines
take as a parameter?
Well, in the test, we’re passing it a Cursor
, but that
won’t be the case in the real program. We want the tool to read from its
standard input:
use std::io::stdin;
use count::count_lines;
fn main() {
let lines = count_lines(stdin().lock());
println!("{lines} lines");
}
So in main
, we’re passing the result of calling
stdin().lock()
, which is a type called, perhaps not
surprisingly, StdinLock
. So which parameter type should we
declare: Cursor
or StdinLock
?
Neither, because we need it to work with both. So we’ll have to be a
bit more general. In Rust we can often use a trait to identify the set
of types that we can accept, based on what we want to do with
the object. So, what do we need to do with input
?
In this case, we want to read from it, so the trait in question would
be BufRead
: both StdinLock
and
Cursor
implement this.
If we declare input
to be of type
impl BufRead
(that is, “anything that implements
BufRead
”), then we can accept a Cursor
,
and a StdinLock
, and anything else we could read
from:
use std::io::BufRead;
pub fn count_lines(input: impl BufRead) -> usize {
0
}
For the return value, usize
makes sense: it’s the right
choice whenever we’re talking about some number of things, such
as lines.
And the function ignores input
completely and always
just returns zero, which is what we said we wanted to do in order to
check that the test fails. Well, let’s see!
cargo test
assertion `left == right` failed: wrong line count
left: 0
right: 2
Great. That’s exactly what we wanted to see, and if we hadn’t seen it, we would have needed to fix the test until we did. But now we can move on and write the real implementation—or rather, you can. Time for another challenge!
GOAL: Modify count_lines
so that it
passes this test. Make sure it works with the main
function
as well.
HINT: Okay, it’s not easy, but I’m not some inhuman
monster. I’ve given you all the clues you need to put this puzzle
together. You already have the right signature for
count_lines
, so it’s just the function body you need to
change.
You have the code for counting lines, too; you can steal that from
the old main
function in Listing
count_1
. All great programmers copy and paste. Why
waste your brainpower on things that are already solved?
SOLUTION: Here’s my version:
use std::io::BufRead;
pub fn count_lines(input: impl BufRead) -> usize {
.lines().count()
input}
How boring! But that’s exactly what we want, or at least it’s what I want: simple, straightforward, and obvious. No fancy stuff. In my book The Secrets of Rust: Tools, you’ll learn more about the power of this approach, by developing some useful programs that are, nonetheless, charmingly simple. All we have to do is focus on writing great tests, and then passing them with as little fuss as possible.
And if your solution passes the test, it’s correct, even if it looks
different to mine. Let’s see if this also works when we run the
main
function as a binary:
echo hello | cargo run
1 lines
We’d be very worried if it had said anything else! So, now that we have a working library crate with a (relatively) user-friendly API, let’s indulge ourselves with a little tidying up and beautifying; “making good”, as the decorators say.
One thing we can do to make our library more Rustaceous is to move
our unit tests (well, test) into a module. A module is simply
Rust’s way of grouping related code into a little sub-unit, using the
mod
keyword, so that it can be turned on or off at
will.
For example, we only need to compile the test function when we’re
running tests. When we build or run the binary with
cargo run
, why waste time compiling a function we’re not
going to call?
The conventional answer to this in Rust is to put our unit tests
inside a module (mod tests
, let’s say, though the name is
up to us), and then protect that module with a cfg(test)
attribute:
#[cfg(test)]
mod tests {
// tests go here
}
cfg
turns compilation on or off for the item it’s
attached to, based on the value of some expression.
cfg(test)
means the tests
module will be
compiled only if we’re running in cargo test
mode.
Otherwise it will be ignored entirely, saving time, energy, and the
planet.
So here’s what our test looks like once we’ve moved it into its own module:
#[cfg(test)]
mod tests {
use std::io::Cursor;
use super::*;
#[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");
}
}
A module can have its own use
declarations, which is
handy since we often want to use
things in tests that we
don’t need in the library itself (Cursor
, for example).
Also, a module doesn’t automatically use
(that is, have
access to) things defined in its parent module. That’s what the
use super
declaration is about. super
always
refers to the parent module, and since tests usually want access to
everything in the parent, use super::*
brings in
the whole lot at once.
It’s all very well to test the happy path—what happens when everything goes right—and, indeed, you’ve done so beautifully. Give yourself a pat on the back. But in the next post, we’ll talk about what to do when things go wrong, which, as you’re well aware, they always do. See you then!