Tired-looking Golang gopher
Source

Things I dislike about Go

As someone who loves Go

Gavin Killough
Published in
10 min readJun 19, 2022

--

I love Go. From the first day I started using the language, I quickly fell in love with it. It provides incredible simplicity while maintaining stellar type-safety and lightning-fast compilation. The execution speed is great, concurrency is a first-class citizen (and that’s an understatement), the standard library has a swath of high-level interfaces that can kick-start any application with very few dependencies, it compiles directly to an executable–I could go on. Although Go’s syntactic literalism takes some getting used to when compared to other C-style languages, it feels incredibly intuitive after a little bit of use.

I come from a Java-heavy background, but have plenty of experience with C, C++, JavaScript, TypeScript and Python. Go is the first language I’ve learned that I want to use everywhere for everything. Though I hate the cliché, click-baity concept of a “product killer”, as someone who was a professional Java developer, Go feels like a Java killer. Do I think Java is going anywhere? Probably not. Do I think Go will ever overtake Java in popularity? Unlikely. For me personally, however, I can’t imagine a single situation (outside of maintaining legacy products too big to rewrite) where I would rather use Java than Go.

At this point you’re probably wondering, “wasn’t this article supposed to be about things you dislike about Go?” That is a totally fair question, and I’m about to answer it, but it’s important to understand just how much I like Go to appreciate what it takes for me to complain about it. So without further ado, what exactly is there to criticize?

Library Functions Modify Their Parameters

My first complaint about Go is something I noticed right away: Many built-in library functions modify their parameters rather than returning new results. Modifying function parameters blurs the lines between input and output and ultimately undermines the expressiveness of the code. A function’s expressiveness is its ability to convey meaning and intent clearly through its signature; the more clearly the meaning is conveyed, the more expressive the function is. Expressiveness is the most important aspect of maintainable code. There are obviously times when performance will benefit a program more than expressiveness, but from a maintainability perspective, expressiveness should always be the priority.

Why then does Go do this? By modifying parameters rather than returning new data, the Go compiler can better track the lifecycle of a given variable. Go is a garbage collected (GC) language, so any time a function returns a pointer or type backed by a pointer (e.g. a slice), it increases the likelihood the memory will need to be allocated on the heap rather than the stack. Heap allocations require garbage collection, and GC takes precious CPU cycles away from your program.

Avoiding heap allocations–and thus reducing garbage collection–can absolutely improve the performance of an application. These optimizations might benefit software rendering high FPS graphics, but for most enterprise applications and everyday services, it’s quite possible the benefit to the end-user will be virtually unnoticeable. A reasonable argument can be made that a language should minimize the overhead of its own libraries, but when speed and ease-of-use are competing objectives, one needs to be prioritized. There are already plenty of languages providing explicit memory management for high-performance use-cases (C, C++, Rust, etc.), so should Go really compromise on one of its greatest strengths (ease-of-use) to provide slightly fewer GC cycles?

As a developer, I would appreciate at least having some more expressive, intuitive alternatives that are functionally equivalent to the optimized APIs. Despite this annoyance, Go is far from the only language guilty of this, and in fact, many of the offending Go functions have nearly identical Java and C++ counterparts. Just because there is precedent from other languages, however, does not mean Go should be blameless and ultimately, this is something I dislike about the language.

To gain a few points back, a case can be made that demanding a pointer as an argument is an expressive way to request modification in Go. For that, I will concede a few points, though I have still not found a compelling way to do this with slices (and slices are often what this pattern is used with the most).

Generics

Go 1.18 introduced generics, so I am somewhat spoiled by the fact that I only had to wait a few months for this feature¹, whereas many veteran Go developers have been waiting for years. Go’s generic implementation feels a bit like TypeScript’s, and for the most part, that’s a good thing. Like TS, a developer can easily constrain a generic type to conform to several possible known types or interfaces. Unlike TS though, Go does not have to deal with the JavaScript baggage, particularly around undefined and null.

To be clear, I like generics as a language feature. Used correctly, they can improve reusability of code which in turn improves consistency and reduces the risk of bugs. When I say I don’t like generics in Go, what I mean is twofold: first, Go’s implementation of generics leaves a lot to be desired, and second, the lack of generics in the language for so long led to many ugly anti-patterns buried under the surface of numerous libraries, including Go’s standard library.

To unpack the first point, Go does not currently support generics on methods or as struct fields. The lack of support on methods is a bit puzzling as, under the hood, Go treats methods as functions with the receiver as the first parameter. If functions support generics, why don’t methods? Go’s support for embedding reduces the criticality of generic struct fields, since much of what generics are used for can be decently imitated with embedding: just keep the “non-generic” fields in a separate struct and embed the same struct a couple of times. Still, embedding is not a perfect replacement, as operations and methods need reimplementing for each variation of the outer struct. Go could shift that responsibility to the compiler rather than the developer by allowing generic struct fields, but for now we’re stuck copying and pasting.

Due to the places generics are currently missing from Go, many data-structure implementations have to use hacky workarounds with reflection, type checking and casting, to provide broad support for different types. This leads me to the second complaint. Go makes a promise of type-safety, and then immediately breaks it all over the place in its standard library through the use of the pseudo-generic workaround: interface{}. Not only does Go’s empty interface usage epitomize an anti-pattern, but type checking and reflection are often slower operations (which ironically is inconsistent with its tradeoff of expressiveness for speed in my earlier complaint). One of the worst parts about this is that third party libraries have also heavily adopted the empty interface anti-pattern, so even if Go eventually migrates all of its libraries to generics, the pattern will likely live on for quite a long time in many a codebase.

The make() function

The make() function is Go’s solution to “primitive type” initialization. Most primitives have a reasonable zero value, but in Go, maps, slices and channels are all primitive types that benefit from dynamic initialization. It’s fully possible and sometimes even reasonable to use the zero value of maps and slices (e.g. JSON operations and avoiding nil returns), but for most cases, make() is the best choice. Where I take issue with make() is that it suffers from two problems I’ve already talked about.

For one thing, make() is not expressive. Its full signature is func make(t Type, size …IntegerSize) Type, which tells me very little about how to properly use it. Even though it is technically just a function, between the special treatment it gets from the Go compiler, and its necessity for creating channels, make() is as an essential part of Go as for-loops are. Taking that line of thinking partially excuses its signature, but it would have been just as easy–if not easier–to provide NewMap(), NewSlice() and NewChan() functions which would have had no ambiguity. I’m not going to get into the weeds on these alternatives as I am sure there are plenty of strong opinions about why those choices might be problematic. Where I will get into the weeds though, is how easy it is to make() mistakes (see what I did there?).

Let’s look at make() in action. m := make(map[int]int, 10) creates an empty map with enough space allocated to store ten entries; len(m) returns 0. Calling c := make(chan int, 10) creates a channel with a ten-entry buffer; len(c) returns 0. Calling s := make([]int, 10) creates a slice with ten entries initialized to their zero value; len(s) returns 10. See the problem? It is far too easy to accidentally gloss over that important difference, whether while writing the code, or reviewing it. To get the behavior you’d expect with slices requires an additional argument: s := make([]int, 0, 10). len(s) in that case will, in fact, return 0. So rather than having more expressive, distinct initializers for these data structures, Go provides a single function with greater ambiguity and thus greater risk of misuse.

To pile onto my thoughts about make(), the second issue I have with it is its pseudo-genericism. Go doesn’t normally allow function overloading, but make() gets a special pass to pretend to be overloaded. Because of this special pass, the first parameter of make() can be one of several types. The same goes for its return type. For a language that went a decade claiming to not need generics, Go had to break a lot of its own rules to get one of its most central functions to work without them. That to me feels sloppy.

Flat package structure

I come from the world of Java. Java applications tend to have lots and lots of packages. In this world, parent packages are often just as important to the context of a class as the class’ name itself, so it was a bit jarring for Go–the “Java Killer”– to have such a flat package structure. This isn’t unique to Go. Many languages geared more toward scripting, such as Python, tend to employ more breadth than depth. Despite this being a relatively common practice, my dreams of Go being a “drop-in replacement” for Java appeared to be shattered.

There’s nothing inherently wrong with a flat package structure. Layers of empty directories (or directories containing a single file) rarely provide value in languages not explicitly designed for them–admittedly, this applies to most languages that aren’t object oriented. If, however, a flat language claims to solve the same problems as a nested language, then the flat language should provide semantically equivalent mechanisms for managing identifier visibility and scope.

Go’s simple approach to exporting identifiers through capitalization is fantastic. One less thing I have to argue about in my team’s style convention meetings. Jokes aside, as much as I like this choice by the developers of Go, the semantics of packages and the convention of a flat structure diminish the would-be value of this feature for application code. In library code, the simple concept of exported or not is perfect for defining a public API. For large applications, especially web servers, this doesn’t usually suffice.

Web servers will necessarily have packages that are never explicitly consumed by other code (except tests), instead being called by external clients and other servers over protocols like HTTP. Code in these packages will benefit as much from abstraction as any other code, but with a flat package structure, unexported abstractions will inevitably be visible to other areas of the package that have no right using them. This leads to a conundrum: should one violate the convention of a flat package structure, sacrifice readability and reuse for less abstraction, or simply let unexported identifiers be accessible in places they shouldn’t be? For this question to exist is to admit Go has a problem. Sure, flat structure is convention and not law, but convention has been wildly influential in Go’s evolution, dictating many new features that have made it into the language. So yes, this may not be an explicit feature of Go, but it is nonetheless something I dislike about it because of how strongly the Go community pushes it as a best practice.

Lack of shorthand lambdas

This one is definitely nitpicky, so I’ll get straight to the point: Go doesn’t have a shorthand for lambda functions. I know there have been proposals for them, and debates on why they aren’t necessary, but despite these considerations, the fact remains that I like shorthand lambdas and Go doesn’t have them.

Go’s function syntax happens to be short and concise. Furthermore, functions are types in Go and can be assigned to variables, which is familiar to me as someone who liked to abuse “method references” introduced in Java 8. Even still, when I’m writing in Go, there are times in which inlining a function is the most appropriate solution to a problem, yet even for one-liners, the resulting code is often clunky, especially when a return statement is needed. I don’t think anyone can convince me that func(x, y int) int { return x+y } is prettier or more readable than (x, y) => (x+y). Debate about strong typing or explicitness all you want, I’m still going to miss shorthand lambdas.²

No programming language is without fault–I really hope no Haskell people are (still?) reading this–and Go is far from an exception. That being said, like any good piece of software, Go continues to iterate and improve, assuaging some of these concerns over time. I still love Go despite these issues, and it continues to be my language of choice for most projects. As programmers, we often turn a blind eye to many of the problems of our favorite languages, but reflecting on even our most pedantic concerns about design is what tends to make our software better.

For those of you who haven’t tried Go, but are considering it, don’t let this dissuade you; it is a fantastic tool that will almost certainly improve your development life. For existing Gophers, I hope you can sympathize with my complaints, but still enjoy the language as much as I do.

Footnotes

¹ This article was written while Go 1.18 was the latest stable version.
² Updated “lambdas” to “shorthand lambdas” for clarity on 06/21/2022.

--

--

Gavin Killough
CodeX
Writer for

Gavin is a software developer who is always looking for ways to improve code quality and build expressive APIs.