2025-01-06

Go is a Well-Designed Language, Actually


lol no generics

An ancient programmer proverb

In many ways 2009 decided my future career. I was thirteen and had just scored my first goal in a competitive football match - a lovely one-two with the winger finished by a powerful strike into the top left corner. Sadly the talent scouts were missing that day. Whilst I was dreaming of Wembley, Go was announced to the world.

Go soon attracted a large following. People loved how simple it was, how optimised it was for web services and the tooling like gofmt. Every action has an equal and opposite reaction however, and so it was with Go. People hated how simple it was, how it was just for noddy REST APIs and the overzealous tooling.

In the past fifteen years many criticisms of, and far more rants about Go, have been written. What interests me is the idea that Go is badly designed. This is probably most famously put forward by two articles - I Want to Get Off Mr Golang's Wild Ride and Lies We Tell Ourselves to Keep Using Go - both by fasterthanlime. The latter goes further and says:

And so they didn't. They didn't design a language. It sorta just "happened".

What is Design Anyway?

To me, a design is a plan or specification for something that fulfils a goal. For example, the goal of the BBC News website might be to inform users of the most relevant things that are going on in the world. The way they do that is by writing news articles, ordering them based on location and importance. The nuclear missile speeding towards me trumps the cat stuck up a tree.

It follows that a design can be evaluated on how well it achieves the design goal.

The Genesis of Go

Go was designed at Google, where Russ Cox, Rob Pike, Ken Thompson and many others were working. Google was using Java and C++ at the time which the designers of Go felt were performant but hard to use. The compilers were slow, tooling was finicky and the languages had been designed at least a decade before. Cloud computing - large numbers of multicore servers working together - was becoming widespread.

They decided to design their own language and prioritised making it work at scale - in computing and man power. Rob Pike explains in Go at Google:

The hardware is big and the software is big. There are many millions of lines of software, with servers mostly in C++ and lots of Java and Python for the other pieces. Thousands of engineers work on the code.

Elsewhere Pike talks about the thousands of engineers he was targeting, in his usual modest, subtle way:

The key point here is our programmers are Googlers, they’re not researchers. They’re not capable of understanding a brilliant language.

Top tip: if you're designing something, try to avoid belittling and patronising the people you are designing for.

Notwithstanding this quote, we get a pretty reasonable design goal: the language should make writing and maintaining large, concurrent server code easy; even across thousands of developers of differing skill levels.

Go Lamentations

Let's look at some complaints people have about Go and evaluate them against the design goal.

Filesystem API

Go's filesystem API is often criticised for being geared towards Unix. Windows doesn't have file permissions in the way that Unix does, so Go just returns some made up ones. Furthermore Go takes a pretty simplistic approach to paths. An OS has a path separator and paths themselves are Go's string type - just a slice of bytes with no real checking or constraints.

Other languages are more strict. For example the Rust method to get a file's modified time can return None. In Zig file metadata is different depending on the OS.

This can be explained by the design goal. Go was designed for use at Google where their servers are all Linux, like most servers. If you're designing a language that is aimed at servers, writing the filesystem API to be Unix-centric is not such a bad idea.

No Operator or Function Overloading

In Go, unlike Java, functions and methods only have one definition (once build tags and target are specified). Operators are implemented in the compiler, unlike C++, and so cannot be overloaded either. In the time package, to add a Duration to a Time you need to use the Add method. If you want to add two days you can't just call Add(0 /*years*/, 0 /*months*/, 2 /*days*/), you need to use AddDate.

To some this may seem inelegant but it is simpler. If you see a function call in Go you know there is one definition you need to check. If you see an operator you know it is for a built in type and will do something sensible, not mint an NFT.

Laborious Error Handling

It's fair to say that the current trend in programming languages is towards terseness. No wonder programmers hate Go's if err != nil style of error handling.

However, again, this was a deliberate choice:

Although in contrast Go makes it more verbose to check errors, the explicit design keeps the flow of control straightforward—literally.

Obvious control flow makes code more readable. Although languages with exceptions might be quicker to write, the code they produce is not as simple, and control flow is hidden.

Go is often criticised for being a bit of a throw-back by avoiding features like exceptions. Someone once asked the designers "Why did you choose to ignore any research about type systems since the 1970s?". Similar arguments has been repeated elsewhere.

Firstly Rob Pike sees your snootiness, and doesn't care about it:

Go was designed to address the problems faced in software development at Google, which led to a language that is not a breakthrough research language but is nonetheless an excellent tool for engineering large software projects.

But secondly designing errors as explicit values has been a trend-(re)setter. Go, Rust and Zig have all chosen to use this approach. Swift, even though it uses exceptions, requires you to mark functions that can error in their signature.

Poor FFI Story

Go doesn't play nicely with other languages. If you want to call a C function - for example to bind to SQLite - then you have to go through CGo. CGo is not Go and has a performance overhead. As goroutines - which have their own stack set up by the Go runtime - are the unit of execution, Go has to do some acrobatics have the stack in the way C expects. This can be costly.

Go's FFI is not helped by it having its own compiler, linker and debugger. Much in the Go ecosystem is custom.

When we consider the design goal we can find justification. Server software has to be concurrent, and so goroutines were chosen. That necessarily makes calling C code more complicated, but the trade-off does at least fit with Go being used for concurrent systems where servers talk to each other, not processes.

These decisions also gave Go a leg-up on tooling. The compiler being specific to Go means it can focus on compiling just Go as quickly as possible. The debugger can understand goroutines and all of Go's built in types.

So Go is Great?

That's subjective. For my part, I like it. The Go code I've looked at and worked on has generally been easy to read and understand. The lack of bells and whistles forces me to just write the damn code rather than building abstractions that don't hold their weight. I've also taught Go to a large group of graduates just out of university with success.

That doesn't mean I don't see its downsides. I've been sat in a call with a customer who was affected by a bug that we couldn't trace due to not checking an error. Easily prevented by linters; painful when you haven't turned them on. For ages Go didn't have generics which made it annoying to write generic data structures. Every time I receive a bug report on Windows I have to pause to think about whether Go has lulled me into a false sense of security.

Ultimately these are issues that have resulted from a trade-off that was deliberately made in the design process. You can say you don't like Go, or that it is a bad fit for a certain application, or it doesn't give you the things you need. Heck, you can even say you hate it. But don't say it wasn't (well) designed.