Rust vs. Go

Welcome to the Rust Review’s bonus post, which I had promised from the very beginning. I’m here to cover the big elephant in the room: Rust vs. Go. Which one is better?

There is no good answer to this question because this comparison is unfounded. I think people tend to bundle the two languages together because they were released at about the same time and the release of Rust felt like a response to the release of Go. Moreover, both languages are supposed to focus on systems software. But they are vastly different, and even as they both target systems software, they target different kinds of such software.

Go can be thought of as “C done right” or “a replacement for Python”. Go excels at the development of network servers and automation tools. Rust focuses on correctness and safety and is somewhere between C++ and Haskell, and as I previously mentioned, can be thought of as “a pragmatic Haskell”. Despite Rust’s high-level abstractions, its promise of making them zero-cost also means that it should excel at writing any kind of systems project.

This personal review is based on my experience writing the exact same project, sandboxfs, in the two languages. The original implementation of this project was done in Go and I have an experimental rewrite (not yet checked in) in Rust. Both pass the same test suite. I did this rewrite to learn the language, but also because profiling the Go variant always identified the hotspots as living in the Go runtime. I wanted to see if a trivial rewrite in Rust would deliver better performance, and that seemed to be the case. Along the way, I’ve been surprised by how many potential concurrency bugs I encountered in the original Go code because Rust didn’t allow me to implement the same design in the rewrite.

Memory management

The first thing that stands out as different between Go and Rust is the way they manage memory.

Go transparently allocates objects on the stack or heap depending on their lifetime and uses garbage collection to manage the latter. Rust uses explicit stack and heap allocations and manages the latter via scopes (aka RAII in C++ parlance) and ownership/move semantics.

In this area, the typical trade-offs between garbage collection and explicit memory management apply. This means that Go suffers from garbage collection overhead while Rust doesn’t, but this is a non-issue for the vast majority of software. Think about the last time you had to write a performance-critical, latency-critical, CPU-bound application.

Effectively using Rust requires understanding how a computer’s memory works. Go is more forgiving in this area, but as I’ll cover in the next section, that’s not necessarily a good thing: knowing how memory works is something you really ought to master as a programmer.

Winner: cannot call. Both Go and Rust provide mechanisms to make memory leaks unlikely and both have good ergonomics.

Difficulty

Go is a very simple language and in that lies its beauty. Go is extremely easy to pick up and to churn lots of code with after only a few hours of practice. But as we saw in the learning curve post, this is a fallacy: any code you write right after learning a language will be non-idiomatic. You need a lot more time to really write code worthy of the language’s best practices.

In the Rust case, there is no denying that it is a complex language. Maybe not as complex as C++, but it has a lot of things going on. Writing code in Rust, therefore, requires more effort. The payoff is that, once the code is written and compiles successfully, there are high chances that it will run just fine. The same cannot be said of Go, in which I’ve experienced plenty of runtime crashes.

You have to pick your battle: either you write Go code quickly and then spend extra time writing trivial tests and debugging runtime problems; or you spend that time writing robust Rust from the ground up and avoiding post-build problems.

Winner: hard to call. It’s easy to say that Go wins, but I don’t want to say so because of what I mentioned. I personally prefer spending extra time crafting code that will stand the test of time instead of having to track down complex memory and threading issues down the road.

Generics

Go doesn’t support generic types. The Go authors are not inherently opposed to having them, but they claim that they cannot be cleanly implemented or supported—and that a perfect solution should be found before anything is implemented. As a result, people abuse interface {} to represent generic types and primitives like append are implemented using features that no other Go code can benefit from.

Rust just has generic types and they work as you expect. Generics work both for plain types as for traits, and in the case of traits, you get to control how the machine code Rust generates looks like via the impl and dyn duality (starting from Rust 2018).

Winner: Rust. ‘nuff said.

Code sanity

The thing I dislike the most about Go is that, in its lack of features, it provides zero mechanisms to encode the characteristics of robust code in the code itself. Yes, automated code formatting and strong recommendations on how the code should be written are great, but these are insufficient to enforce logical properties of the code. I’ve found myself writing long comments explaining why some conditions are never possible, why a particular parameter works the way it does, or how variables and mutexes are related… and these are all symptoms of code that will break in the future: comments cannot be sanity-checked by the compiler and I can guarantee you that they go stale. Everything should be expressed in code if at all possible.

So what features do we find in Rust to make code more robust and future-proof that we don’t find in Go?

Assertions. These are invaluable in letting the programmer communicate their intent about state that may be not obvious. Rust has assertions. Go, on the other hand, doesn’t. Well… Go does have panic which can be used to simulate assertions, but this is strongly frowned upon so you should never do that.

Why does Go not have assertions? My blind guess: because programmers routinely misuse assertions to validate user input, which later causes runtime crashes on bad or malicious input. Therefore, in its pragmatism, Go requires reporting all problems as controlled errors to avoid this kind of mistake. (Hmm… this is something I’ve been meaning to cover as a separate post for a while…)

By the way, a note I’ve heard several times from others: you can judge the experience of a programmer by how many assertions their code has, where more means better. Do whatever you want with that thought.

Annotations. Sometimes there are input parameters that go unused when they should be used, or return values that must be checked but aren’t. Other times, you know a particular function will never return and want to communicate this fact to the caller so that the compiler doesn’t complain about, for example, missing return statements. Go doesn’t have any of these, which makes it hard to express programmer intent. Rust does have such annotations.

What’s worse in the Go case is that some of these behaviors exist for specific internal functions such as panic, which the compiler understands cannot return, but it’s impossible to express this for any Go code you write. Quite unfair, considering that this same drawback applies to what I said about generics above.

Docstrings. Yes, Go has docstrings, but they are incredibly rudimentary. They work just fine for the most part though, but having written a lot of Java, there is a ton of value in having docstrings with a predefined structure. This allows tools to verify the sanity of the documentation. For example, IntelliJ is able to validate that parameter names match the actual function parameters and that cross-references to other classes are valid.

Rust’s docstrings are better than Go’s in the sense that they support Markdown, but they are still a underspecified: there doesn’t seem to be a standard way of structuring them, and they don’t support documenting individual items. The tooling cannot cross-check that those docstrings are valid according to the code.

Error checking. I am one of the few (?) people that likes Go’s explicit error propagation. Yes, it’s annoying to write many error checks, but doing so forces you to think about errors in a way that many other languages do not.

Unfortunately, the chosen idiom has a problem: a function always returns a value and an error, and it’s up to the caller to first check the error and then check the value. The language won’t enforce doing the right thing, and I’ve seen people make mistakes by unpacking the result of a function before checking for an error. Rust, on the other hand, comes with higher-level types to wrap values or errors. Combined with the lack of null, this means that the caller can never access a value when an error is present and viceversa. See the Result type and what I wrote about the match keyword.

To conclude this section, a positive note about both languages: none of them promote types automatically. For example, both Rust and Go force the programmer to cast integers across different sizes so that any possible overflows or underflows become apparent. Mind you: Go is a bit better than Rust in this area because Go’s type aliases are treated as semantically different by the compiler—requiring explicit casts between them—while Rust just treats them as syntactical aliases (like C’s typedefs).

Winner: Rust hands down. You can claim that these are just complaints about Go’s lack of features and that I should just take Go the way it is… but no: these are complains about the inability to write future-proof, crystal-clear code in Go because of these missing features.

Profiling

Go’s simplicity transpires everywhere, including the tooling required to improve a program once written. One specific aspect of this is profiling: Go features a builtin mechanism to capture CPU and memory usage traces and provides transparent integration with the pprof tool. It’s trivial to instrument a Go program and obtain useful data for its optimization.

I haven’t found a profiler for Rust as well-integrated as pprof is in Go. Yes, there is a library to generate pprof-like traces, but it hasn’t worked very well for me yet and it’s a bit cumbersome to install (needing gperftools to be present on the system). This old post contains some more information on this topic and other available tools.

Winner: From what I know so far, Go claims victory here.

Build speed

Go was designed from the ground up to build as fast as possible. As far as I know, this was an attempt to cut down the build times of very large apps within Google. I suspect this explains why Go uses duck typing, for example, as this language choice avoids hard coupling between components and thus speeds up incremental compilation. I was shocked the first time I bootstrapped the Go toolchain on NetBSD and saw the whole thing complete in minutes: I was used to the hour-long clang’s bootstrap.

Rust is known for being slow to compile. All the sanity-checks that the borrow checker performs or the generic types it supports, for example, don’t come for free. I hear there are possibilities for improvement in this area but: one, I haven’t researched what these are, and two, they haven’t materialized yet.

Winner: Easy. Go.

Build system

As is customary in any modern programming language, both Go and Rust come with their own package-management and dependency-tracking tools.

On the Go side, we have the extremely-simple Go tools that allow fetching a package and its dependencies, and building the whole thing without the need to create any build configuration file. This sounds very appealing but it’s dangerous and goes against basic engineering practices: for example, Go’s tools always fetch dependencies from sites like GitHub and they fetch the latest snapshot of the code; there are no mechanisms to indicate required versions nor to ensure that malicious code is not pulled in. This is inspired, I believe, by how Google’s monorepo works and its “build at head” philosophy, but this just doesn’t match what the open-source ecosystem expects. Apparently the Go community has finally accepted the need a better solution and there are proposals to resolve this situation.

On the Rust side, we have Cargo. Using Cargo in a project requires some more configuration than the Go tools, but not a lot more: your typical Cargo.toml file will be a few lines long and it will just declare basic metadata about the project and the list of dependencies it needs. The Rust ecosystem then uses semantic versioning and Cargo understands this. In other words: Rust and Cargo were designed to support the ecosystem they live in instead of pushing their agenda. Cargo is typically singled out as one of the best things in Rust.

Interestingly, one can also use Bazel to build both Go and Rust code. In this case, Bazel fixes all of the problems identified above for Go with its (rules_go) ruleset: these rules allow dependency and toolchain version pinning. As for Rust, the Bazel support is quite rudimentary because the rules (rules_rust) ruleset doesn’t have a lot of features so far.

Winner: When considering the native build systems of each language, Rust wins with its Cargo tool. When considering Bazel, Go wins for the time being.

Unit testing

Automated testing is a critical need in any codebase to ensure that code works as expected and to guarantee that it will continue to work as expected as it evolves. (Without tests, you cannot do large-scale refactorings, for example.)

I’m quite bitter at Go in this area and I think I should save this rant for a separate post. Go’s testing package seems to disregard all modern testing techniques and instead forces its own view of how tests should look like. How so? By forbidding assertions so that the only piece of code that can fail a test is the test function itself; no helper functions allowed, no fixtures. This sounds appealing and works well for simple unit tests, but easily gets out of hand. As a result, the more-complex tests obscure the logic of what they are testing and become hard to understand.

Thankfully, there is a third-party library for Go, testify, that provides a JUnit-like framework for Go tests. It is great that this library exists as it brings sane semantics to testing. The problem is that the Go community is so opinionated that you may not have a chance to use this library in your project.

On the other hand, Rust’s testing libraries are more in-line with what you would expect in any other language. In other words: you have assertions. One thing I find weird, though, is that Rust recommends keeping your test code within the same source file as the code it’s testing. I have yet to feel how this scales in the real world as I haven’t written enough tests.

Winner: I very much want to say Rust because of how displeased I’ve been by Go’s testing approaches, but given the existence of Go’s testify, I’ll say that there is a tie here.

Summary

There are good and bad points in every single language out there, and the small selection of items I’ve presented in this post proves this for Go and Rust. The winning language in each section varies and sometimes it’s not even possible to call one language out.

Your job as an engineer is to understand the tradeoffs of every project and to choose the best tool for each of them. Rust and Go are just tools. So pick the one that works best for the team, the project, and yourself (in that order).

If you want me to say something less abstract, I’d strongly encourage you to learn Rust and to become comfortable with its borrow checker and data ownership rules. Even if you don’t end up using Rust, whatever you learn in the process will benefit you in any other language—including Go.