Go is nice. Go is a high level procedural programming language. It’s philosophy is quite compelling to me – keep things simple wherever possible. It’s been a pleasant respite from rust where correctness is everything. I spent a month programming in it, here are my inaugural thoughts.
The background
This year I participated in an event called “Advent of code”. This is my second time and I have learnt a lot of great things. This time around, I decided I wanted to grab the opportunity with both hands and go out of my comfort zone a bit. I decided there is no better way to broaden one’s horizons than try and entirely different programming language and ecosystem.
Let’s back up a bit. You may not of heard of advent of code, it’s a yearly event held by Eric Wastl. It is a very specific kind of nerd’s best friend. It’s a kind of advent calendar. On the first of December every year it begins and on the 25th it ends. Each day one small programming challenge is unlocked and participants are encouraged to solve it however they please and submit their results to earn a gold star. Each day traditionally has 2 parts, the first part introduces you to the problem and the 2nd builds on it. I quite enjoy it, mind you it’s not every programmer’s cup of tea – the problems are abstract and whimsical in nature. For reasons I may never understand, not everyone likes playful whimsy.
Why did I go with go?
But why not use any other language? Well to be entirely truthful, I could have chosen just about anything with little consequence – advent of code is a completely open-ended event after all. It My motivations for choosing go this time were rather simple, I have heard it discussed quite often and it looked easy to pickup. And surely these are as good a reason as any to pickup a new skill!
Initially, my broad plan was to try to complete only a handful of problems in go and then move to a language I’m more comfortable with as the difficulty of the challenges did inevitably ramp up, however I found myself becoming rather comfortable in go remarkably quickly. That’s not to say I’m an expert by any means, or even that I’ve explored all of the language’s features, but it does mean that the difficulty curve was extremely flat. This was a pleasant surprise.
The good
Go is legible
Go is a very straightforward language. It does exactly what you tell it and nothing more. Its idioms are intuitive and effective. It’s trivial to pickup, it’s satisfying to write and it has a compelling ecosystem.
Anyone who doesn’t even know go can easily read code written in it. In this go is humbly victorious. It is easy to understate the value of readability, it’s often the first thing to go when programmers are under strict deadlines. It’s everywhere in code and many of us have blindly accepted that some things are just hard to read and it doesn’t matter. I strongly disagree with this sentiment and go is an excellent example of the benefits.
On day one, I could read the source code for the go standard library without trying, even after a year i cannot say this about rust. I cannot say this about many languages at all. Better yet, with zero training I could understand how some parts of entire libraries and projects worked. Understanding the foundations your code is built upon is invaluable. Let me give you an example.
The Go standard library has a module called slices and a particular useful function in there is `ContainsFunc`. It allows you to pass in a function and an array (It’s actually a slice, if we’re going to be pedantic) to see if any item meets an arbitrary condition. Well, suppose you put in an un-sorted array? The answer was not clear to me as a novice and I had little understanding of what any of the generics meant so how was I to know? Tell me, without looking, will this function expect a sorted array?
func ContainsFunc[S ~[]E, E any](s S, f func(E) bool) bool
Please ignore all the generics here between the []
, it’s not relevant. Well if we look at the implementation, you will immediately know how it works…
func ContainsFunc[S ~[]E, E any](s S, f func(E) bool) bool {
return IndexFunc(s, f) >= 0
}
func IndexFunc[S ~[]E, E any](s S, f func(E) bool) int {
for i := range s {
if f(s[i]) {
return i
}
}
return -1
}
Well, obviously it does not because it’s doing a linear search on the input. Easy! It was faster for me to read the definitive implementation than ask myself if it was even possible. You really cannot complain when the answers to questions like this are so easy to find. This is not the best example of it’s power but I think it perfectly illustrates the value of relying on libraries that are readable, you never need to rely on documentation, spend long studying the code, guess or anything. The answer to all your questions is not read the documentation, but read the self-documenting code!
Go error handling is easy
Go approaches error handling in a way I haven’t seen the likes of before. It strikes a perfect balance between performance, ease of use and interoperability. It also manages to keep everything super flexible.
First of all, error handling in go is primarily a convention, this has several benefits.
- You can opt out at any time if the convention is not sufficient for your sophisticated task
- APIs are self documenting of their recoverable errors
- You can change things up if you have performance concerns –
- It is easy to pass errors up the call chain as needed
The simplest variation of go’s error handling is to return a tuple with the last value being an error type. Here are some examples from the standard library documentation
func ReadFile(name string) ([]byte, error)
You can then check if the error exists and if not, proceed as normal
// https://pkg.go.dev/os#example-ReadFile
func main() {
data, err := os.ReadFile("testdata/hello")
if err != nil {
log.Fatal(err)
}
os.Stdout.Write(data)
}
If you would like to propagate the error, you can just return it in a similar way
func notMain() ([]byte, error) {
data, err := os.ReadFile("testdata/hello")
if err != nil {
return nil, err
}
os.Stdout.Write(data)
}
And to create your own error, you just use error.New()
func notMain() ([]byte, error) {
data, err := os.ReadFile("testdata/hello")
if err != nil {
return nil, errors.New("IO error")
}
os.Stdout.Write(data)
}
Isn’t that so easy! Using errors in go is easy and satisfying! It’s also common to return a boolean instead of error in the case that there’s only one obvious way that the function can fail. Here’s a completely bogus example exhumed from by behind.
func getCount(value int) (int, bool) {
if value == 0 {
return 0, false
} else {
return value, true
}
}
The not so good
Unfortunately, nothings is perfect and go is no exception to this fact of life. If go’s best benefit is that it’s easy to read, then obviously some verbosity is required. Another fact of life is that when something is a convention, it can be ignored entirely, in the case of go error handling it means you can get strange behavior if you’re lazy. Finally, my experience with go-routines has been a bit disappointing.
There’s some flaws in errors
The verbosity may already be clear to you by these examples – the idiomatic way to handle errors requires some boilerplate at every level of the call stack. Go may be famous for its syntax and go-routines but it’s infamous for this boilerplate…
if err != nil {
log.Fatal(err)
}
It’s not much but it really adds up when every function that can fail requires these 3 lines. More sophisticated languages like rust have added magic symbols to save us from carpal tunnel syndrome. It’s so bad in practice that it’s many people’s first and strongest frustration with the language, some people have even made special macros to workaround this issue. Nonetheless, I wouldn’t say it’s really a huge deal, it’s boring but it’s clear and effective but I would be dishonest if I didn’t say it bothers me sometimes.
Errors don’t stop you being stupid
One major downside to go’s error handling is the fact you can just not do it. You are responsible for checking an error and handling the result appropriately and if you do not, you are able to operate on incomplete or misleading information. Here’s a snippet from the standard library
func FindFilesOnPaths(paths []string, files ...string) []string {
var res []string
for _, path := range paths {
for _, fn := range files {
fp := filepath.Join(path, fn)
ok, _ := FileExists(fp)
if ok {
res = append(res, fp)
}
}
}
return res
}
The underscore (_)
in this snippet shows that a variable is being ignored and in this case it’s the error value for FileExists. It is silently ignoring the errors! Do I need to explain why this is bad? If you use this function you may silently not get information you asked for which could lead to a crash if you assume it’s going to be there.
This isn’t an isolated incident, it’s common to return half-complete values in go. The implications are quite broad, you cannot trust functions to always give you correct information, and you must be disciplined enough to always account for errors. The language places the full burden on you not to be lazy and be considerate of the caller. Admittedly, this cannot go catastrophically wrong like in C, but it is nonetheless a genuine flaw you will have to account for.
Go-routines suck
Alright, I’m going to walk that back a little bit, I’m sorry go fans I didn’t really mean it. Go-routines don’t suck per-say but they are more limited than I hoped. The overall design is quite nice, you can spin up a go-routine with zero effort and there’s first class message passing system to hand over the results. With that aside, they have one significant flaw – they’re single threaded(edit: it turns out they aren’t but they still choke up for memory-bound tasks). It doesn’t sound like a big deal and it may not be for you but for advent of code and other memory-bound tasks it makes them useless.
Single core devices are not manufactured anymore, and seeing a whole concurrency ecosystem built around a just one thread seems rather archaic for 2024. (Edit: this would be archaic if it were true) I’m sure they’re fantastic for scalable programs that are I/O bound like website back-ends but for anything else that isn’t inherently vertically scalable and IO bound, it does nothing.