DEV Community

Dimitri Merejkowsky
Dimitri Merejkowsky

Posted on • Originally published at dmerej.info on

Letting the compiler tell you what to do - an example using Rust

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);
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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`

Enter fullscreen mode Exit fullscreen mode

We do what we are told, and add the #[derive(Debug)] annotation:

#[derive(Debug)]
enum ColorWhen {
  // ...
}
Enter fullscreen mode Exit fullscreen mode

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`

Enter fullscreen mode Exit fullscreen mode

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 {

}
Enter fullscreen mode Exit fullscreen mode

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>`

Enter fullscreen mode Exit fullscreen mode

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 },
}
Enter fullscreen mode Exit fullscreen mode

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() })
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Error 4

error[E0599]: no method named `to_string` found
  for type `FooError` in the current scope

Enter fullscreen mode Exit fullscreen mode

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(),
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

It compiles!

Let’s check error handling:

$ cargo run -- --color foobar
error: Invalid value for '--color <color_when>':
  Choose between 'never', 'always', 'auto'
Enter fullscreen mode Exit fullscreen mode

Let’s check with a valid choice:

$ cargo run -- --color never
dry run: false
color: Never
Enter fullscreen mode Exit fullscreen mode

It works!

The default

There’s still a small problem: we did not use a Option for the color_when field, so the --colorcommand line flag is actually required:

$ cargo run
error: The following required arguments were not provided:
    --color <color_when>
Enter fullscreen mode Exit fullscreen mode

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>,
}
Enter fullscreen mode Exit fullscreen mode

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(),
    // ..
  }
Enter fullscreen mode Exit fullscreen mode

The compiler would have told us about our mistake:

ColorWhen::Never => force_no_color(),
^^^^^^^^^^^^^^^^ expected enum `std::option::Option`, found enum `ColorWhen`

Enter fullscreen mode Exit fullscreen mode

And we would have been forced to handle the default value, for instance:

let color_when = color_when.unwrap_or(ColorWhen::Auto);
Enter fullscreen mode Exit fullscreen mode

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());
}
Enter fullscreen mode Exit fullscreen mode

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)

Collapse
 
jeikabu profile image
jeikabu

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 like F(A) -> B and B -> B[] get you there. It's essential in languages with more extreme type inference than Rust, but the general concept is helpful.