The Grass is Always Greener - My Struggles with Rust
A Disclaimer about this Article (Nov 24 2019): Since I've written this stupid article, it's the number 1 read article on my site. Please take everything I've written here with a grain of salt. I still use and like Rust. It's not perfect. No language is perfect. This is just some frustration I've had while trying to adapt my mental model - I'm not trying to say that Rust sucks or is unusable.
Here's the kind of guy I am: I'll come across some awesome product, such as a toaster oven or dish detergent, sing its praises for the next couple of months, discover one minor flaw with it and instantly become discouraged and crestfallen.
The Task
I have a simple Python script at work. It scans our Jenkins server via its wonderful JSON API, looks for any builds on the develop or master branches that failed, and notifies me. I thought its simple and limited scope would make it the perfect candidate for porting it to Rust.
I'd be lying if I said this was even remotely a straightforward process. The very first hurdle I encountered was in trying to do something extraordinarily basic: loading a configuration file from disk. In Python, this can be accomplished via the following:
import configparser
config = ConfigParser()
config.read("config.conf")
For comparisons sake, let's do the same but use the JSON library instead of ConfigParser:
import json
with open("config.json") as f:
contents = f.read()
config = json.loads(contents)
Rust has no configuration module in its standard library, but fear not, there
are many crates that do this job well. I decided to go with the toml
library,
having over 650k downloads and being the first result when I search "toml" on
crates.io.
Reading from a File to a Struct
So the naive approach is to do something like this:
#[macro_use]
extern crate serde_derive;
extern crate toml;
#[derive(Deserialize)]
struct MyConfiguration {
jenkins_host: String,
jenkins_username: String,
jenkins_token: String
}
fn gimme_config(some_filename: &str) -> MyConfiguration {
let mut file = File::open(some_filename).unwrap();
let mut s = String::new();
file.read_to_string(&mut s).unwrap();
let my_config: MyConfiguration = toml::from_str(s).unwrap();
my_config
}
This code took much longer to write than I'd like to admit and I encountered the following issues while writing it:
- Either you litter your code with
unwrap()
's, or you add a ton of boilerplate to your code so you can properly usetry!
/?
. - File must be mutable to be read from. I don't know why, I suppose some
internal state is changed, but even in rewriting it for this post I forgot
to add
mut
. - You can't have
#[macro_use]
outside of your main module file. It took me a few passes to get this code to work in a separate.rs
file.
Thankfully, the Rust linter plugin for VSCode is absolutely fantastic and really made dealing with these errors a lot easier.
Error Handling
So this isn't even the right approach to do this: each of those unwrap()
's may
panic, exiting the program. Cool, well thankfully I know that there's a try!
macro, shorthand ?
, that is used for just this scenario. It will return early
when the result is Err. Let's try it:
fn gimme_config(some_filename: &str) -> Result<MyConfiguration, Error> {
let mut file = File::open(some_filename)?;
...
Wait, Error isn't a object, it's just a trait so it can't be part of a Result. Time to scroll through the documentation and see what a good way to handle this is.
Taken from Rust's chapter on error handling, here's a relevant example:
use std::fs::File;
use std::io::Read;
use std::path::Path;
fn file_double<P: AsRef<Path>>(file_path: P) -> Result<i32, String> {
File::open(file_path)
.map_err(|err| err.to_string())
.and_then(|mut file| {
let mut contents = String::new();
file.read_to_string(&mut contents)
.map_err(|err| err.to_string())
.map(|_| contents)
})
.and_then(|contents| {
contents.trim().parse::<i32>()
.map_err(|err| err.to_string())
})
.map(|n| 2 * n)
}
I'm really trying hard not to be overly dismissive and critical, but this code does not make me happy and I don't think I'm alone on that. Nested, chained, conditional maps are not my idea of good, clean, and clear code.
The book follows up with an actually usable example using early returns, where the pattern goes something like this:
fn do_something_cool() -> Result<i32, String) {
let mut f = match some_function() {
Ok(thing) => thing,
Err(e) => return Err("It went badly".to_string())
}
}
Note: at this point I have no idea when to_string() or to_owned() are used. My usually strategy is to try as_str(), to_str(), as_string(), to_string(), and to_owned(), until the compiler stops complaining.
Coherence
This still does not make use of Rust's try!
macro. I want safe handling of
code, and I want a clear error reasons when something does go wrong. Trying
the try macro:
fn gimme_config(some_filename: &str) -> Result<MyConfiguration, String> {
let mut file = File::open(some_filename)?;
If you try this, you'll get an error: String does not implement the trait
From
You also cannot implement it.
This was something that actually really discouraged me upon learning, but you cannot implement a trait for an object that you did not also create. That's a significant limitation, and I thought that one of the main reason Rust decided to go with Traits and Structs instead of standard classes and inheritance was for this very reason. This limitation is also relevant when you're trying to serialize and deserialize objects for external crates, like a MySQL row. Serde's documentation proposes [this workaround]|(https://serde.rs/newtype-wrapper.html).
Basically the pattern is to create a struct that consists of one element, the struct that you want to implement your trait on. You then implement that trait on your new struct, along with Deref for on the new struct that turns it back in to the original struct.
A Stack Overflow user had this to say in this post:
it is not a hack, it is in fact the only possible method. You can't add trait implementations of traits you don't own to types you don't own, and there is no workaround but newtypes. – Vladimir Matveev
It is possible to be both a hack and the only possible method, and I would argue that most hacks come about because there is no alternative "good" method.
I'm certain there's a reasonable explanation for this rule. It's been explained that it's for the sake of unambiguity: If two crates implement the same trait for an object, then it's ambiguous as to which one to use. But is omitting this feature entirely really the only solution? I am confident that the Rust developers can think of a better pattern than this hack.
Back to Errors
Standing on the shoulders of giants, I decided to take a look at how Elasticsearch-rs handles it's errors. The relevant code is found here:
https://github.com/benashford/rs-es/blob/master/src/error.rs
Despite its verbosity, I actually like this approach quite a bit. You basically create one Error struct for your crate or program, implement a lot of From's for each type you need, and then finally implement Display on it so it can be printed out properly. It's a lot of boilerplate, especially if I plan on replacing my small Python scripts with Rust programs, but it's genuinely clean code and it ticks a lot of boxes with me.
Side note: there will never be a day where i can remember off the top of my head how to implement Display as it stands right now.
In Conclusion
I never ended up porting the script and kept the 20 line Python code as is. In addition to the error and coherence walls, I thought Serde's code generation was more magic than I was willing to take; I'd much rather just write a simple deserializer, but that approach seems to be discouraged. I sort of feel like a kid who just bought a new Buzz Lightyear toy and found out that the wings don't actually spring out like it does in the movie.
And I know a lot of this is my own incorrect expectations. Python is a garbage collected, interpreted language that uses exceptions with base classes for its error handling. It has made drastically different design decisions and is a cripting language. I'm sure writing the equivalent code in C wouldn't be a picnic either. This exercise was helpful and I have to step back and ask, at least for me, if Rust is productive. I have a long ways to go before I understand idiomatic Rust code, and its philosophy behind everything.
I am excited about Rust's ergonomics initiative and am curious to see what good may come of it.