Golang and Why We Can't Have Nice Things
Published on 2021-05-13
I have now worked with Go enough to consider myself an intermediate Go programmer. I have been using Go for over a year and worked on multiple projects with it. I even advocated using it on some projects. After all this time working with Go it was only about a week ago when I realized conclusively that I just don't like it.
It was a small problem really, but it was the straw that broke the camel's back. I was writing a CLI in Go, which is one of its common use cases, and I wanted to check if the user input was one of a small list of valid options. To do that I had to iterate through the slice checking each element and save the result in a variable. So this Python:
if option not in options:
print("such beauty")
Became this Go:
valid := false
for _, opt := range options {
if option == opt {
valid = true
break
}
}
if !valid {
fmt.Println("such length")
}
But wait, I know your fingers are warming up to type into my non-existent comment section: just use a map! I know what maps are. If I used a map it would just move my problem. You see, I also want to display my list of options to the user in a comma separated list and to use the Join
function I need a slice. Now in another language if I had a map/dictionary I could easily get an slice/iterator from its keys and everything would be great:
print(", ".join(options.keys()))
But in Go I have to do:
optionsSlice := make([]string, 0, len(options))
for option := range options {
optionsSlice = append(optionsSlice, option)
}
fmt.Println(strings.Join(", ", optionsSlice))
This is just one small thing of course but this is just how Go is. Any nice little convenience function you can think of is gone and you better believe you are going to be re-implementing the same basic stuff over and over again. You see Go is your buddy who has figured out how to make his life simpler; "You don't need a set bro, maps just do like the same thing". If you were to go over to Go's house and open their cabinet you would find not cups but only mugs, because mugs work for hot liquids AND cold liquids so mugs are better than cups. You don't really NEED cups. Having only mugs makes the cabinet much simpler. If there were cups in there someone might open it and get confused because, you know, there are different kinds of things. How would people know which one to pick?
This is why Go has decided we shouldn't have these things: simplicity. Go is lean. It is said that Go's code, while more verbose, is ultimately more clear because the developer's options are constrained. When I hear this I feel like I am living in some sort of strange upside-down universe. In Python if you want to get a list of keys from a dictionary there is one-- and only one way obvious to do it and it is plainly clear what your intent is. In Go I could have done it in a number of ways and there are even common pitfalls that may lead to slower performance like:
optionsSlice := make([]string, 0)
for option := range options {
optionsSlice = append(optionsSlice, option)
}
still compiles and works just fine but it will be slower because there are more memory allocations. I did a quick benchmark and found that this is ~3x slower with 100,000 keys. Will this really matter that much in practice? Probably not, but re-using standard implementations raises all boats. It makes for faster, shorter, and clearer programs. Performance is one of the justifications for Go's extremely slim feature set. This makes no sense because utility functions could easily only be included in the binary if they are used and they will always be better than any hand-rolled implementation.
You are also unable to implement these yourself or install a package that implements them. Go has doubled down on things being built into the language over an expressive language that can be used to implement standard functionality. This is certainly... a choice. Because of the lack of generics, slices and maps are magic built in things and you can't cleanly make functions that operate on any slice or map. I am sure you can technically do it with interfaces or even maybe unsafe but it would be a kludge. I don't want to go down the rabbit hole of generics in Go but where things stand right now Go's core data structures are special built-ins which means it is on the language to give us nice tools to work with them.
Now you may be saying that I am being unfair comparing Go to Python. Python is famously lauded for its beautiful clean syntax but the performance is slow. Go is web scale! We can't be using nice pretty syntax, like filthy casuals. I see that argument and raise you one Rust; a language more performant than Go that actually gives you nice tools that you want to use.
Slice contains:
if options.contains(&option) {
println!("such beauty, such performance")
}
Keys of map:
// Not so pretty but clear and the compiler won't let you get this wrong
println!("{}", options.keys().cloned().collect::<Vec<String>>().join(", "));
// With itertools we can be pretty too
use itertools::Itertools;
println!("{}", options.keys().join(", "));
/** It's almost like making an extensible, expressive language
* results in people adding new and wonderful things
* that don't increase overall language complexity because only
* the people that want to use them will add them.
*/
I know Rust is more challenging to learn than Go but this is almost entirely because of borrow checking. One could imagine a garbage collected rust with many of its other performance characteristics. When I first picked up Rust it drove me crazy. I had to learn a lot before I could even begin to write programs due to the borrow checker but the more I learned the more productive I became and the better my programs became. When I first picked up Go I loved it, it has great tooling, great concurrency support, and compiled down into convenient compatible binaries. However, the more I used it the more boxed in I felt by its arbitrary limitations.
That's what they are at the end of the day: arbitrary. Go prides itself on being a pragmatic language, in not falling in love with fancy code and just getting the job done simply. But in my experience it is one of the most strangely philosophical languages I have ever encountered. I want convenient tools to help me work and Go tells me no, it is simpler not to have them. This is not simpler by any everyday human definition, it is simpler in the context of Go's philosophy. Here's what's pragmatic for me: sometimes a contains
method is just a contains
method. It is not the thin end of a wedge of complexity that will turn your language into shudders Java. The programming goblins won't come in the night to take you away because you found the index of an item or split an array with a single function call. Other programming languages are fine. They almost all have nice things and people use them every day and yet each morning the sun rises.
Though our industry has a reputation for moving fast and innovating the truth is the opportunity to create a major programing language only comes along a few times a generation. The number of languages that get first class support for SDKs, are easy to hire for, have a large community of active maintainers, and a rich ecosystem of educational materials and support is actually pretty small. Each one has been pushed into it's place by a monumental effort on the part of a community, a company, or both. I am just disappointed that all of that effort has been spent on a language that is essentially hostile to developers. Had Google not thrown its considerable weight behind Go, who knows where all of that effort could have been spent? While Go has been able to use the lessons of programming languages past to avoid many of our past missteps in language design it also gleefully ignores all of the positive lessons we've learned. Go brings very few new things to the table and is in many ways a step backwards.
I want to love Go. I will still choose it for certain projects because, when considering many factors, it still comes out ahead for certain use cases. It has so many great things: composition over inheritance, convenient built in concurrency support, compiling to portable statically linked binaries, excellent tooling, and so much more. Go would be a great language if it weren't for the language part.