Skip to main content

I rewrote it in Rust 🦀

Over the the last months, I’ve been learning Go through a hobby project called Laebel.1 I recently reached a point where it was more or less finished. So what should I do?

I decided to rewrite it in Rust!2

Full of enthusiasm #

I’ve been fascinated by Rust since I first heard about the language. I like strong typing, so the idea of making the compiler also verify memory safety seemed like an intriguing idea.

Since then, I’ve read a lot about Rust. I know that many people love it, but that it has a reputation for being hard to master. (Especially the “borrow checker” whose job it is to actually ensure that memory safety.)

A happy and enthusiastic crab.

Since I had written the system in Go already, I figured I could do a straight rewrite. No requirements changed, no wheels reinvented. Just translate the existing Go code into corresponding idiomatic Rust code.

Equipped with the Rust book and a large portion of optimism, I jumped in!

First impressions #

These are some of the things I noticed early on.

  • It was easy to get started with Rust and Cargo. In particular, I liked Cargo’s simple TOML-based configuration and intuitive directory structure.3
  • There are many language features that I really like, especially with Go fresh in mind. I like that if and match are expressions that produce a value. I like the commonly used Result type for error handling. I liked having map at my disposal again (though its friends iter and collect came along, uninvited.) And I thought it was pretty neat that Rust looks both backwards and forwards in scope to infer the type of a variable.
  • Rust looks more complex than either Go, Kotlin, or TypeScript. There are more “odd symbols” littered throughout the code, such as !, ?, ::, ;, &, and ->.
  • Semicolons! Since they are optional in both Kotlin and TypeScript, I had all but forgotten about that key on my keyboard. But at least the rule was clear; semicolon means statement, and no semicolon means expression. I can live with that.
  • Rust likes to put things inside other things. Would you like some Future with that lovely Result<Option<Vec<T>>> type you’ve got there?
  • A minor surprise was that the Rust book encouraged shadowing. I’ve generally considered it a bad practice. I much prefer Kotlin’s approach with “smart casts” or TypeScript’s narrowing and type guards.

Much to learn #

With Go, I felt like I could trial-and-error my way through the language. While I used the documentation to learn about the APIs, I rarely had to pick it up to understand the concepts involved.

With Rust, I need to read the documentation all the time! There were several things that contributed to this.

  • Working with manual memory management took a lot of time to adapt to after so many years in garbage collected languages. In Rust, the compiler “gently nudges” you into memory safe code. And a lot of nudging was necessary! I can only imagine the horribly broken code I would have written in C without those compiler checks.
  • Rust has many concepts that really are not very “guessable”. There’s no guessing my way from the difference between String to &str or from io::Error to Box<dyn Error>.
  • Rust has more “dimensions” to think about while coding. Sooner or later, you need to understand borrowing, lifetimes, mutability, and much more.

As an example, it took me a long time to figure out how to get a reference to my templating library handlebars into my axum routing handlers.

What I intuitively wanted to write was something like:

let templating = initialize_templating();
let router = Router::new()
	.route("/", get(|| handle_route(&templating, "index")));
fn handle_route(templating: &Handlebars, template: &str) {
	 // ...
}

But that would have been too easy. What I ended up with was:

let templating = Arc::new(initialize_templating());
let index_handler = {
    let templating = Arc::clone(&templating);
    move || handle_route(templating, "index")
};
let router = Router::new().route("/", get(index_handler));
fn handle_route(templating: Arc<Handlebars<'_>>, template: &str) {
	 // ...
}

When I finally got this code to run I was pretty tired. I learned a lot about borrowing and lifetimes, so something good came out of it, but I would have preferred not having to.

Rust does not play as nicely with Docker #

I originally chose Go for this project because it needed to integrate with Docker, and Docker itself is written in Go.

Therefore, one of the first tasks during this rewrite was to find a crate for communicating with Docker. I was a bit worried that it could be hard to find a good library, but I found bollard and it has been working very well for me.

However, Rust itself did not seem as Docker-friendly. For example, it is common in a Dockerfile to separate downloading dependencies and compiling the source code. That way we can rely on Docker’s layer mechanism to avoid downloading dependencies again unless Cargo.toml (or Cargo.lock) has changed. This proved to be “impossible” with Cargo. It refuses to download the dependencies from a Cargo.toml file unless it also has access to the source code (which I did not want to give to it, as it would ruin the Docker layer caching). So I had to resort to hacky workarounds like creating a “dummy” project to get fast Docker rebuilds.

On a similar note, the server I produced did not seem to handle signals out of the box. (Not sure if this is on the Rust runtime or the axum HTTP server crate that I used.) I had to add tini to get my Docker container to response to SIGTERM and other signals.

Am I still having fun? #

An unhappy crab weighed down the borrow checker.

I started out this rewrite with a lot of enthusiasm. I expected that I would like Rust and have good time learning new things. And it started out a lot like that. But honestly, the further along my rewrote came it started feeling more and more like a chore.

I slowly managed to translate the project to Rust, but I wasn’t enjoying it as much as I hoped. Maybe because it was a plain rewrite with little product development? Maybe. But I’ve enjoyed similar exercises before, so I’m not sure that is the full explanation.

Perhaps I haven’t spent enough time with Rust to truly understand it? I’m nowhere near fluent yet, so there is probably some truth to that. But again, I’m not sure that explains it completely.

I think that Rust maybe is a little bit too concerned with the low-level details for my taste. While I understand the theoretical beauty of it, I’m more interested in building a product than figuring out how to convince the borrow checker to accept my code. I also don’t really write the kind of code where that level of detail is truly necessary, so maybe I’m not in the target audience?

I also cannot help wonder whether there will come a “second-generation” memory safe language, that builds on Rust but makes it more powerful and convenient. Given the need for Box, Arc, Rc, RefCell, Cow, etc, it seems the borrowing abstraction does not pull the full weight.

In the end, I think I will go back to the Go codebase for this project. I have learned a lot during this rewrite, but I feel Go it is a better match for the work I’m doing. It had all the tools that I needed, with very little standing in the way of building the product. It allowed me to make changer quicker, and simply was more fun!

Anatol at : I've checked out the handlebars source a bit and while I wouldn't call it *bad* really, it's rather, uh, "Rust feature-heavy" (so many macros...). I'm sure they are getting great performance out of it, but it comes at a heavy price of ergonomics and readability - personally I would've tried to get away with less lifetimes and more things on the heap.

Updates #

  • 2024-12-03: Original post published.
  • 2024-12-03: Added the “second-generation memory safe language” paragraph.

  1. Laebel is a small server that runs in your Docker Compose project, serving a website that documents your project. ↩︎

  2. I’m a bit worried that I’m late to the party. After all, Rewrite it in Rust has been a meme for close to ten years now, so there should be a new even cooler language to rewrite it in, right? ;-) ↩︎

  3. Working with TOML-based build files were especially interesting, as another of my hobby projects is a build system which also used similar-looking TOML files. ↩︎