Reconsidering Go

Reconsidering Go

I've had an entire arc with respect to Go. I went from despising the design of it, and peremptorily refusing to use it, on the basis of the famous Rob Pike quotes and Amos's inflammatory articles about it (one, two), to absolutely loving Go as a language. Here are some random thoughts as to what I like about it now, and how my feelings have changed.

The agentic coding shift

The first shift, the one that got me to try Go out at all, was thanks to the new affordances made possible by AI coding agents when it comes to the mechanical process of writing code, and the new perspective and experiences AI coding agents can bring to the software development lifecycle as a whole.

It turns out that verbosity isn't really a problem when LLMs are the one writing the code based on more high level markdown specs (describing logic, architecture, algorithms, concurrency, etc), and Go's extreme simplicity, small range of language constructs, and explicitness (especially in error handling and control flow) make it much easier to quickly and accurately review agent code.

This no longer being a problem also means that Go's incredible (IMO) runtime, toolchain, and standard library were no longer marred by the boilerplate and limitations of the language for me either, which meant that I could really begin to appreciate their brilliance.

The gradual broader shift

Beginning to appreciate the Go runtime, toolchain, and standard library has begun to precipitate a general shift in my thinking around langauge design, though. Now that I've had some Golang exposure therapy (including listening to talks from Rob Pike like "Lexical Scanning in Go", "Concurrency is not Parallelism", "What We Got Right, What We Got Wrong"), I'm beginning to realize that I actually like Go for a lot more reasons than just its runtime. This has sort of happened alongside a disillusionment with Lisp (one, two), so go read those if you want the negative image of this take, but here I'm just going to focus on what I've really truly come to absolutely love about Go.

  1. Structurally-typed interfaces. This is one of my favorite aspects of Go, because it was a pain-pont for me in Rust. I love the fact that anything that defines methods with the right names and signatures can be accepted by an interface. It not only cuts down on boilerplate, and shuffling things around packages or copying and pasting types from libraries you want to use just to make them compatible with your types (since most languages with nominally-typed interfaces/traits won't let you define them on foreign types), but it allows for the same thing Julia's multimethods allow: un-planned-for interoperability. Any method or function is compatible with any type that would work, whether or not it was planned for; any type is compatible with any function or method that makes sense, whether or not the designer knew about them ahead of time. It allows for really powerful composition of different libraries.
  2. Errors as values. A lot of people complain about if err != nil, but I really don't see the problem. The benefit of Go's error handling is that the control flow is extremely clear. There's no non-local jumping, no wondering where an error will end up if you throw it, or being surprised by an error from deep down the call stack at a high level catch or, worse, being surprised by an uncaught, unanticipated error. If something can cause an error, you immediately know from its function signature, and when you call it, then you have to destructure that return value, and if you do that, then you've declared an error variable, and since unused variables are compiler errors, you have to do something with it, and all you can do about it is use traditional, immediately locally visible control flow, to decide what happens. That's clever language design, if you don't want to add sum types, to strongly encourage people to deal with errors. And then there's the error types themselves: the easy with which you can define an error type — unit sentenel errors, or full error structs that can contain arbitrary metadata — is equivalent to that of any other language; you are not forced or encouraged to use stringly-typed errors. Even better, fmt.Errorf is a powerful primitive for building actually meaningful backtraces for errors: instead of just bubbling up a contextless error from somewhere deep, deep in the call stack using the ? operator like Rust, or dumping massive unreadable stack traces on you like languages with exceptions, Go provides a system where you can wrap an error you recieved with some text providing important context for where that error was actually found, and as the error travels up the call stack, it will accumulate more and more context until, when it reaches the top level, it's got a fully human-readable narrativ about what happened attached to it. But even better, this isn't stringly-typed, it's strutural metadata: those wrapping messages are not strings, but error types themselves, which carry their wrapping message, but also a reference to the type they're wrapping, forming a linked list of errors narrating the whole proces, and satisfies the error unwrapping interface, meaning that you can automatically check (with errors.Is), conditionally cast to (with errors.AsType), or match on (using switch .(type)) whatever the core error was. And this doesn't just work with appending string error messages either: you can define your own structured error messages, carrying any metadata you want, that can also hold an inner error and automatically unwrap to it for matching, testing, and casting. This, too, is far superior to the Rust error model, where if you try to keep track of the back trace of an error, you end up with unwieldy nested Result<Result<Result<Result<>>>> types and manually destructuring them to get at the inner value.
  3. Struct and interface embedding instead of inheritance. I hate inheritance. It is the shortest path to incomprehensible spaghetti code and horrible, terrible boilerplate. It's what ruined Java, along with not allowing top level procedures. Go avoids both of these mistakes. Yet, something like inheritence can still remain useful. So what Go provided instead was composition with the syntactic sugar of inheritance. With struct embedding, for instance, in terms not just of memory model, but language semantics, what's happening is that the structs you're embedding are simply properties on the struct having them embedded. That's it. You don't get magic overriding or super behavior, or polymorphism. That's even how you have to initialize the struct: by creating the embedded structs and passing them in as properties. But, you can call the methods of the inner structs as if they were directly on the outer struct, and you can save some typing on the struct definition, and always be assured of standardized property names for the embedded structs. It's a similar story for interface embedding. It just makes doing composition over inheritance nicer, giving people what they generally really wanted from inheritance, instead of letting the language get marred by the real thing.
  4. Generally sensible default values. I really like that, as the Go proverbs say, Go "make[s] the zero value useful": the idea that you don't have to worry about initializing a type after you've created or allocated it, because its zero value — while it may not be what you want — at least makes sense and isn't in an invalid state, is great.
  5. There is only one way to do it. I really enjoy that there really isn't language level analysis paralysis when it comes to implementing something in Go. There's never any wondering about what language feature I should use to do something, because for any one kind of abstraction or implementation you might want to do, there's pretty much only ever one obvious way to go about it. This saves thinking about unnecessary things (bike shedding, yak shaving) and it also means things are never surprising.
  6. The clarity. I love how almost everything in Go is completely explicit, perhaps at the cost of some boilerplate. It's always pretty much completely clear exactly what a piece of Go code does, usually without even needing to look anywhere else. There are few features so it's easy to keep them all in your head (fear the man who has practiced a single attack one thousand times, not the man who has practiced a thousand attacks once), the syntax is beautiful, simple, cleear, and low noise, the control flow is always obvious on the face of it, there's never any syntactic sugar you have to mentally decompile to something else.
  7. The toolchain. I love that everything you need — a profiler, a package manager, a formatter, a tester (with code coverage), a deadlock detector, a compiler, a project management system — is all in a single relatively small and extremely reliable CLI binary is just beautiful. It works so well, and it means I don't need to worry about installing anything else except gopls, the language server. It's even better that the package manager works with git repos and tags. It means that if I find a useful library I don't have to go through the horrible hastle of manually vendoring it myself, or copy-pasting it into my codebase, or just give up because it isn't on some separate package repository. As long as a package is on some kind of git forge, I can point my package manager there, with the desired version or commit hash, and it will do what I want. Even better that the package management system doesn't need a lock file: the files needed to build the project are purely specified by the project source files' imports themselves, and the dependencies of the project are fully and exactly specified (no version drift because of inexact version specifiers that you need to lock down with package.json) in the go.mod file.
  8. The runtime. Go has an incredible runtime. It can run millions of lightweight threads concurrently with a job-stealing M:N thread model similar to Erlang, it's extremely fast, it has a state of the art low-pause and low memory usage garbage collector, and it compiles to fully stand alone static binaries that don't even rely on any version of libc (no musl vs glibc or glibc versioning issues!). It also cross compiles trivially, far more easily than any other system I've used. It's basically ideal for almost any kind of systems programming above the OS, driver, or embedded level that I can think of.
  9. Backwards compatibilty. The hard backwards compatibility guarantee since 1.0, combined with the proper formal language specification and ability to precisely specify the versions of the packages you want and the built in extremely easy and automatic method for fully vendoring them and all their transitive dependencies means that Go is an incredibly stable long term platform to build on. You're not going to find yourself in Python or NPM dependency hell, ever.
  10. Doesn't shy away from functional programming. Having really excellent support for higher-order first-class functions might seem like a given now, but it wasn't when Go came out, especially since its main competitor seems to be Java. But it's not just about having them. It's about the fact that it's extremely idiomatic to use them, elegantly and effectively, as an abstraction and execution management system. See for instance the Functional Options pattern, or Go's iterators and the iter package. This is a minor thing, not unqiue to Go, but it's nice, and I like it.
  11. The standard library. The Go standard library feels incredibly complete and powerful to me. While it may not provide a user interface library like Python does, it has everything else, and far more in other more systems programming oriented areas, and to me, it standard library feels extremely well designed. Of course, people like Amos might disagree, pointing out that, for instance, Go's file system API papers over platform differences to provide a POSIX-like abstraction over whatever OS you're on, meaning that technically you aren't getting perfectly accurate information (even though the information you do get is correct and everything you try does work), or that Go's approach to paths has a special case for Windows's accepting of forward slash-based paths, instead of a universal secondary-path-separator construct, or that since all Go strings are arbitrary byte sequences, paths in Go are just strings, but at the same time Go's printing functions assume strings are valid UTF-8, so it will let you print out paths straight from the file system, but if they're weird, they won't print perfectly right. But in general, I think this is a difference of goals: I don't like how Rust perfectly models all of the nuances and complexities of representing all file systems and paths across all platforms in a single complex type system, forcing you to do an immense amount of type construction and pattern matching boilerplate just to get anywhere; and I don't like that Rust has an entirely disparate set of libraries for each platform's file IO that are conditionally compiled in and out, and which have totally different interfaces, forcing you to do platform specific conditional compilation just do manipulate files. I like that Go's standard library by default provides a reasonable, consistent, and very standard and well understood abstraction for it all, and accepts the fact that there are a few edge cases that aren't perfect, instead of foisting all the complexity up front onto you, when chances are you wont' care. I like that it uses runtime switching by default over conditional compilation, so that you have more choice and clearer code over a minor performance boost that doesn't really matter.
  12. Finally, the concurrency model. This is something I truly, truly, deeply admire about Go. The fact that you get the best parts of Erlang — extremely lightweight threads that still necessarily have parallelism, and predominantly share memory and communicate via message-passing — and allowed shared memory into the equation, as well as allowing these lightweight threads to be explicitly launched and managed in groups just using higher order function calls, is brilliant. It means you can have all the performance and flexibility of something lower level, combined with the safety and lightweightness of green threads, whenever you want, but the default paradigm is still the clean, simple, safe method of CSP. Additionally, the fundamental idea of channels and goroutines is so easy to grasp, understand, and build architectures around compared to regular threads, yet so explicit, and such an actually useful and novel way of modelling problems even when you don't need parallelism, compared to data parallelism like in OCaml, Haskell, or Rust with Rayon, or async IO models, just makes me extremely happy. Concurrency as Go does it is just a fundamentally useful way of separating concerns, allowing parts of the code to operate independently without having to worry about what the others are doing, and loosen coupling, since now you communicate with message queues, which are not direct and immediate calls, but instead shared and common objects, narrow waist interfaces, so that neither side has to directly connect to the other, that can operate asynchronously so you have automatic awaiting, backpressure, and coordination. It essentially delivers on Alan Kay's original idea for object oriented programming, in some sense, far better than Java ever did, and it's also based on an incredibly elegant mathematical formalism for concurrancy, Tony Hoare's CSP, that was actually invented to make solving totally single threaded, non-parallel problems clearer, easier (or even possible) long before we started to use concurrency for parallelism, showing that it is a powerful idiom in itself. It also effectively solves the function coloring problem as well, by building concurrency into the runtime, and providing language constructs to do it that work on regular functions (you can both launch any regular function concurently, and any regular function can pause execution to "await" on another one, because the whole runtime is built for that, and there's syntax and first class values for it) instead of trying to implement it at the type system level, providing a way to start and then await on tasks in a very clear, simple, and concise way — thanks to the fact that these are built-in language concepts — without having to have specifically async functions.
  13. Resistance to feature, especially syntactic sugar, creep. This is a surprisingly big one for me. Yeah, it's sort of silly, but it's important to me. I feel like there's a point in every language's life where it begins to jump the shark, adding tons of pointless syntactic sugar just because a little extra typing or reading began to annoy somebody. It's happend with C#, it's happend with Rust, and it's happened especially with Swift. Once that starts to happen, it tends to just keep going and going, adding more syntactic sugar to keep track of and mentally decompile, more exceptions, more ways to do things. With a lot of languages, this begins to fade into, or is intertwined with, feature creep. Just look at Swift: notoriously buggy, far slower than it should be, not well supported on any platform other than Apple, and yet adding huge feature after huge feature with every version, trying to become everything to everyone. Look at this concurrency model.

Conclusion

In the end, I don't feel like Go "decreases my leverage" as an individual programmer, or "talks down" to me — at least now that it has generics. I feel, instead, that it offers me a lot of very unique and powerful tools (in the ecosystem, toolchain, standard library, runtime and concurrency model) wrapped in a language that minimizes cognitive load as much as possible, and makes complexity disappear by making everything explicit and straightforward, so that I can focus on other things instead of worrying about the language. Other things like designing a great architecture or algorithm. I think even if you have — as I do — the energy, talent, and skill to use Haskell or Rust, why do you need to, when that skill and energy could go so much further using Go, not having to worry about that extra complexity?

Yes, Go is a 90% language — a worse-is-better language. But sometimes, you have to admit that's what you need. Hell, even the much-hallowed Common Lisp, the grail-language, is itself a language of ugly compromise, a worse is better Lisp, in other words.

See also: a blog post from a PL nerd who programs in Haskell for fun about why he prefers programming in Java or Go in general