Originally published on my blog.
If you’ve ever written code in a compiled language (C, C++, Java, …), you are probably used to compiler error messages, and you may think there are only here to prevent you from making mistakes.
Well, sometimes you can also use compiler error messages to design and implement new features. Let me show you with a simple command-line program written in Rust.
An example
Here’s the code we have written so far:
use structopt::StructOpt;
#[derive(Debug, StructOpt)]
struct Opt {
#[structopt(long = "--dry-run")]
dry_run: bool,
}
fn main() {
let opt = Opt::from_args();
let dry_run = opt.dry_run;
println!("dry run: {}", dry_run);
}
We implemented a --dry-run
option using the structopt crate.
Now we want to add a --color
option that can have the following values: never
, always
, and auto
.
But structopt (nor clap, which it is based on) does not have the concept of “choice”, like argparse
or docopt
.
So we pretend it does and we write:
enum ColorWhen {
Always,
Never,
Auto,
}
#[derive(Debug, StructOpt)]
struct Opt {
#[structopt(long = "--dry-run")]
dry_run: bool,
#[structopt(
long = "--color",
help = "Whether to enable colorful output."
)]
color_when: ColorWhen,
}
fn main() {
let opt = Opt::from_args();
let dry_run = opt.dry_run;
let color_when = opt.color_when;
println!("dry run: {}", dry_run);
println!("color: {}", color_when);
}
Note: this is sometimes called “programming by wishful thinking” and can be used in various situations.
Anyway, we try and compile this code and are faced with a bunch of compiler errors.
And that’s where the magic starts. We are going to make this work without changing the way structopt works and by only reading and fixing compiler errors, one by one. Ready? Let’s go!
Error 1
color_when: ColorWhen,
| ^^^^^^^^^^^^^^^^^^^^^ `ColorWhen` cannot be formatted using `{:?}`
|
= help: the trait `std::fmt::Debug` is not implemented for `ColorWhen`
= note: add `#[derive(Debug)]` or manually implement `std::fmt::Debug`
We do what we are told, and add the #[derive(Debug)]
annotation:
#[derive(Debug)]
enum ColorWhen {
// ...
}
Well, that what easy. Let’s move on to the next error.
Error 2
| #[derive(StructOpt)]
| ^^^^^^^^^ the trait `std::str::FromStr` is not implemented for `ColorWhen`
The compiler tells us it does not know how to convert the command line argument (a string) into the enum.
We don’t really remember what the FromStr
trait contains. We could look up the documentation, but we can also write an empty implementation and see what happens:
impl std::str::FromStr for ColorWhen {
}
Error 3
Again, the compiler tells us what to do:
not all trait items implemented, missing: `Err`, `from_str`
--> src/main.rs:10:1
|
10 | impl std::str::FromStr for ColorWhen {}
missing `Err`, `from_str` in implementation
note: `Err` from trait: `type Err;`
note: `from_str` from trait:
`fn(&str) -> std::result::Result<Self, <Self as std::str::FromStr>::Err>`
We need an associated type Err
, and a from_str()
function.
Let’s start with the Err type. We’ll need to tell the user about the invalid --color
option, so let’s use an enum with a InvalidArgs
struct containing a description:
#[derive(Debug)]
enum FooError {
InvalidArgs { details: String },
}
Note how the compiler almost “forced” us to have our own error type, which is a very good practice!
Anyway, along with the from_str
function.
impl std::str::FromStr for ColorWhen {
type Err = FooError;
fn from_str(s: &str) -> Result<ColorWhen, FooError> {
match s {
"always" => Ok(ColorWhen::Always),
"auto" => Ok(ColorWhen::Auto),
"never" => Ok(ColorWhen::Never),
_ => {
let details = "Choose between 'never', 'always', 'auto'";
Err(FooError::InvalidArgs { details: details.to_string() })
}
}
}
}
Error 4
error[E0599]: no method named `to_string` found
for type `FooError` in the current scope
All custom error types should be convertible to strings, so let’s implement that:
impl std::string::ToString for FooError {
fn to_string(&self) -> String {
match self {
FooError::InvalidArgs { details } => details.to_string(),
}
}
}
It compiles!
Let’s check error handling:
$ cargo run -- --color foobar
error: Invalid value for '--color <color_when>':
Choose between 'never', 'always', 'auto'
Let’s check with a valid choice:
$ cargo run -- --color never
dry run: false
color: Never
It works!
The default
There’s still a small problem: we did not use a Option
for the color_when
field, so the --color
command line flag is actually required:
$ cargo run
error: The following required arguments were not provided:
--color <color_when>
Can’t blame Rust there. That’s our fault for not having used an optional ColorWhen field in the first place.
Let’s try and fix that by using an Option<>
instead:
// ...
struct Opt {
// ...
#[structopt(
long = "--color",
help = "Wether to enable colorful output"
)]
color_when: Option<ColorWhen>,
}
Well, since we did not do anything with the opt.color_when
but print it, everything still works :)
Error 5
Had we tried to use the option directly like this:
fn force_no_color() {
// ...
}
fn main() {
let color_when = opt.color_when;
match color_when {
ColorWhen::Never => force_no_color(),
// ..
}
The compiler would have told us about our mistake:
ColorWhen::Never => force_no_color(),
^^^^^^^^^^^^^^^^ expected enum `std::option::Option`, found enum `ColorWhen`
And we would have been forced to handle the default value, for instance:
let color_when = color_when.unwrap_or(ColorWhen::Auto);
Side note
There’s an other cool trick we can use to achieve the same result, by leveraging the default
trait:
#[derive(Debug)]
enum ColorWhen {
Always,
Never,
Auto,
}
impl std::default::Default for ColorWhen {
fn default() -> Self {
ColorWhen::Auto
}
}
fn main {
let color_when = opt.color_when.unwrap_or(ColorWhen::default());
}
The code is a bit longer but I find it more readable and more “intention revealing”.
(End of side note)
Conclusion
I hope this gave you new insights about what a good compiler can do, or at least a feel of what writing Rust looks like.
I call this new workflow “compiler-driven development” and I find it nicely complements other well-known workflows like TDD.
Final note: to be honest we could have achieved better results by reading the documentation too: for instance, we could have used a custom string parser instead of the FromStr
boilerplate, and implemented the Display trait on our custom error instead. Good docs matter too …
Cheers!
I'd love to hear what you have to say, so please feel free to leave a comment below, or check out my contact page for more ways to get in touch with me.
Top comments (1)
Having used a slew of other languages, have to say I'm often impressed with the Rust compiler. It's invaluable navigating some of the stickier areas of the language. When I can't make sense of the errors the first course of action is simplifying the code (e.g. breaking down into more statements, reducing indirection, etc.) to get sensible feedback.
Unrelated but in the same vein was something I originally read in an F# article of "using the types to guide you" when looking for solutions. Can't find it now but it was essentially talking about if you need
A -> B[]
then function prototypes likeF(A) -> B
andB -> B[]
get you there. It's essential in languages with more extreme type inference than Rust, but the general concept is helpful.