I've been writing Go ~daily since before 1.0 came out. I've been writing Rust ~daily also since before its 1.0 release. I still do. I primarily write Go at work and Rust in my free time these days, although I sometimes write Rust at work and sometimes write Go in my free time.
Go's goals actually deeply resonate with me. I very much appreciate its fast compilation times, opinionated and reasonably orthogonal design and the simplicity of the overall tooling and language. "simplicity" is an overloaded term that folks love to get pedantic about, so I'll just say that I'm using it in this context to refer to the number of different language constructs one needs to understand, and invariably, how long it takes (or how difficult it is) for someone to start becoming productive in the language.
I actually try hard to blend some of those goals that I find appealing into Rust code that I write. It can be very difficult at times. In general, I do my best to avoid writing generic code unless it's well motivated, or manifests in a way that represents a common pattern among all Rust code. My personal belief is that Go's lack of generics lends considerably to its simplicity. Writing in Go code heavily biases towards less generic code, and thus, more purpose driven code and less YAGNI code. I don't mean this to be a flippant comment; the best of us succumb to writing YAGNI code.
So if I like Go's goals so much, why do I use Rust? I think I can boil it down to two primary things.
The first is obvious: performance and control. The projects I tend to take on in my free time bias towards libraries or applications that materially benefit from as much performance tuning as you want. Go has limits here that just cannot be breached at all, or cannot be breached without sacrificing something meaningful (such as code complexity). GC is certainly part of this, and not just the GC itself, but the affects that GC has on the rest of your code, such as memory barriers. Go just makes it too hard to get optimal codegen in too many cases. And this is why performance critical routines in Go's standard library are written in Assembly. I don't mind writing Assembly when I have to, but I don't want to do it as frequently as I would have to in Go. In Rust, I don't have to.
The second reason is harder to express, but the most succinct way I can put it is this: Go punishes you more than Rust does when you try to encapsulate things. This is a nuanced view that is hard to appreciate if you haven't used both languages in anger. The principle problems with Go echo a lot of the lower effort criticism of the language. My goal here is to tie them to meaningful problems that I hit in practice. But in summary:
- The lack of parametric polymorphism in Go makes it hard to build reusable abstractions, even when those abstractions don't add too much complexity to the code. The one I miss the most here is probably
Option<T>
. In Go, one often uses *T
instead as a work-around, but this isn't always desirable or convenient.
- The lack of a first class iteration protocol. In Rust, the
for
loop works with anything that implements IntoIterator
. You can define your own types that define their own iteration. Go has no such thing. Instead, its for
loop is only defined to work on a limited set of built-in types. Go does have conventions for defining iterators, but there is no protocol and they cannot use the for
loop.
- Default values. I have a love-hate relationship with default values. On the one hand, they give Go its character and make a lot of things convenient. But on the other hand, they defeat attempts at encapsulation. They also make other things annoying, such as preventing compilation errors in struct literals when a new field is added. (You can avoid this by using positional struct literal syntax, but nobody wants to do that because of the huge sacrifice in readability.)
So how do these things hamper encapsulation? The first two are pretty easy to exemplify. Consider what happens if you want to define your own map type in Go. Hell, it doesn't even have to be generic. But maybe you want to enforce some invariant about it, or store some extra data with it. e.g., Perhaps you want to build an ordered map using generics. Or for the non-generics case, maybe you want to build a map that is only permitted to store certain keys. Either way you slice it, this map is going to be a second class citizen to Go's normal map type:
- You can't reuse the
mymap[key]
syntax.
- You can't reuse the
for key, value := range mymap {
construct.
- You can't reuse the
value, ok := mymap[key]
syntax.
Instead, you wind up needing to define methods for all of these things. It's not a huge deal, but now your map looks different from most other maps in your program. Even Go's standard library sync.Map
suffers from this. The icing on the cake is that it's not type safe because it achieves generics by using the equivalent of Rust's Any
type and forcing callers to perform reflection/type conversions.
Default values are a completely different beast. Their presence basically makes it impossible to reason conclusively about the invariants of a type. Say for example you define an exported struct with hidden member fields:
type Foo struct {
// assume there are important invariants
// that relate these three values
a, b, c int
}
func NewFoo() Foo { ... }
func (f *Foo) DoSomething() { ... }
Naively, you might assume that the only way to build Foo
is with NewFoo
. And that the only way to mutate its data is by calling methods on Foo
such as DoSomething
. But this isn't quite the full story, since callers can write this:
var foo Foo
Which means the Foo
type now exists as a default value where all of its component members are also default values. This in turn implies that any invariant you might want to come up with for the data inside Foo
must account for that component's default values. In many cases, this isn't a huge deal, but it can be a real pain. To the point where I often feel punished for trying to hide data.
You can get a reprieve from this by using an unexported type, but that type cannot appear anywhere (recursively) in an exported type. At least in that case, you can be guaranteed to see all such constructions of that type in the same package.
None of these things are problems in Rust. YMMV over how much you consider the above things to be problems, but I've personally found them to be the things that annoy me the most in Go. An honorary mention is of course sum types, and in particular, exhaustive checks on case analysis. I've actually tried to solve this problem to some extent, but it's not ideal for a number of reasons because of how heavyweight it is: https://github.com/BurntSushi/go-sumtype --- From my perspective, the lack of sum types just means you can move fewer invariants into the type system. For me personally, sum types are a huge part of my day-to-day Rust coding and greatly lend to its readability. Being able to explicitly enumerate the mutually exclusive states of your data is a huge boon.
Anyway, Rust has downsides too. But I'm tired of typing. My main pain points with Rust are: 1) complexity of language features, 2) churn of language/library evolution and 3) compile times. Go suffers from basically none of those things, which is pretty nice.