What I Don't Like in Go

6 minute read

Before Anything Else

I have been using Go for almost four years and all I can say is that I like it very much. It has become my language of choice for Web development and systems programming, where I was previously using mostly Python and C.

Renaming of Package and Module

I make a lot of changes and rename packages many times, before I get comfortable with any project’s structure. This is severly hindered by the fact that whenever you need to update a package name, you need to update all the files with the package name.
I need to rely on gorename for this task.

Another small annoyance comes from having the full module URL in the go.mod file. This is problematic, when you have modules renamed, e.g. you have moved your repository from one site to another and the new URL does not work. You need to use replace directive to make a quick fix, or update the module.

:= Short Variable Declaration

It is very useful to have a declaration operator that infers the variable type. However, := feels like an assignment operator and it is used as such. This is not only because it looks like the assignment operator in Pascal, but generally because it is used when assigning a result to a new variable.
This becomes really annoying, when I change my logic a bit (e.g. declare err earlier) and now I have go down and update the the code below (e.g. change err := fn() to err = fn()).

Another thing is that variables declared with := in an if statement are hiding the variables with the same name in the outer scope. This is one of the things I wrote code to verify, back when I starting with Go. And just now I have realised that the type could also change 🤮.

s := "outer scope"
if s, ok := 42, true; ok {
	println("s =", s) // prints "s = 42"
}
println("s =", s) // prints "s = outer scope"

It should have been only available as var foo = "bar", as the var keyword explicitly shows that you are declaring variables and the error of redelcaring or hiding an outer scope variable is now clearly visible.

Caveats with Slices

Resetting

The idiomatic way to reset a slice and reuse the allocated capacity is to write:

s = s[:0]

This make a lot of sense when you first see it in someone else’s code. However, when I was new to the language, I wasn’t aware that this would preserve the capacity.

Copying Data

The decision on how to copy a slice is not always straight forward. Sometimes I could use copy(dst[a:b], src[c:d]), other times I need to make a for-loop. In the latter case I have two more options - to use indices or to use append(). For example:

destSlice := make([]Type, len(srcSlice)) // preallocate slice
for i := range srcSlice {
	destSlice[i] = transform(srcSlice[i])
}

// or

destSlice := make([]Type, 0, len(srcSlice)) // declare capacity
for _, x := range srcSlice {
	destSlice = append(destSlice, transform(x))
}

It takes some time to decide which one is more suitable and it’s not always obvious.

Append() Misuse

This is something I’ve never experienced myself, but people have complained at FOSDEM. It’s about append()-ing data to old slices, which replaces data in the current slice.

This is easliy illustrated in the following example:

s := make([]string, 0, 10)
s1 := append(s, "string1")
s2 := append(s, "string2")

println(s1[0]) // prints "string2"
println(s2[0]) // prints "string2"

There are a few situations where that might happen. The general case is that you pass the slice to a struct or a function, but you also keep a local reference. Since both slice refereces are mutable, writing to the slice could result in unexpected behaviour, which could be hard to detect, as it also depends on the slice’s cap() and len().

Opinionated Build and File Names

The Go programming language has a opinionated build system. It skips files and directories starting with a dot. It also checks if for instance, the file ends with _linux.go and it builds it only if the build environment is Linux.
This very useful, but prevents me from using underscores in filenames, as it may become a keyword at some point. As a result it increases the effort to name the file in the package.
Moreover, there are already Build Constraints readily available.

In addition to that the builder is not looking into directory called testdata. This could have been a configurable behaviour and have those directories to into a file, e.g. .goignore.

Getting Used to Channels

The channels are nice semantic integrated into the language. They allow you to solve concurrency problems using channels as a message-passing mechanism between goroutines. The plus side is that you are also getting parallelism, but introduces more complexity than coroutines and generators.

In addition to that it requires some discipline when handling channels. You should make sure that you don’t send messages over a closed channel, which is usually done by only closing them on the sender’s side. And even sometimes you need to think about adding a buffer with size of one to prevent self-deadlock.

Package Management (go.mod)

To be fair, the introduction of go.mod is the reason I gave Go another try. I wasn’t a fan of the $GOPATH, but now in retrospective I might have judged it too harshly.
Nowadays, I find go.mod to be harder to use with private Go modules and I would like to avoid using GOPRIVATE, .netrc and adding URL rewrites in ~/.gitconfig.

The way I’d approach the problem now is to use git submodules for the private repositories and throw in some replace directives in go.mod.

bytes.{Reader vs Buffer} as an io.Reader

There is a slight difference in the behaviour of bytes.Reader and bytes.Buffer when acting as an io.Reader. Unlike bytes.Buffer, bytes.Reader returns io.EOF error, when reading zero bytes at the end of the slice.
This inconsistency resulted in a small crash in production while deserialising data. Though, it’s my fault I haven’t had comprehensive number of tests.

An example that illustrates the problem:

func testReader(r io.Reader) {
    // e.g. length-prefixed field / message
    var length uint32
    err := binary.Read(r, binary.BigEndian, &length)
    if err != nil{
        panic(err)
    }
    rest := make([]byte, length)
    _, err = r.Read(rest)
    fmt.Printf("error = %s\n", err)
}

func main() {
    data := []byte{0, 0, 0, 0}
    testReader(bytes.NewBuffer(data)) // error = %!s(<nil>)
    testReader(bytes.NewReader(data)) // error = EOF
}

Time Format and non-POSIX flags

Time Format

The time formatting and parsing in Go is a very werid animal and it is one of things that put me off when I was considering to try the language in 2013.

I don’t get why Go authors use the printf-style of formatting in fmt package for strings, but decided against strftime in date / time.

Non-POSIX command line flags

This is a small inconvinience, but I am very much used to having long options / double-dash options in CLIs (e.g. ls --help), as well as short options (e.g. ls -ashl).
Fortunately, there is a package github.com/spf13/pflag (thank you Steve Francia) that I have in almost every Go project of mine.

Commenting Chunk of Code

Whenever I want to comment out a piece of code just to try some stuff, I am stuck at removing, more and more code, just because more and more variables are no longer used.

Miscellaneous

  • json.Encoder
    • Encoded records end with a newline. There is no way to disable this.
  • fmt.Printf
    • The formatting string %#v does not indent for easier reading.
  • Generics
    • When generics were introduced I was afraid, people would overuse and misuse them. So far, this has not happend.

Remarks

I really like Go. Those things are just minor annoyances that hinder the software development with Go or just irritate me.