Elegance in the Absence of Complexity — A Rust Example
I feel as though I should start this post off with a disclaimer — I am a Rust noob. I’m still working my way through its arguably steep learning curve. I’ve written books on Go and C# and I’ve put apps and services into production in C, C++, Pascal, Delphi, Go, Java, Scala, JavaScript, and probably a half dozen I’m forgetting and I think Rust’s learning curve has been one of the most challenging for me.
I am passionate about clean code. I feel that code needs to be clean, readable, and succinct. This is as important to the successful production deployment of an application as the right choice of technology and architecture. One area of code cleanliness that I often find starts out as merely “left behind” and becomes a massive mound of fly-ridden stagnation after months of development is error handling.
In languages where it’s a best practice to use a try/catch block, you often see methods wrapped in a single catch-all try or, as is fairly common in Java, you’ll see it declare which types of exceptions it throws and then it’s up to the consumer of the method to deal with the exceptions accordingly. Both of these models keep error handling at an arm’s length from the function’s core logic.
In languages like Go that advocate a tuple-like return pattern where functions return a value and an optional error, you end up with methods that look like this (pseudocode):
func doSomething(input AwesomeStruct) (*SuperResult, err error) {
if somethingBad {
return nil, errors.New("foo")
}
if somethingElseBad {
return nil, errors.New("something else")
}
if yetAnotherBad {
return nil, errors.New("fail")
}
// finally do real work!
fantastic := doSomethingElse()
return fantastic, nil
}
For Go programmers this function shape should look very familiar. You have this descending staircase of error checks until you finally get to the point where you can manipulate the return value. This works great, but for a language that embraces the KISS principle as much as Go, the need for this kind of ceremony seems to be begging for a cleaner design.
Now back to Rust — the reason for this post. As I spend more time working with Rust, I am constantly finding incredible bits of elegance in the things that are not there; or rather in the amazingly intricate and subtle complexities happening under the covers that allow for expressive and powerful code while still maintaining that low-level systems feel (and of course data-race and mostly leak free code).
As an example of some ugly code that I wrote recently in Rust to illustrate communicating with a Redis database, here’s a function that creates a collaborative retrospective board that will eventually be home to a bunch of virtual sticky notes:
pub fn create_board(&self, board: &Board) -> Result<Board, String>
{
let con = &self.client.get_connection().unwrap();
let res = match redis::cmd("INCR").arg("id:boards").query(con) {
Ok(newid) => {
let board = Board { id: newid, ..board.clone() };
redis::cmd("SADD").arg("boards").arg(newid).execute(con);
let encoded = serde_json::to_string(&board).unwrap();
redis::cmd("SET")
.arg(format!("board:{}", board.id))
.arg(encoded)
.execute(con);
Ok(board)
}
Err(e) => Err(format!("{}", e)),
};
res
}
This bit of code smells a lot like the same descending staircase of error checking that the Go code did, except it’s worse because there are a couple of places that can cause a fatal error. The first unwrap()
call will fail if acquiring a connection doesn’t work…but it won’t fail gracefully.
Next I’m using a match
to handle potential failure in the query
method, and then I make things even worse by nesting yet another unwrap
inside this match.
In short, this code not only looks ugly, but it pretends like it’s handling errors cleanly when it’s really a mess. This is what happens when n00bs write code.
Further, the Result<Board, String>
return type looks a little kludgy and it’s not idiomatic Rust. Many library authors will create their own module result type that also includes their own module error type. This helps clean up the code, but it also prevents your library’s implementation details from leaking out into your consumer’s code.
pub enum BoardError {
RedisFailure(redis::RedisError),
JsonFailure(serde_json::Error),
}impl From<redis::RedisError> for BoardError {
fn from(err: redis::RedisError) -> BoardError {
BoardError::RedisFailure(err)
}
}impl From<serde_json::Error> for BoardError {
fn from(err: serde_json::Error) -> BoardError {
BoardError::JsonFailure(err)
}
}pub type BoardResult<T> = Result<T, BoardError>;
In this bit of refactoring, I created my own BoardError
type. Next — easily one of my current favorite features of Rust — I implemented the From<T>
trait for my error type. This allows automatic and implicit conversion from the Redis or Serde errors into my error.
Now I can combine this with the use of the question mark operator to simplify my code as follows:
pub fn create_board(&self, board: &Board) -> BoardResult<Board> {
let con = &self.client.get_connection()?;
let newid = redis::cmd("INCR").arg("id:boards").query(con)?;
let board = Board { id: newid, ..board.clone() };
redis::cmd("SADD").arg("boards").arg(newid).execute(con);
let encoded = serde_json::to_string(&board)?;
redis::cmd("SET")
.arg(format!("board:{}", board.id))
.arg(encoded)
.execute(con);
Ok(board)
}
While this looks prettier, it has quite a few truly powerful benefits over my original. First, there are no conditional branches in this code (well, there are, but they’re being dealt with invisibly). This makes is significantly easier to test (and read).
Everywhere I’m using a question mark, there is a contract that an error returned from that function call will immediately return from my function. If query(con)
fails, it will return with a Result<Board, redis::RedisError>
type. Since my new error type has a From<redis::RedisError>
implementation, this conversion happens automatically.
The same goes for a failure to encode my data as JSON. For a further refactor in the near future, I’m going to modify the BoardError
type to not simply encapsulate the third party error types but instead to translate them into my own so I’m not leaking those types out of my module.
While I think we can all agree that perfect is the enemy of done, there are also immeasurable benefits to not simply settling for “good enough” code that merely compiles and passes our tests.
My favorite Japanese word is 枯淡 (Kotan), which means elegant simplicity. One of my takeaways from this learning exercise is that elegant simplicity is achievable in Rust and is a worthy goal.
Clean code doesn’t just make our code prettier, it makes it easier to maintain, easier to test, more reliable, and often more efficient. This early in my journey learning Rust, I am in awe of how it is possible to create clean code while still working under the strict constraints of the borrow checker and lifetime manager.