An unordered list of things I miss in Go

I like Go. I think it is a reasonable good language, and has some good qualities that makes up for its flaws. However, this doesn't mean I think the language couldn't be better, far from it.

This blog post is a list of things that I miss from Go from other languages. Some of the things here could probably be implemented soon, some other would probably need a major revision of the language. The list is unordered, because this makes it easier for me to update in the future if I found something else, but also because I don't want to think too hard about giving each point here a rank.

With all above, let's start.

Ordered maps in standard library

When I first learned about dictionaries in Python it quickly became one of my favorite data structures ever. They're extremely versatile, and most modern programming languages have something similar in its standard library. Go isn't different, it has map, that is Go implementation of a hash table. However map in Go are quirky, for example:

package main

func main() {
	m := map[string]bool{"foo": true, "bar": false, "baz": true, "qux": false, "quux": true}

	for k := range m {
		println(k)
	}
}
$ go run ./test.go
bar
baz
qux
quux
foo

$ go run ./test.go
foo
bar
baz
qux
quux

$ go run ./test.go
qux
quux
foo
bar
baz

Now, I don't expect any hash table implementation to keep the order of the elements, but Go actually randomise each map instance:

But here’s the deal, while the hash function used for maps in Go is consistent across all maps with the same key type, the seed used by that hash function is different for each map instance. So, when you create a new map, Go generates a random seed just for that map.

While I understand the reason for this (i.e.: to avoid developers relying in a specific iteration order), I still find it weird, and I think this is something unique for Go. This decision means that even if you don't care about a specific order, you will still need to sort the map before doing something else if you want reproducibility, something that I care a lot.

The fix for this? Go could offer an ordered map implementation inside the standard library. An ordered map ensure that the iteration order of the map is the same as the insertion order (that is, by the way, a powerful property that allow maps to be used in other contexts, not just my pet peeve above).

Python actually does this for any dictionaries since Python 3.6, but it offered an OrderedDict before it (and OrderedDict still has some methods that normal dict doesn't, that maybe useful in specific cases).

Before generics it would be impossible to have a type-safe API for such data structure without introducing a new data type in the language (like slices), but now Go has generics so it is not an issue anymore. The other issue is that you would be forced to iterate manually in this new data structure, but thanks to the new range-over-func in Go 1.23, it means we can iterate in an ordered map as a library almost exactly like we can do as a map:

import "orderedmap"

func main() {
    m := orderedmap.New[string, bool]()
    m.Set("foo", true)
    m.Set("bar", false)
    m.Set("baz", true)

    for k := range m.Iterator() {
        println(k) // Order always will be: foo, bar, baz
    }
}

Now, of course the lack of Ordered Map in the standard library can be filled with third party implementations, e.g.: I am using this one in one of my projects. But being in standard library reduces the friction: if there was some implementation in standard library, I would generally prefer it unless I have some specific needs. However when the standard library doesn't offer what I need, I need to find it myself a suitable library, and this ends up taking time since generally there are lots of alternatives.

Keyword and default arguments for functions

Something that comes straight from Python that I miss sometimes in Go is that you can do things like this when declaring a function:

def hello(name="World"):
    print(f"Hello, {name}")

hello("Foo") # "normal" function call
hello(name="Bar") # calling with keyword arguments
hello() # calling with default arguments
$ python hello.py
Hello, Foo
Hello, Bar
Hello, World

The lack of default arguments especially affects even some of the API decisions for Go standard library, for example, string.Replace:

func Replace(s, old, new string, n int) string

Replace returns a copy of the string s with the first n non-overlapping instances of old replaced by new. If old is empty, it matches at the beginning of the string and after each UTF-8 sequence, yielding up to k+1 replacements for a k-rune string. If n < 0, there is no limit on the number of replacements.

If Go had default arguments, Replace could have e.g.: func Replace(s, old, new string, n int = -1) signature, that would mean we don't need strings.ReplaceAll (since this is basically what this function does, call strings.Replace(s, old, new, -1)).

Nullability (or nillability)

I talked I little about this in my previous post about Go, but I want to expand here.

First, I don't think the language needs to support the generic solution for nullability, that would be either having proper Union or Sum types. Kotlin AFAIK doesn't support neither, but my 2 years experience with Kotlin showed that just having nullable types already helped a lot in ensuring type safety.

Second, I do feel that Go has less issues with nil values, than say, Java, because its decision of using zero values instead of nil in many cases. So for example, a string can never be nil, however a string pointer can be. This means that this is fine:

func(s string) {
    // do something with s
}

However:

func(s *string) {
    // s maybe nil here, better check first
}

Still, I get more panic for nil pointer deference than I get in other languages that offer nullables (heck, even Python with mypy is safer).

Sadly this is the change in this post that is more likely to need a completely new revision of the language. nillability was proposed before, but it is really unlikely it can be done without breaking backwards compatibility.

It could be done the Java way by adding a nullable type to the standard library (JSR305), but the fact that JSR305 is considerd dead by many shows how difficult it is to do something like this without a major change in the language. Dart is the only language that I know that did this successfully, but definitely it was not without its pains. And the fact that most people that program in Dart probably does because of Flutter (that eventually required newer versions with null-safety) is not a good sign.

Lambdas

Added in 2024-08-18

Go is a surprising good language for some functional code, thanks to having first class functions and closures. Sadly the syntax doesn't help, since the only way you can use anonymous functions in Go is using func. Especially if the types are complex, this can result in some convoluted code. Take the example from the range-over-func experiment:

package slices

func Backward[E any](s []E) func(func(int, E) bool) {
    return func(yield func(int, E) bool) {
        for i := len(s)-1; i >= 0; i-- {
            if !yield(i, s[i]) {
                return
            }
        }
    }
}

If Go had a syntax for lambdas, especially if we could elide the types, this could be simplified a lot:

package slices

func Backward[E any](s []E) func(func(int, E) bool) {
    return (yield) => {
        for i := len(s)-1; i >= 0; i-- {
            if !yield(i, s[i]) {
                return
            }
        }
    }
}

Or even something like this would already help, no special syntax but allowing the types to be elided in an unnamed function:

package slices

func Backward[E any](s []E) func(func(int, E) bool) {
    return func(yield) {
        for i := len(s)-1; i >= 0; i-- {
            if !yield(i, s[i]) {
                return
            }
        }
    }
}

This feature I am still somewhat hopeful that may become a reality in some future version of the language, since they didn't close the issue yet, and the discussion about the possibility of this feature is still ongoing.

Error on unused return values

Added in 2024-09-12

In a recent post about LLMs, I talked about this function that I asked ChatGPT to generate to create a socket that accepted a context:

func readWithContext(ctx context.Context, conn net.Conn, buf []byte) (int, error) {
    done := make(chan struct{})
    var n int
    var err error

    // Start a goroutine to perform the read
    go func() {
        n, err = conn.Read(buf)
        close(done)
    }()

    select {
    case <-ctx.Done(): // Context was canceled or timed out
        // Set a short deadline to unblock the Read()
        conn.SetReadDeadline(time.Now())
        <-done // Wait for the read to finish
        return 0, ctx.Err()
    case <-done: // Read finished successfully
        return n, err
    }
}

However while reading this blog post about "The Error Model", I realised that this function is lacking error check during conn.SetReadDeadline() calls:

func readWithContext(ctx context.Context, conn net.Conn, buf []byte) (n int, err error) {
	done := make(chan struct{})

	// Start a goroutine to perform the read
	go func() {
		n, err = conn.Read(buf)
		close(done)
	}()

	select {
	case <-done:
		return n, err
	case <-ctx.Done():
		// Set a short deadline to unblock the Read()
		err = conn.SetReadDeadline(time.Now())
		if err != nil {
			return 0, err
		}
		// Reset read deadline
		defer func() {
			if e := conn.SetReadDeadline(time.Time{}); e != nil {
				err = errors.Join(err, e)
			}
		}()
		// Make sure that the goroutine is done to avoid leaks
		<-done
		return 0, errors.Join(err, ctx.Err())
	}
}

I could blame the LLM on this, but this is the type of mistake that I could see happening even in a Pull Request done by an actual human.

Ignoring errors is bad, especially because once the error is ignored it is gone, forever. You may have strange issues in the code that are impossible to debug because you can't know about something that doesn't exist. This is one of the reasons I think exceptions are better, since this is the kind of error that would be impossible to ignore if the language had exceptions, and the exception would eventually propagate in your stack until it is captured or you get a crash (with a stack trace to debug the issue).

Now, I don't think this is a fault of using error as values instead of exceptions. Go has the tools to encapsulate the errors and propagate them properly, like it is shown in the fixed code. But the fact that someone can do this mistake without any warning or error from the compiler is bad.

If Go had a warning or error for unused return values, this would be different:

func foo(conn *net.Conn) {
  // ...
  _ = conn.SetReadDeadline(time.Now())
}

Now in this case it is clear: I am ignoring the error explicitly, probably for a good reason. I can ask during a Pull Request why the committer is ignoring if it is lacking appropriate context, and maybe even ask for some comments to be added why this would be safe. What can't happen is this being ignored by mistake.

I am not completely sure if I want this only for errors or for any unused value though. There are lots of cases where ignoring non-error values is ok, but I also don't see the issue of having something like:

func foo() {
  // ...
  _, _ = FuncThatReturnIntAndError()
}

If anything, it is clear for whoever is reading this code later that you're only calling this function only for its side-effects.

By the way, yes, errcheck linter exists, but if the language itself doesn't enforce this it means that there are lots of libraries that are probably mistakenly ignoring errors. And if the library is ignoring errors by mistake, you can't do much when you actually have them.