Kicking the tires on the Go lemon
So last week I posted my longest post on this blog ever, wherein I complained about Rust. I think some folks got the idea from that post that I don’t like Rust, which is incorrect. Actually I just like to complain.
With that in mind, I’m going to spend today’s post complaining about the language that everyone loves to compare Rust to, Golang1. Can’t wait to see how many people I infuriate with this one!
Start off with the good stuff
Just like last week, I’m going to start off talking about the list of things I like about Go.
Ok, now let’s move on to the bad things.
I jest, I jest. I don’t know that it’s much of a secret that Go isn’t my favorite language ever, but I hope that I’m at least able to give it a semi-fair shakedown. So: good things about Go:
Goroutines are a really nice way of doing concurrency. Unlike both Rust and Python, Go was designed from the beginning with asynchronous programming as a first-class concept, so the language comes with a built-in async runtime, and it’s really easy to spin off new asynchronous tasks.
It is a very easy language to get started in. Again, unlike Rust, I feel like I can spin up a working program very quickly, and iterate on it until it’s in a good state. In contrast, Rust takes a lot of time fighting with the borrow checker before you can even get a working binary, which makes prototyping and iteration much slower.
Ok, that’s it. Let’s get to complaining!
The language itself
The language itself is generally “fine”, and is not what I like least about Go. But even so, there are a bunch of language design choices that I find to be on the spectrum from “weird but ok” to “downright frustrating”. I’m just gonna dump these in a list, there’s probably some others that I’m not including here, too:
Variable declaration, initialization, and assignment syntax: If you’re not familiar, in Go, you declare a variable that you’re going to use later like this:
var foo int32
. It gets initialized to whatever the “default” value is2. If you don’t like the default, you could instead sayvar foo int32 = 5
. Except: you can also declare and initialize a variable like this:foo := 5
. That’s obviously a lot shorter and cleaner, and is generally what folks do when the type can be inferred easily. But! Because of the way Go works, this block of code is illegal:var foo int32 = 5
The compiler will complain at you because you aren’t declaring any new variables in the left-hand side of the second statement. Instead, you have to use the assignment operator, written
foo := 7=
instead of:=
. This maybe seems relatively inconsequential until you have a block of code that looks like this:result1, err := fn1()
if err != nil {
// handle error
}
… 100 more lines of code
if err = fn2(); err != nil {
// handle error
}
This type of code is very common in a Go program, and now you can see another weird quirk of variable declaration and assignment. Go doesn’t have a real “tuple” type3, but it does let you return multiple values from a function, and as long as one of the variables on the left-hand-side of a:=
is new, it will happily just perform an assignment to the other variables. But! What happens if you refactorfn2()
so that it returns a result and an error? Then you have to change the operator you use, because you’re no longer declaring a new variable:result2, err := fn2()
This is approximately fine, because you’ll have to change the code at the function call site anyways, but what is NOT ok is if you refactorfn1()
so that it no longer returns an error (maybe you decide it’s more appropriate to handle that error lower in the call stack), now suddenly and magically you have to change the code at some completely unrelated point in your program:if err := fn2(); err != nil { … }
I regularly run into this situation, and this kind of “spooky action at a distance” makes for messy PRs and an annoying developer experience.
While frustrating, the above issues can at least be caught at compile-time. But there is a whole ‘nother can of worms with variable declaration, assignment, and shadowing that is even more insidious and will cause all kinds of issues at runtime. The short version is that in this code, the innererr
is a different variable than the outererr
:if err := fn1(); err == nil {
If you were expecting this to return an error if
result2, err := fn2()
}
return errfn2()
fails, surprise! It won’t. This is a really easy trap to fall into and will cause you no end of headaches to debug. If you want to avoid this, the only way to do it is to rewrite the code like this:if err := fn1(); err == nil {
var result2 sometype
result2, err = fn2()
}Error handling is… verbose: While we’re on the subject, I don’t like the way Go does error handling. It’s both verbose and completely optional4. The addition of a proper (built-in) sum type and some simple syntax to work with it would solve this problem fairly easily, I think.
defer() is a poor substitute for RAII: A big problem in early C/C++ code is forgetting to clean up resources when you’re done. This can be memory, of course, but also things like file handles, network connections, etc. This is solved in modern C++ code using a concept called Resource Allocation Is Initialization (RAII)5, and is something Rust takes to the next level. On the other hand, Golang says “when you allocate a new object or resource, you should call
defer(cleanup())
immediately after allocation, so that you don’t forget to do it later.” Which is fine and all, I guess, but it doesn’t solve the problem of “what if I forgot to do it at all, and/or I don’t know that I’m holding onto a resource?”6struct tags and comment-based directives are nonsense: I complained last week about Rust’s macros, saying that the language itself wasn’t expressive enough, so they built a whole second language on top with arcane syntax. Struct tags in Go are the same. You can spend a lot of time (speaking from experience) trying to understand why a particular bit of code isn’t working correctly only to discover that it’s because some struct tag that you may or may not have even known existed, in some very different code base is influencing things. See also: “spooky action at a distance”.
Compiler directives are similar. I don’t want a programming language in the comments of my programming language. This is a particular complaint of mine about the kubernetes controller-runtime/kubebuilder toolchain.nil is typed: I just recently learned about this weird bit of trivia. The following block of code will not do what you expect:
var a *structA
I haven’t personally been bitten by this (yet) but I can imagine it might cause some nasty bugs if you aren’t aware of this behaviour. Also, didn’t we all generally agree a long time ago that
var b *structB
a == nil // returns true
b == nil // returns true
a == b // returns falseNull
/nil
was a bad idea7???Capital letters mean “public”, lowercase letters mean “private”: My personal opinion here is that using capital letters to indicate public/private visibility is on the same level of dumb as giving semantic meaning to whitespace8. See also: no semicolons at the ends of lines.
I’m sure there are some other issues with the language design itself that I’m forgetting about, but really, every language has its own set of gotchas and idiosyncrasies that programmers are going to have to learn about. It’s just the nature of the job. Maybe I wouldn’t make the same tradeoff decisions if I were to design a language, but I can understand and respect most of the decisions that were made here.
Before we close this section out, let’s talk a little bit about linting. Many of the problems I’ve called out about Go above can be solved with a good linter, and honestly? I’m all for linting! Making your code more consistent, checking for possible error cases—all good things! But there are sooooo many linters for Go. There are so many linters that we now have a “meta linter” which runs all your different linters for you. My broader complaint here is that it really feels like in the Go ecosystem, we’re using linters to cover up for deficiencies in the language itself. Which… doesn’t feel great.
But anyways, moving on, let’s talk a little bit about the other aspect of writing Go code, namely, dealing with the ecosystem.
The Golang Ecosystem
Because Golang was designed at Google for Googlers, it makes a ton of assumptions about the tooling that is available that… just isn’t true if you don’t work at Google. So what we’re left with is (what feels like) a bunch of half-baked poor substitutes.
Modules and Dependencies
Every software program is going to have things it depends on; other libraries that it uses; etc. So having a tool or set of tools to manage those dependencies is critical. Golang does technically have a tool via go.mod
, but it is pretty barebones9. You can specify the things your program depends on, at the exact version you want to use, and… that’s it. You can’t specify version ranges, or dependencies that you use just for development and not for production or… anything. It works. It gets the job done. But is it nice to use? No.
And while we’re on the subject: I know that software supply chain attacks are real, and I know that PyPI, or crates.io, or the npm registry, or any of these “dependency repostiories” don’t really vet or do any sort of checks on the packages that get uploaded to them, but I still would rather have a central, managed package repository that hosts dependencies than have to go “Oh I want a library that does X, let me Google around and see how other people solve this problem, oh yes let me just import this library straight from github.com/xXx_TitanSlayer69_xXx/my_totally_serious_software_project because everyone agrees this is the best solution.”10
Also, you can’t use forked versions of projects. It’s just impossible.
Testing
Writing tests in Golang is, I would say, “ok”. go test
works reasonably well, even if I still can’t tell you why go test foo/bar
doesn’t work but go test ./foo/bar
does. There are some decent testing libraries out there (testify.mock and testify.assert are both good). Table-driven tests are an interesting way to do test cases/parameterization, but I don’t hate it — though I am a bit confused why the “canonical” way to do cases is with an array instead of a map. For example,
cases := map[string]struct {
testInput string
testOutput bool
} {
"case1": {
testInput: "foo",
testOutput: false,
},
...
}
makes way more sense to me than the “canonical” way to define test cases, e.g.,
cases := []struct {
name string
testInput string
testOutput bool
} {
{
name: "case1",
testInput: "foo",
testOutput: false,
},
...
}
No, what bugs me the most about testing in Go is that it pushes everything to be an interface, even things that don’t need to be an interface. I do sorta get that this is a consequence of being a compiled language and monkey-patching stuff in a compiled language is not easily done, but even so: interfaces add a lot of cruft to your code and I’d rather have fewer of them, not more11.
The other testing-related thing that bothers me is the extremely poor code coverage support. There’s no easy way to “exclude these bits” from your coverage, or impose “your code must have at least X% code coverage,” etc. I know that test coverage isn’t the be-all, end-all for software safety, but I like it as one signal to be able to look at.
Debugging
I mentioned this last week, but I do want to call out again: Delve is a really great debugger. I wish I could use it in all my projects.
Conclusion
Well, ok, there you have it. All (or at least a lot of) my complaints about Golang. We ended up a good thousand words shorter than my Rust post, which I’m honestly a bit surprised by. I have other things I could complain about, but none of them feel particularly consequential. I guess I just want to conclude with the same sentiment I started with last week: “Programming in Rust is frustrating as all get-out, but I’m generally really proud of the result when I’m finally done. Programming in Golang is easy and straightforward, but I always feel kinda gross at the end.”
Anyways, hopefully you had as much fun reading this series as I have had writing it! It’s always a good time to get to complain about things. Maybe next week I’ll complete the trifecta and whine about Python for a bit :)
Thanks for reading,
~drmorr
Technically, Golang is not the correct name of the language. It is just Go. But, in keeping with tradition, Google went ahead and gave something they created a name that is impossible to Google.
This is another thing I wish Rust did: a lot of my code is littered with Default::default(), I think it’s really nice that everything in Go has a default and you can elide the initialization if you’re happy with the default.
Grumble.
Though if you run a linter, it will yell at you if you don’t check error values.
Which, by the way, is a really bizarre name for the concept, since it really doesn’t have anything to do with allocation/initialization, but has everything to do with automatically cleaning up resources when they go out of scope.
I had a fun memory leak in some code I wrote because if you perform an HTTP GET request, and don’t call “close” on the response object, it just keeps that socket open forever until suddenly you don’t have any memory.
Null References: The Billion Dollar Mistake, by Tony Hoare
And I just want to point out that this is the third iteration of dependency management in the language. The previous two solutions were even worse.
This problem is exacerbated because of the Go “batteries not included” philosophy. There are some very bizarre omissions from the Go standard library which you then just have to either re-implement yourself, or go import something from some GitHub repo that you hope is not as sketchy as it looks.
Also on the language design front: it bugs me that “regular pointers” are denoted as *type
, but interfaces (which act and behave just like pointers) are denoted without the star, e.g., interfaceType
.