Thoughts on Go
I've been learning Go recently for a work project, but I've also been doing a lot of Kotlin for the Intellij plugin that I'm (slowly) writing and thought it might be interesting to lay out some of the problems I have with Go (and maybe how Kotlin solves them).
Note
There are a million articles already to do with why Go is a good language and the problems with it and hopefully this doesn't overlap too much with them. I'm trying to come at this from a view of "people say Go is a good language, but Kotlin does the same things but better".
Just a few common ones that people say to get it out of the way:
- Go is easier to learn, because its so simple. The only slightly confusing thing I found was that defining a method on a pointer receiver is different than a pointer to an interface.
- There is only typically one way of doing something well, which means it's easy to read other people's code and review it and see if makes sense. There's even a standard set of code review things to look for on the Go wiki.
- No generics is very annoying and affects almost everything you do.
Good differences
It compiles and runs fast
Pretty nice, caches test inputs so it doesnt rerun them, etc. The only sensible way to compile Kotlin is by using Gradle which can be pretty slow, on top of the compilation itself being quite slow. Compiling and running tests for a medium-size program can take about a few seconds for Kotlin (currently working on a Ryzen 3600) but it feels essentially instant for Go.
This is of course a bit of a false dichotomy as Gradle is not just a build tool - Comparing it to Bazel would be a lot more fair but I'm not going to learn a new tool that I would never otherwise use just to compare.
I tried using a Go gradle plugin and it seemed to work roughly OK, but it had the classic Gradle issue where it's written assuming you're using a Groovy build script and doesn't work properly otherwise.
Implicit Interfaces
Kotlin has delegates, but sometimes its still easier to just have the implicit interfaces from Go. Note that this can sometimes be a bad thing (more on that below), but for the most part it's pretty nice to not have to specifically inherit from an interface to implement it.
Being able to define types in functions
This is marginally useful, but it is nice in tests or when writing nested goroutines that you just want to be able to pass some kind of data structure around but it's not relevant outside of that specific function.
Soft assertions in tests
I think the the C++ googletest is a bit nicer with assertions/expectations but it's quite nice to be able to continue on when an assertion fails but still be able to immediately stop the test as required. You need extra libraries to do this in Kotlin/Java (this isn't really a strike against it though - JUnit + AssertJ is a fairly common dependency in any project, and the Kotlin test library integrates with them fairly well)
Closures
The way the closures work is easy and intuitive. In some languages these can be a bit fiddly or have some weird syntax associated with it (especially C++), but it's very simple in Go.
Bad differences
Empty interface
A lot of emphasis is always put on the type safety of go but that only really applies when you're doing the most simple
things. Any time you want to use some other data structure (like a
queue), you have to start using 'the
empty interface', interface{}
. This is basically still just the void pointer from C.
Because there are no generics, you are forced in any data structure to just take an empty interface as an argument to
everything, then do a load of checks at runtime to see whether what the person passed in actually made sense. This means
that you really, really need to have a super high code coverage for anything touching these data structures or you
will get random failures at runtime when the library does a reflect.Type(x) == ...
deep inside a function.
Another place this bites you is when you define a method receiver for a pointer to a struct then accidentally pass a non-pointer to a function which takes an empty interface so it can be 'generic'. This will then either fail at runtime, or if you're unlucky, will just return the wrong thing and there's no clue why it went wrong because it's "implicit" and is supposed to "just work".
Both of these things mean that you end up writing a lot of tests for things that would just cause a compilation error in other languages. It's like re-implementing the type checker in the form of unit tests. Sometimes unit tests will not be enough, because you need to test that your code works well with the internal code of another library. See this issue from go-pg - to properly validate your code, you need to spin up a real Postgres database.
Because all the data structures have to take this empty interface if they want to be generic, every library will have a lot of code just behind the API barrier using the 'reflect' package to actually validate what you've passed in (at runtime), invoking a performance penalty.
This also pops up when you're using gomock in tests - you can say "when this mock is called, return this", but the thing you expect it to return might not be the right type that the caller was expecting, and this will just fail when you run the test and get some error about the return value being the wrong type.
You also can't use the empty interface for basic data types, so you need to wrap them in a struct if you just want a queue of integers or something.
Type coercions
Checking types (eg, seeing if what somebody passed in is actually valid for that function) is awkward. Kotlin does runtime type coercions nicely:
- Check if it is a type:
x is y
- Check if nullable type is not null and is the correct type:
x is? y
- Cast it safely, return null if it isn't the correct type:
x as? y
- Escape hatch (not common), cast unsafely:
x as y
.
The only thing you can do in go is either an unsafe cast like the last point (x.(y)
) which raises a panic (and
arguable has confusing syntax), use the reflect package to introspect the type, or do some other very verbose checking:
converted, ok := x.(y)
if !ok {
return errors.New("...")
}
'Flags' are bonkers
This is similar to type safety. Basically the idiomatic way to configure a library is to pass some magic string values
into a function (the builtin flag package) which then modifies some global state
inside the library. This then might fail when the library calls flag.Parse
, at some point in the future. Even Spring
autoconfiguration annotation magic isn't this weird, at least you get IDE help with that! Doing it with flags is mostly
just source hunting to figure out what to do, and it is not type safe.
A quite nice way of doing this is config for a gradle plugin - you define a set of default variables in a data class:
data class MyPluginConfig(
var outputFolder: String = "out",
var inputFolder: String? = null
)
Which they then use in the build script:
myPluginOptions {
outputFolder = "out2"
}
And then you can check the options that the user has set on this config object when you start the plugin. This is also type safe.
Struct tags
Because sometimes you need to mark a struct field as having some specific behaviour that another library can then read and change behaviour (eg, what a field will serialise to as JSON) there are magic strings that you can put on the end of struct fields that might affect the behaviour of some libraries. You need to look at documentation for the library to see what magic string is actually valid. How do you check these magic strings? Again, with the reflect package.
Kotlin (and Java) solves with annotations, in a much better way. If you annotate a field with a Jackson annotation saying that it should be serialised in a certain way, I know that if I used Jackson to serialise the field then it will use that annotation, and other libraries will usually ignore it. These annotations are checked at compile time (in Kotlin at least) to make sure that they make sense for that field.
With Go, there's just some magic string on the field, where you hope that the library you're using will use it in the way you expect. See this issue about a very commonly used library where a struct tag is ignored sometimes depending on what type of data is used.
You shouldn't have to put a load of magic strings onto a struct which are not checked at compile time, so can mysteriously have no effect at runtime and cause weird issues. This also means you just need to memorize a load of extra struct tags or have the documentation up all the time, and there is next to no IDE help for them.
There is also no support for tagging/annotating the whole struct in some way, unlike in Kotlin/Python/Rust/ReasonML.
Pointers
Not entirely sure why pointers are in the language. If there isn't memory management, why even have pointers? Other languages get around it just fine. It makes things more complicated and different libraries are not consistent in returning pointers or concrete structures from constructors.
This also means you have all the annoyances of having to put parentheses around a lot of code to make sure you're
dereferencing things properly because of operator precedence (eg (*x).func()
).
You also can't have pointers to basic types like integers unless you wrap it in a function:
// Create a pointer to an integer
func IntPtr(i int64) *int64 {
return &i
}
Why is this required?
Nullability
Somewhat different to pointers - referring more to the fact that Go (and libraries) will typically use the 'zero' value for that datatype (eg, an empty string) rather than having some other way of specifying that it is unset - like nullable types in Kotlin, or just an algebraic data type like the common 'option' type in lots of other languages.
This is a major headache. What if you're unmarshaling a string into a struct, which has a required string member, but the empty string is a valid value for that field? You need to write your own unmarshal function! What if you are checking for a query parameter in gin and you want to make sure that if they pass an empty query parameter, it is not empty? You need to parse the parameters into a map then check the thing you want is not there, AND check it's not empty!!
You can also just use pointers to everything in your struct and then check that fields are not nil
, but this is again
just extra boilerplate you need to do to construct one. It is not a 'zero cost' abstraction.
This article is a good overview of the kind of trickery you have to do just to get around a very basic problem - how to handle null fields in a database and convert it to JSON (this is also related to the 'no generics' problem as well). This just adds a load of extra boilerplate code to something which is built in to modern languages.
Test util code
In Kotlin/Java you have a separate source root for test code to stop it being used in your real code. There is no way to
'share' test code between Go test files except by putting them into a separate file, which can then be imported by your
non-test code. You can get around this by doing silly things with build tags or requiring all your test code to take a
*testing.T
but this is just annoying.
Trying to move code into a testutil package and just hoping that people don't use said test code isn't really a good solution either, because it can then cause import cycles due to the way packages work in Go (ie, not fully qualified). This is a minor one because you can get around it by changing the way you do imports, but it's still irritating when in other languages with imports like Kotlin/Python it's not an issue. You can also put your test files in a different package, but then you can't test internal functions because you can only access them from the same package.
Mocking
The only way to do mocking is to autogenerate the mock code based on interfaces. There is built in code generation for Go to simplify this but you need to do it manually and it can be a bit awkward. Code generation in Rust with something like rocket is easy and automatic.
Build system
Go basically doesn't have a build system. It seems like most people use hand written makefiles - it's 2020 and people are still doing this apparently. Even Kubernetes uses them for some things.
Every library has a completely different structure so it can be difficult to find what you're looking for when you are forced to do some source diving to figure out why your program failed at runtime.
GOPATH is godawful and has weird rules you just need to learn rather than it being "I want to compile code in this directory". This has been cleaned up a bit with modules in 1.13, but having to change your gitconfig so it can actually 'go get' private repositories was ridiculous.
Gradle lets you write your build script, and any build plugins, and your code in the same language, reuse code between them all, and publish your plugins for other people to use easily.
No stack trace from errors
Until 1.13, there were libraries that exist just to give you some kind of context from errors because they didn't by
default. This is horrible, Python gets around it with raise Exception() from e
, Kotlin will print a stack trace (if
you even need exception - which is rare - see below).
Even after 1.13, the way you wrap errors is by using magic printf format verbs with a special printf function from the erorrs package.
Error code returns
This goes across a lot of the mentioned issues - pointers, nullability, and unexpected gotchas due to bad API design. If
they fixed those, you wouldn't need error code returns, ideally you could just use algebraic data types and/or
nullable types to indicate success/failure. Kotlin does this fine - I've written a lot of Kotlin code and you only need
to use exceptions when you really can't continue (ie, panic()
).
This is on top of the common complaint about how a lot of go code is just if err != nil {...}
.
Theres also different kinds of error code returns:
- Most functions will return 'err', where it should not be nil if the function failed
- Some return 'ok', where it is not nil if the function call succeeded
No easy way to propagate errors
This is sort of a separate issue (and mainly syntax related), but in Kotlin you can do something like
fun getThing(): String? {
// return a string, or null
}
fun thing(): String? {
val x: String = getThing() ?: return null
// do something with x...
}
If getThing returns null, you can immediately return. If not, then you have a definitely non-null string. In Go you just
have a chain of if err != nil {...}
in every function to do this. Then because there are no nullable types, you need
to have error return codes in every function up the stack to propagate the error.
This is especially useful if you have a utility function that stops a request - you need to have the function check an error code, then have the endpoint check the return code from that function and return. Very verbose!
This is also an issue in tests. If I write a test and I call some incidental function that returns an error, I want to
fail the test immediately, which means I have to write a error check function inside the test, whereas if this was
Python I would just let the exception propagate, and if it was Kotlin and I was expecting a non-null value then I would
just write returnValue!!
which would immediately trigger a NPE to fail the test. No such luck in Go.
Another problem that combines with the 'very imperative' point is this kind of thing:
func one() (int, err) {
...
}
func other() (int, err) {
...
}
func oneOrtheOther() int {
if condition() {
result, err := one()
if err != nil {
panic("err1")
}
return result
} else {
result, err := other()
if err != nil {
panic("err2")
}
return result
}
}
Here you have to have two exit points from the function - which I would argue is bad practice - because you can't just
define a var result
at the top without either also defining 'err' at the top and using =
to assign both, or
definining a temporary value in each branch then reassigning it. eg:
func oneOrtheOther() int {
var result int
if condition() {
// := creates a new variable, so there has to be a temporary variable here
result1, err := one()
if err != nil {
panic("err1")
}
result = result1
} else {
result2, err := one()
if err != nil {
panic("err2")
}
result = result2
}
return result
}
In something like Kotlin this would just be:
fun one(): int? {
...
}
fun other(): int? {
...
}
fun oneOrTheOther(): int {
return if(condition()){
one()!!
} else {
other()!!
}
}
Magic comments
There are also magic comments to control conditional compilation which is also annoying and doesn't integrate nicely with IDEs. If I want to compile and run tests, I don't want to also have to set up build tags to do that.
The place these magic comments appear can also make your code not compile, eg if you forget to leave a newline between the magic comment and the package.
Unexpected gotchas in libraries
Error code returns is sometimes useful, but sometimes a library will not return an error and will just return the 'zero value' for that type instead, which is useless.
type NumberPost struct {
Number int64 `json:"number"`
}
var req = new(NumberPost)
err := c.BindJSON(req)
What happens if you send null
to this endpoint? You get a struct {Number: 0}
and no error is returned.
Very imperative
This is related to just about every other point, but you end up having to write very imperative code in Go. The new hotness is using functional programming idioms to make your code shorter and less error prone, but with Go you need to write so much code to do simple things.
Say you have a function that takes an array of integers and returns the ones bigger than 10. In Kotlin:
fun filtered(inputs: Iterable<Int>) = inputs.filter { it > 10 }
The same in Go:
func filtered(inputs []int) []int {
var result []int
for i := 0; i < len(inputs); i++ {
if inputs[i] > 10 {
result = append(result, inputs[i])
}
}
return result
}
Which one is easier to understand? Would you even bother writing a unit test for the first one? This is another case where you need to write a lot more tests just to check something that would be 'obviously correct' in other languages.
This verbosity also carries over to writing unit tests - writing fairly simple unit tests can be tens of lines long.
A lot of stuff which takes one statement in other languages can take multiple lines of code in go, and if it's something
you need to avoid runtime errors (eg, a defer close()
) and you accidentally miss it, it's a bit irritating (though
luckily IDEs will catch a lot of these). It's also just more boilerplate. Combine this with the 'no generics' and
rewriting the same function (and tests for those functions!) multiple times for different types...
There's lots of places even in the documentation where it says "a common error is to do this thing and forget to do this other thing afterwards", but there's no syntactical sugar for just doing both of those things at once!
Enums
Enums are essentially just integers, not really sure why they did it like they do. Why not just have an enum class like other modern languages?
A related issue is non-exhaustive switch statements. What if I just want to make sure that it is one of two
values and let the compiler know that it will only ever be one of those two? There is no way to do that, you just need a
default: panic(...)
branch or to deliberately leave no default branch - if somebody then implements an interface and
passes it to your function then it will fail in mysterious ways.
Something like this in Kotlin:
enum class AnEnum {
A,B
}
fun asInt(i: AnEnum): int {
return when(i) {
A -> 2
B -> 3
}
}
Turns into this in Go:
type AnEnum int
const (
A = iota
B
)
func getMode(val AnEnum) Mode {
switch val {
case A:
return 2
case B:
return 3
default:
panic(fmt.Sprintf("UNKNOWN mode: %d", val))
}
}
You can leave out the 'default' and the compiler will not complain. What if you just leave the default out? Then somebody will come along, add another 'enum' value, and your code will panic when that new value is passed. If you add an extra value to the enum class in Kotlin, the compiler will point out that the 'when' branch is missing an eventuality and will refuse to compile.
Kotlin has both these enums and sealed class
es, which is like having an abstract class that nothing outside of the
file it is defined in can inherit from. This means nobody can add an extra implementation and break your internal code
that relies on that assumption, and also means that if you add a new class that inherits from this seasled class then
anybody doing some kind of switch statement on it will get a compilation error and will need to take your new class into
account in the same way as an enum.
See also the C issue:
typedef enum {
A=0,
B=1
} an_enum;
an_enum x = (an_enum)3; // buhhh
This kind of thing is valid in Go, you can just pass any random integer to the getMode
function defined above and it
would panic if it's not equal to A or B.
There is this tool which does something to at least warn you about this but it should be part of the language.
Automatic semicolon insertion.
That thing from Javascript that everyone loves, and never caused any issues. This is also annoying if you're using something that has a sort of 'builder pattern', for example this snippet from the pg-go documentation:
var err = db.Model(story).
Relation("Author").
Where("story.id = ?", story1.Id).
Select()
And a Kotlin equivalent with the dots at the start of the line:
var err = db.Model(story)
.Relation("Author")
.Where("story.id = ?", story1.Id)
.Select()
The dot comes after the end of the line rather than the start like Java/Kotlin - I'd say it's better in those languages
because you can comment out any of the following lines and have it work fine, whereas if you comment out the Select()
line in the go snippet then code will not compile.
Extra minor points
Kotlin has access to the whole Java ecosystem, which is enormous. There are quite a few Go libraries at this point (lots and lots with 1k+ stars on Github) but it can't match the number of JVM libraries available. The ones that are available, and that use empty interfaces, mean you need to write a load of tests for any code you use to interface with it (as mentioned above), not like a Java library where if it compiles it probably works.
Because there is no encapsulation, you can end up with lots and lots of functions in one file with no clear blocks and files can end up cluttered. You can normally just split this out into separate files at least. This is also better than Java where you can only have one class per file, even if it's 5 lines long.
defer
is not as good as Python'swith
or Kotlin'suse
. I'd say it's probably not even as good as Java's "try-with-resources".I'm unsure why it there are sized arrays (eg
[3]int
) when a struct would probably do the job better? I'm sure there are use cases for this so I'm not going to complain too much, but so far I haven't found one. I'm also assuming structs are packed like C so they're ready to serialise over the wire already.Unused variables being an error. Very occasionally I just want to define an unused variable so it's easier to view local state in a debugger, but this is impossible in Go. Why isn't there some command line flag to control this?
If you override an interface, and your IDE is telling you you're overriding it properly (eg, custom JSON marshal a structure), but it doesn't get called when you expect it to? Good luck finding out why (hint: source diving).
The cli tool sucks - there's no simple option like
--verbose
to just tell it to print whatever it's doing. If you have some kind of weird error happening with module paths and you don't know why, then it's impossible to debug. The error messages it gives can also be completely wrong - I had a source file that I had accidentally not added to git, and when I tried to clone and buld it on a different machine it instead gave me an errorno matching versions for query "latest"
when trying to run the tests.There are a million different log implementations. In Python, (and sort of in Java with slf4j) there is one built in one and you just plug things into it so it behaves as you expect.
Conclusion
The 2 really big issues I find:
The empty interface means there's a lot of stuff which can still fail at runtime, and introduces a huge amount of code bloat. You need to write unit tests just to check things that are normally checked by the compiler in other languages.
No algebraic data types/sensible null handling (ie, like Kotlin/C# null handling) is the real reason for a lot of code bloat. This is the reason there is the concept of the 'zero value', error code returns, no good enums, etc.
If these two were solved it would be a lot nicer. The whole language feels like they just took C and polished it rather than fixing some of the more annoying issues, like Kotlin did with Java.
I was going to say that learning Go has made me realise that maybe a language which is sort of like Go but leans more towards Rust but:
- without the magic syntax they keep adding to Rust
- no macros
- garbage collected
might be an ideal language, than I realised I'm thinking about ReasonML which already exists. Go use it, everyone.