Go, a reasonable good language

Go was one of the languages that I always was interested to learn, but never got the hang of it. I first got interested in the language when I was in my first job, between 2016-2018. At the time the language was a completely different beast: no modules, no generics, no easy way to error wrap yet, etc.

Go forward 2023 (no pun indented), I wrote my first project in Go, wrote some scripts at $CURRENT_JOB in the language, and now wrote my first library (with an interesting way to run CI). I am also writing more scripts in the language, where I would prefer to use Bash or Python before. Heck, even this blog is automatically published with a Go script, that used to be a Python one before. I can say that nowadays it is another language in my toolbox, and while it is still a love and hate relationship, recently it is more about love and less about hate.

The points that I love about Go is probably obvious for some, but still interesting to talk about anyway. The fact that the language generates static binaries by default and have fast compilation times is something that I apreciate since I first heard about the language, and now that I am using the language frequently, are points thatI appreciate even more. Something about getting almost instant feedback after changing a line of code and running go run (even with its quirks) are great for the developer experience. This is the main reason why I am using the language more frequently for scripts.

Then we have the fast startup times. I am kind of sensitive to latency, especially of command line utilities that need to answer fast when I expect them to be fast (e.g.: foo --help). This is one part where I could have issues in Python, especially for more complex programs, but in Go it is rarely an issue.

Modules are also fantastic. It is not without its weirdness (like everything in Go ecossystem), but the fact that it is so easy to add and manage dependencies in a project using only the go CLI is great. I also like that it generates a hash of every dependency, making it reproducible (well, probably not at Nix level, but still reproducible).

Since I started to talk about go CLI, what a great tool! The fact that you can manage dependencies, generate documentation, format code, lint, run tests/benchmarks/fuzzing, check code for races etc., all with just the "compiler" for the language is excelent. Still probably one of the best developer experiences I know in any programming language.

I will not even talk about the things that everyone talks about Go, like goroutines, because I just don't think I can add anything interesting to the topic.

Now for the parts that I like less, the test part still quirks me that it is not based in assertions, but thankfully it is easy to write assertions with generics nowadays:

func Equal[T comparable](t *testing.T, got, want T) {
	t.Helper()
	if got != want {
		t.Errorf("got: %#v, want: %#v", got, want)
	}
}

func GreaterOrEqual[T cmp.Ordered](t *testing.T, actual, expected T) {
	t.Helper()
	if actual < expected {
		t.Errorf("got: %v; want: >=%v", actual, expected)
	}
}

// etc...

Just one of those things that I end up re-writing in every project. Yes, I know about testify and other assertion libraries, but quoting Rob Pike here, "a little copying is better than a little dependency". As long the code you write is trivial, it is better to duplicate the code than try to import a dependency.

About another piece of code that generics allows me to write and I always end up re-writing in every project is the must* family of functions:

func must(err error) {
	if err != nil {
		panic(err)
	}
}

func must1[T any](v T, err error) T {
	must(err)
	return v
}

func must2[T1 any, T2 any](v1 T1, v2 T2, err error) T {
	must(err)
	return v1, v2
}

// must3, must4, etc...

Those functions are so useful, especially for scripts where I generally don't want to handle each error: if I have an error, I want the program to halt and print a stack trace (exactly as I would have with a language with exceptions). It basically allow me to convert code from:

contents, err := os.ReadFile("file")
if err != nil {
    panic(err)
}

To:

contents := must1(os.ReadFile("file"))

This brings Go closer to Python to me, and I think for scripts this is something great.

Finally, for the things that I hate, well the biggest one currently is the lack of nullability (or in Go terms, nillability). After using languages that has it, like Kotlin, or even something like mypy, this is one of those things that completely changes the developer experience. I also still don't like the error handling (but must* goes far by improving the situation, when it is possible to use it), especially because it is easy to lose context on it:

// bad
func readFileContents(file) ([]byte, error) {
    contents, err := os.ReadFile(file)
    if err != nil {
        return nil, err
    }
    return contents, nil
}

// good
func readFileContents(file) ([]byte, error) {
    contents, err := os.ReadFile(file)
    if err != nil {
        return nil, fmt.Errorf("readFileContents: error while reading a file: %w", err)
    }
    return contents, nil
}

I also have some grips about the mutate everything approach of the language. I prefer immutability by default, but I find that in general as long as you split your functions at a reasonable size it is generally fine.

And for a language that is supposed to be straightforward, it is strange on how much magic the language relies on, in the form of things like internal and main packages, name capitalisation to indicate visibility (private vs Public), conditional compiling by filenames (e.g.: foo_amd64.go, bar_linux.go), magic comments (e.g.: //go:build), etc.

I expect to write more Go code going forward. Not because it is the perfect language or whatever, but just because it a is language that has some really good qualities that makes the language attractive even with the issues that I have. That makes it a reasonable good language, and at least for me this is good enough.