Go: a fractal of bad design

Just to get this out of the way: I like Go. I really do. And I'd like to keep it that way but recently that has been getting more and more difficult for me. I've been programming in Go for almost 10 years now. I worked with it professionally for 5 years. And I really want to keep using it. But I think the best way of describing my feelings about it lately would either be tired or disappointed. Partially because of what it is and partially because of what it could have been.

See, I often tend to get angry at technology. But it's mostly because I know we can collectively do better or because I know something had a lot of potential that ended up being wasted. But I really tried to be fair here instead of getting mad and give Go some leeway even if it's just because of the cute Gopher. But I just can't lie to myself anymore. The language has multiple problems which could have been avoided. It's time to get out of denial and be propelled all the way past anger and bargaining to acceptance with the grace of a well-meaning but ultimately misguided Gopher stumbling on a misplaced if err != nil statement.

My gophers.

Yes! A cute Gopher!

All right, let's roll the clock all the way back. It's 10 years ago. I remember sitting and thinking that the programming language scene was quite stale and that I wasn't sure what new language I should learn (my discontent with Python and other languages that I've been using has been growing at the time). Nothing seemed interesting enough. And then boom! I found out about Go. I was really excited! When I first tried the language it was honestly a breath of fresh air in my opinion:

  • Concurrency! Channels! Communicating sequential processes! Gophers running around like crazy! What's not to like?
  • Reasonable tooling out of the box! Just run your tests, format your code, easily cross compile!
  • Actually FAST compilation! Admire Rob Pike live compiling the entire toolchain on stage in seconds!
  • Compiled to a single binary! Just grab it and lob it onto another machine! Easy to distribute!
  • Not too fast, not too slow: reasonably fast, which is good enough for me!
  • Statically typed! Bugs be gone!

It just felt much more enjoyable to use than other languages. I'm not just writing those things down because this is how the language is advertised, no! It was certainly much less of a hassle than let's say Python (people love moving from Python to Go), the concurrency did indeed seem nice, it was truly easy to cross-compile for my Raspberry Pi (which was my server at the time), it did run reasonably well on it, the community was quite energetic and optimistic... it really seemed cool, fresh and fun to code in!

As a new user I was immediately bombarded by phrases such as "idiomatic Go", "the Go way" and "simplicity". I remember watching a lot of talks by Rob Pike (if not all) and what was said in them really resonated with me. A lot of the time it was pointed out how the language is simple and easy. Designed for newcomers. Let's do away with all the tiring and complicated stuff from other programming languages. Just write a for loop, an if statement, don't overcomplicate things! Do not communicate by sharing memory; instead, share memory by communicating! All of this genuinely sounds great. The Go community loves using all of those phrases and so did I (and still do to be honest)! I think a lot of us were at some point tired by cumbersome and complicated code in the past so this is a great sales pitch if you want to convert developers to use your language.

Cracks in the facade

Unfortunately the longer you use the language the more you realise those statements are, let's say... optimistic.

I'm guessing you've heard the phrase death by a thousand cuts? I think with Go this is exactly what happens. See, initially it's all great and you automatically agree with all the aforementioned statements. But over time you start questioning things. The longer you use the language the more you realise that the language isn't actually that simple, there are a ton of footguns in it. For all that so-called simplicity coming from just writing if statements to check for errors and for loops to filter slices... you can actually easily make silly mistakes mindlessly outputting the same code over and over again like a robot it turns out. And it's not necessarily very readable, at some point your brain just filters out all the boilerplate, sure, but if your brain just filters it out what's the point of typing it out? Just to possibly make a mistake in it?

You can say: use a linter! Well, all right. But why leave space for errors in the first place? A lot of the code we write in Go is written just because it has to be written and has nothing to do with what we are doing e.g. endless manual error handling. Using a linter just deals with the symptoms and doesn't solve the underlying problem. Languages should make making mistakes difficult.

Over time you encounter more and more really odd edge cases. Edge cases which often stem directly from conscious design choices which were supposed to make Go simple (slices come to mind). In reality the complexity is hidden away and invisible, but still there. I'm not entirely sure if that's the right way of helping programmers who:

...are Googlers, they’re not researchers. They’re typically, fairly young, fresh out of school, probably learned Java, maybe learned C or C++, probably learned Python. They’re not capable of understanding a brilliant language but we want to use them to build good software. So, the language that we give them has to be easy for them to understand and easy to adopt.

If you want to actually accomplish this goal you would need to make the language predictable. We will soon see that Go is anything but predictable.

A Gopher contemplating the five stages of grief.

Over time there was quite a lot of odd behavior in the community as well. I think the best way to describe it is... "cope", if you know what that is. The absolute denial that generics are a useful feature comes to mind. I'm sure those people would also rather use arrays, slices and hash maps which aren't "generic" and require you to .(TypeAssert) the values you get out of them all the time. Want to create some data structures e.g. a heap? Good luck making a type safe library out of it! See, Go is a simple language so it can't have generics because that would make it no longer simple. It's the error prone code with no type safety which forces you to put type assertions all over the place or code mindlessly generated from unreadable templates that's simple!

Another thing comes to mind as well: absolute denial that good tooling around dependencies enabling proper versioning is needed. I remember people saying that programmers should simply never break backward compatibility in their libraries. This statement reads like something written by someone who has never actually worked with anyone else's code. We finally moved past all of this but I'll never forget how stubborn people were just to prove some kind of a point.

But I'm really getting ahead of myself at this point. First let's create more setup for the last section.

Designing a programming language

We looked at some of the ways that people talk about the language and some of the automatic reactions exhibited by the community that this results in.

Some other word which people often throw around as a positive adjective in the Go community is "opinionated". Well, fair enough, but I'd like to point out that usually being very strongly opinionated means that you refuse to accept very reasonable criticism and reject quite a lot of good ideas, replacing them with your view on how things should be. Being slower to course correct may mean that you'll never get to do it because, for example, your language promised everyone backward compatibility forever.

I feel like I'm meandering quite a lot with what I'm saying. Perhaps let's try to slowly head to where I'm trying to get.

I'll cite a certain article which was at this point written 12 years ago, yes you now remember that the year 2012 was 12 years ago, let's stay on topic, don't worry about it now, worry about it later, please keep those statements in mind as you continue reading:

A language must be predictable. It’s a medium for expressing human ideas and having a computer execute them, so it’s critical that a human’s understanding of a program actually be correct.

A language must be consistent. Similar things should look similar, different things different. Knowing part of the language should aid in learning and understanding the rest.

A language must be concise. New languages exist to reduce the boilerplate inherent in old languages. (We could all write machine code.) A language must thus strive to avoid introducing new boilerplate of its own.

A language must be reliable. Languages are tools for solving problems; they should minimize any new problems they introduce. Any “gotchas” are massive distractions.

And just to be clear: when you take Go out of the toolbox it's an actual useful hammer. You can put nails in the wall with it. It doesn't have a claw at each end. I put countless nails in the wall with it. It's just that it could be a better hammer, which doesn't make you hit your finger so much and doesn't require reading the manual so often to operate.

If you haven't read that article it would be useful to do it at some point to provide more context for this one. At least read the beginning of it.

I actually managed to hit my finger with a hammer yesterday, this isn't a bit for the article.

Now that we read all of those: so what are the pain points in the language? What could have been done better and save me and probably many others a lot of frustration at times? I'd like you to keep another thing in your mind as you read on: I've seen a lot of bugs that stem from the problems I'm about to mention.

Ok let's finally move on to the bit which is much more or much less of a rant than the preceding amalgamation of letters, depending on the point of view. What follows is a list of debatable choices in the Go programming language. To keep this shorter and make it more readable maybe let's do bullet points.

Maybe let's do bullet points

  • Writing if err != nil every single time is tiring.
    • I Have seen people introduce bugs by making a mistake and typing if err == nil instead.
    • If people have to mindlessly write the same thing over and over again without changing it then the implication is that it's boilerplate which should be streamlined.
    • There were some ideas to fix this over the years such as the proposal from rsc or the proposal to add more keywords but none of them got anywhere. I'm not saying those particular ideas are good ideas but there are ways to improve this situation.
  • Returned errors can accidentally be ignored as checking them isn't enforced, you just get a loose separate return value.
  • Returned errors can be ignored on purpose by using the underscore e.g. foo, _ := bar() with the reasoning "this code will never fail".
    • The standard library trains people to do this by proudly stating "the returned error is always nil" in the documentation of some functions. Then people do the same thing somewhere else, things change and the code blows up in their face. Why are we so keen on having people skip error checks? Are we implying that they are annoying to write by any chance?
  • There is an expectation that errors will be wrapped manually by writing incantations similar to return errors.Wrap(err, "...").
    • Why isn't this automatic if people consistently do it everywhere? We could automatically annotate them and maybe call the thing we annotate them with something revolutionary for example "a stack trace". Patent pending.
    • I have seen accidental errors.Wrap(nil, ...) in the wild (not nil verbatim but coming from somewhere by accident). The most common library for wrapping github.com/pkg/errors enabled this error for years by returning nil if nil was passed to errors.Wrap(...). Why wasn't passing nil to it an error?
    • I'm listing this here as a problem with the language as using this library everywhere was caused by the language itself lacking features. The Go programming language being deficient is directly responsible for those bugs.
    • After a long time %w was finally introduced but again it seems like the way of wrapping errors with the highest potential of making mistakes when typing it out repeatedly was chosen. Also it feels like a way of wrapping that is the most annoying to type (at least for me) was chosen on purpose.
  • Lack of a sum type which is used for returning errors. That type is called Result in many languages.
    • That type would force people to check errors if nicely integrated into the language.
    • Returning a naked error as the last function argument is a non-solution and a cop out in comparison.
  • Pointers can be nil resulting in confusion both when accepting parameters and returning things from functions.
    • Can a parameter to a function be nil to induce some kind of a default behaviour? No idea, we have to check every time.
    • Can a function return nil? No idea, we have to check every time. I use a convention that it can't unless a bool is returned alongside the other value but not everyone follows this convention.
    • The fact that people return bool alongside the value proves that the problem exists and this isn't a genius solution to it contrary to what people think. You can forget to check this bool, all problems from the section about errors apply, you rely on people following conventions etc.
  • Lack of a sum type which solves this problem and is nicely integrated into the language. In other languages this sum type is often called Option.
    • Again, returning a naked bool is a non-solution and a cop out in comparison.
  • Checking if v != nil on an interface doesn't actually check if the underlying struct is nil leading to bugs and panics.

    func foo(value SomeInterface) {
        if value != nil {
            value.DoSomething() // underlying struct may be nil, possible panic
        }
    }
    
  • Lack of enums.

    • It is a good and useful feature, there is no way to argue against it.
    • The best proof is that people, including myself, keep hacking around the problem by introducing things like this (those solve several problems but not all of them obviously):
      type MessageType struct{ string }
      
      var (
          MessageTypeReceived = MessageType{"received"}
          MessageTypeSent     = MessageType{"sent"}
      )
      
  • I remember reading somewhere that Go decided to throw the last 30 years of type system development out of the window and happily declared victory like it's a good thing. No, if something increases the likelihood of making mistakes then it's probably not the right thing to do. And it probably means that it actually doesn't make the language simpler to use, on the contrary!

  • Incorrect switch statements not covering all cases are common, sure you should have tests but you know very well what the real world is like. This directly relates to the lack of enums.
  • The way slices work seems specifically designed to have people shoot themselves in the foot not with a gun but rather with a nuclear bomb.

    • I don't even want to start on this topic as it has been discussed ad nauseam. In fact an article about it was posted just a few days ago. Go and read that article instead.
    • From the comments on that article: what does this print, if you have to think about this for longer than a second then this feature of the language is broken (here is the playground link):
      a := make([]int, 0, 1)
      b := append(a, 1)
      fmt.Println("a: ", a)
      fmt.Println("b: ", b)
      c := append(a, 2)
      fmt.Println("a: ", a)
      fmt.Println("b: ", b)
      fmt.Println("c: ", c)
      
  • The authors of the language claim that zero values of all types e.g. structs are and should be useful. This is false.

    • Constructors, such as they are, can be bypassed by directly instantiating structs e.g. v := Foo{}. This will likely bypass critical validations without anything that can be done about it.
    • I have a distance sensor in the cupboard behind me. It measures the water level in a water tank which is used for misting my vivarium. Due to physical limitations of the sensor as well as the physical layout of the whole setup there is NO way that a result "0 meters" is valid. Let's say I create struct DistanceReportedByMySensor { meters float32 }. A zero value of this struct isn't valid.
    • Let's say I'm measuring apples and I have struct AppleSize { meters float32 }. Apples can't have a size of zero. A zero value of this struct isn't valid.
    • All right, let's say I have a thing which grabs metrics from that vivarium. It needs to be initialized with an address or it doesn't know where to get them from. A zero value of a struct which grabs those metrics isn't valid. It just isn't. There is no way to magically create meaning where there is none.
    • Because of all of this people end up writing IsZero() bool methods on structs and then you end up having to call that function everywhere you take that struct as a parameter.
  • Inconsistent behaviour around zero values:
    • A zero value of slices is useful and can be appended to.
    • A zero value of maps is useful to convey some information (nil) but can't be written to. You need to initialize it.
    • Accessing uninitialized slices with my_slice[...] panics. Accessing uninitialized maps with my_map[...] returns a zero value of the type contained within it.
    • Slices being either nil or empty can be confusing.
    • Maps being either nil or empty can be confusing.
    • Receiving from a zero value of a channel (nil) blocks forever. This is an interesting choice. How is this useful?
    • Some of these contradict the point about zero values being useful. I'd say the behaviours are inconsistent.
  • I believe maps and slices will never automatically shrink so the memory will never be reclaimed, this can be confusing to newcomers.
  • Initially no generics in the language.
    • This resulted in heaps of type unsafe code.
    • The language had no way to create new type safe data types for example, code generation doesn't solve this as most people won't play around with that. They will just use a type unsafe library. There is nothing "simple" about this.
    • This resulted in convoluted code generation setups which were way more complicated than just having generics.
    • Saying that generics are a bad idea seems odd when e.g. append itself, a base feature of the language, obviously needs to be generic and everyone expects it to be generic.
  • I will skip all complaints about generics because I have no heart to voice them here after we begged to get them for years.
  • The standard library is a bit of a mess.
    • Initially no generics in the standard library.
    • Do you really think that math.Round should only accept float64?
    • The standard library is now a mess with only some things being generic.
    • Duplicate functionality exists now because of this e.g. packages sort and slices.
    • Bolted on context.Context in some places but not in others, sometimes contradicting the best practices outlined for it.
    • Weird global HTTP clients.
    • Inconsistent naming. Being confused about calling things addr or address comes to mind.
  • The standard library panics in unreasonable situations.
    • There are many examples of this, the strings package comes to mind.
    • Directly contradicts what the language authors are saying.
  • Builtins such as len or cap shouldn't exist and should be methods on slices and other relevant types instead to avoid stealing those identifier names from me.
    • Eventually max and min were added as builtins providing duplicate functionality which exists in the standard library.
    • You can have a look at other builtins, some are truly odd.
    • It's easy to misuse copy by forgetting to initialize the target slice or initializing it incorrectly.
  • Named return values shouldn't exist.
    • They are a weird feature which just adds another way of doing things and leads to bugs.
  • Lack of impossible to modify variables, or whatever you want to call those (I'm avoiding the word "immutable" as well as the word "constants" as that is something else in Go).
    • This would obviously reduce errors, most assignments are in fact never changed.
    • Right now I have no way of creating some kind of a global configuration const that truly is a const if it requires initialization. This leads to problems when giving people access to "constants" which aren't constant in libraries.
  • There are multiple ways of instantiating with new, make, &Struct{} etc.
  • There are multiple ways of declaring variables e.g. var x int = 3 or x : =3 or var x = 3.
  • Both = and := exist which can lead to bugs in nested code blocks. This relates to the previous point.
  • context.Context attempts to solve multiple problems at the same time.
  • A method can be called on a nil pointer to a struct so that the receiver is nil. This is literally equivalent to begging people to create buggy code.
  • The lack of operator overloading or disabling them.
    • Unfortunately == is often not the correct way of comparing types which ends up in people writing Equals() methods. Once that happens bugs caused by people accidently using == on types which require Equals() (or similar) methods to be called are common.
    • I was long in the "you should never be allowed to mess with operators" camp, however: why can I have an operator for comparing values of type string and not values of type DogName if I want to compare them differently e.g. ignore case?
    • Google itself uses magical "pragmas" internally to forbid comparing some things and to try to patch the language. Is this simple? Is a new programmer supposed to understand how those work internally?
  • Can't override the hash function in hash maps. Is this a massive problem? No. Is it annoying sometimes? Yes.
  • The performance is good until it isn't and then you have a problem.
    • This doesn't always relate to the garbage collector, sometimes it relates to the custom compiler stack.
  • GOPATH was a mistake and a massive pain.
    • Why was this a problem? Because it's not a problem at Google and therefore there was no incentive to do anything about it. This follows a pattern of behaviour around Go language design.
  • Lack of proper dependency versioning resulted in a myriad of custom tools.
  • It took years to design Go modules.
    • I never had this happen in other languages but I had modules explode on me in ways so spectacular that go mod why or go mod graph couldn't figure things out.
  • Both go fmt and goimports exist.
    • Imports aren't sorted unambiguously if they have newlines between them (which often happens with IDEs) so then you use a custom tool anyway.
  • There is no ternary operator in the language.
    • This forces you to write an extra function to avoid creating and then modifying a variable in an if statement which can lead to bugs.
  • Constructor functions aren't associated with structs they create.
    • This sometimes creates confusion and makes them hard to find.
    • Go authors suggest themselves that this would be a good idea by displaying constructor functions next to the types they create in the documentation (go doc/pkg.go.dev) and not all over the place.
    • This can be more widely phrased as "no associated functions".
  • The lack of explicit interface implementation can be confusing.
    • Example: when using a library you have a function which accepts an interface and presumably a bunch of things implementing it. It's not obvious right away what to put in there. Sometimes those things are in a different package and then you have a problem.
  • Can't typecast arrays/slices, you need to use a for loop to go from a slice of structs to a slice of interfaces for example.
  • For a very long time for loops used variables which weren't scoped to a particular iteration leading to bugs. This was one of the most common mistakes and has been fixed but not without endless debating.
  • Both interface{} and any exist.
  • It's easy to forget to call defer on something in order to close it. This is similar to how errors can be forgotten about.
  • It's easy to make a mistake thinking that defer is scoped to a block and not the whole function.
  • Sometimes string consists of bytes and sometimes runes.
    • When iterating over them with a range loop we get runes and skip to correct rune indexes in bytes. The string is interpreted as containing UTF-8 encoded runes.
    • Calling len on the same string will return the number of bytes.
    • Accessing a slice e.g. slice[1] will also return a byte.
    • In general a string may or may not be valid UTF-8 encoded Unicode.
    • If you range over a string containing some valid UTF-8 and some invalid UTF-8 some of the runes you get in the loop are invalid. There is no obvious error available to check, you have to remember to validate the runes separately.
  • Not a language complaint but something that may highlight why Go is a bit weird in some ways. If I remember correctly the official library for protobuf makes a questionable decision of panicking if there is a conflict in proto definition names. This will obviously never happen at Google but can happen anywhere else.

I think it's time to stop since it's taking me more than 30 seconds to come up with each one of those now. I tried to keep the standard library complaints to a minimum since in some ways that's not exactly a language complaint (in reality it is) but I could dredge up a lot more issues there sadly.

Again, one can try to dismiss a lot of those complaints by yelling "linter" or "good practices" loudly. And again: I'd rather have someone design my electric coffee grinder in a way that stops me from putting my fingers in there when I'm sleepy in the morning rather than trying to come up with complex mechanisms which are supposed to detect if I put my fingers there and procedures minimizing that risk that I need to follow.

And now the conclusion

What's the point of writing this down? I'm not sure. The problems won't go away. It's not like I want to stop writing Go or anything like that, in fact I just wrote a quick project in it yesterday. I've been using other languages more recently to change things up though. I enjoy writing Rust quite a lot despite banging my head against the wall with the borrow checker and the type system sometimes. But it's certainly time to stop lying to myself that all those little things that are annoying are somehow good design just because they were a conscious decision. I guess that I wanted to finally write all of this down to get it out of my system.

Is the title of this article overly dramatic? Of course it is. But I always wanted to write an article which has that phrase in the title.

Epilogue

I just remembered something and want to come back to it. I remember watching the "Go Proverbs" talk and in that talk Rob Pike said those words:

Don't just check errors, handle them gracefully.

Unfortunately he forgot to underline his point by including the code which Go programmers use for graceful and sophisticated error handling in 99% of all situations on the next slide:

if err != nil {
    return err
}

Please direct all comments, complaints and clarifications relating to this article to the following email addresses: r@golang.org and rsc@golang.org.

2024-12-03