Golang: (Still) Not a Fan
I try to be pragmatic when it comes to programming languages. I've enjoyed learning a lot of programming languages over the years, and they all have varying benefits and downsides. Like a lot of topics in Computer Science, choosing a language is all about tradeoffs.
I've seen too many instances of people blaming the programming language they're using for some problem when in fact it's just that they misunderstood the problem, or didn't have a good grasp of how the language worked. I don't want to be That Guy.
However, I still really haven't understood the hype behind Go. Despite using it a few times over the years, I do not find writing Go code pleasant. Never have I thought "Wow, this is so much nicer in Go than [other language]." If asked, I can't think of a task I'd recommend writing in Go vs. several other languages. (In fact, part of my reason for finally compiling my issues with Go into a blog post is so I'll have a convenient place to point folks in the future w/o having to rehash everything.)
Before I get into what I don't like about the language, I'll give credit where credit is due for several features that I do like in Go.
Stuff I Like
No Function Coloring
Go doesn't suffer from "Function Coloring". All Go code runs in lightweight "goroutines", which Go automatically suspends when they're waiting on I/O. For simple functions, you don't have to worry about marking them as async
, or await
ing their results. You just write procedural code and get async performance "for free".
Defer
Defer is great. I love patterns and tools that let me get rid of unnecessary indentation in my code. Instead of something like:
let resource = openResource()
try {
let resource2 = openResource2()
try {
// etc.
} finally {
resource2.close()
}
} finally {
resource.close()
}
You get something like:
resource := openResource()
defer resource.Close()
resource2 := openResource2()
defer resource2.Close()
// etc.
The common pattern in other languages is to provide control flow that desugars into try/finally/close, but even that simplification still results in unnecessary indentation:
try (val fr = new FileReader(path); val fr2 = new FileReader(path2)) {
// (indented) etc.
}
I prefer flatter code, and defer
is great for that.
Composition Over Inheritance
I've been hearing "[Prefer] composition over inheritance" since I was in university (many) years ago, but Go was the first language I learned that seemed to take it to heart. Go does not have classes, so there is no inheritance. But if you embed a struct into another struct, the Go compiler does all the work of composition for you. No need to write boilerplate delegation methods. Nice.
Story Time: Encapsulation
Now that we have the nice parts out of the way, I'll dig into the parts I have problems with. I'll start with a story about my experience with Go. Feel free to skip to the "TL;DR" section below for the summary.
Back in 2016, my team was maintaining a tool that needed to make thousand HTTP(S) requests several times a day. The tool had been written in Python, and as the number of requests grew, it was taking longer and longer to run. A teammate decided to take a stab at rewriting it in Go to see if we could get a performance increase. His initial tests looked promising, but we quickly ran into our first issues.
- Unbounded resource use
- Unbounded parallelism
- Lots of boilerplate for managing channels
The initial implementation just queried a list of all URLs we needed to fetch, then created a goroutine for each one. Each goroutine would fetch data from the URL, then send the results to a channel to be collected and analyzed downstream. (IIRC this is a pattern lifted directly from the Tour of Go docs. Goroutines are cheap! Just make everything a goroutine! Woo!) Unfortunately, creating an unbounded number of goroutines both consumed an unbounded amount of memory and an unbounded amount of network traffic. We ended up getting less reliable results in Go due to an increase in timeouts and crashes.
Given the chance to help out with a new programming language, I joined the effort and we ended up finding that we had two bottlenecks: First, our DNS server seemed to have some maximum number of simultaneous requests it would reliably support. But also (possibly relatedly), we seemed to be overwhelming our network bandwidth/stack/quota when querying ALL THE THINGS at the same time.
I suggested we put some bounds on the parallelism. If I were working in Java, I'd reach for something like an ExecutorService, which is a very nice API for sending tasks to a thread pool, and receiving the results. We didn't find anything like that in Go. I guess the lack of generics meant that it wasn't easy for anyone to write a generic high-level library like that in Go. So instead, we wrote all the boilerplate channel management ourselves. Because we had two different worker pools to manage, we had to write it twice. And we had to use low-level synchronization tools like WaitGroups to manually manage resource.
Disillusioned by how gnarly a simple tool turned out, I did some searching to find out if Go had plans to add Generics. At the time, that was a vehement "No". Not only did the language implementors say it was unnecessary (despite having hard-coded generics-equivalents for things like append()
, make()
, channels, etc.), but the community seemed downright hostile to people asking about it.
At that point I'd already played with Rust enough to have a fair idea that such a thing was possible. In a weekend or two, I wrote a Rust library called Pipeliner, which handles all of the boilerplate of parallelism for you. Behind the scenes, it has a similar implementation to our Go implementation. It creates worker pools, passes data to them through channels, and collects all the results (fan-out/fan-in). Unlike the Go code, all that logic gets written and tested in a separate, generic library, leaving our tool to just contain our high-level business logic. Additionally, this was all implemented atop Rust's type-safe, null-safe, memory-safe IntoIterator interface. All of our application logic could be expressed more succinctly and safely, in roughly:
let results = load_urls()?
.with_threads(num_dns_threads, do_dns_lookup)
.with_threads(num_http_threads, do_http_queries);
for result in results {
// etc.
}
Go 1.18 Generics
Recently, I interviewed with a company that wrote mostly/only Go. "No problem," I thought. "I'm pragmatic. Go can't be as bad as I remember. And it's got generics now!"
To brush up on my Go, and learn its generics, I decided to port Pipeliner "back" into Go. But I didn't get far into that task before I hit a road block: Go generics do not allow generic parameters on methods. This means you can't write something like:
type Mapper[T any] interface {
func Map[Output](mapFn func(T) Output) Mapper[Output]
}
Which means your library's users can't:
zs := xs.Map(to_y).Map(to_z)
This is due to a limitation in the way that interfaces are resolved in Go. It feels like a glaring hole in generics which other languages don't suffer from. "I'm pretty sure TypeScript has a better Generics implementation than this", I thought to myself. So I set off to write a TypeScript implementation to prove myself wrong. I failed.
IMO, good languages give library authors tools to write nice abstractions so that other developers have easy, safe tools to use, instead of having to rewrite/reinvent boilerplate all the time.
TL;DR
- Despite the creators of Go and much of the Go community saying there was no need for Generics, they were finally added in Go 1.18.
- But the generics are still so basic that they can't support fairly standard use cases for generics. For example, in 2023, Go still doesn't have a standard Iterable/Iterator interface. (And the proposals at that link don't look promising to me.)
- Without good support for generics, it's difficult to write good higher-level libraries that can abstract complex boilerplate away from developers.
- As a result you either get poorer (unsafe, leaky) APIs, or developers just rewriting the same boilerplate code all the time (which is error-prone).
Other Dislikes
OK this post is already really long, so I'm just going to bullet-point some of my other complaints:
- oddly low-level for a modern GC'd language. Why do I need to
foo = append(foo, bar)
instead of justfoo.push(bar)
? - Interfaces are magic. Does
Foo
implementBar
? Better check all its methods to see if they matchBar
's. You can't just declareFoo implements Bar
and have the compiler check it for you. (My favorite syntax for this is Rust's:impl Bar for Foo { … }
, which explicitly groups only the methods required for implementing a particular interface, so it's clear what each method is for. - The automatic composition/delegation that I mentioned above is cool, but I don't think there exists a way to mark an
override
as such so that the compiler can check it for you. - The compiler is annoyingly opinionated. If you have an unused variable, your code just won't compile. Even if the variable is unused because you've just commented out a chunk of code to debug something. Or when you're scaffolding something and want to test your progress before it's complete. Playing dumb whack-a-mole w/ errors that don't matter while experimenting is not the best use of my time. Save it for lints.
- Go isn't null-safe. Why am I getting null dereferences at runtime? Rust, Kotlin, and TypeScript have had this for a while now.
- Go has been described by Rob Pike as "for people not capable of understanding a brilliant language".
- This sort of elitism rubs me the wrong way.
- I believe you can have a "brilliant language" that enables people of many different levels to be productive. In fact, having tools to make safe APIs means that people with more interest/time can write libraries in that language that save others time and duplicated effort.