What I have learned using go for 6 months

October 18, 2023

Lately, I had the opportunity to try out go as my primary programming language. After having worked with c++ and rust in the systems programming domain, I want to share my experience of using go.

General good impressions

For a newcomer, go is easy to learn thanks to its small api footprint. There aren’t many language features that are foreign at first glance, outside of go channels and defer. Therefore, it is easy to make sense of the code.

That tiny interface has a downside, though: Everything is very verbose. I don’t have a lot of issues with verbosity, but in some cases, I just wonder whether there is untapped potential and some room for improvement.

Something I really like is that the functions are first class citizens. You can have closures freely and this unlocks so many possibilities. Plus, in comparison to rust, the ergonomics of functions are way easier to deal with.

Tooling is another aspect in which go shines. Everything works out of the box (if you are not using cgo_enabled). Compilation is very fast and error messages are comprehensive. And the biggest productivity gain is probably standardized formatter. Also, the standard library is excellent, and it is a delight to work with (of course, there are exceptions).

Memory management is another thing that go excels (this depends heavily on the requirements, though). Using a language with performant garbage collector is a sweet spot for most of the applications. As opposed to rust’s steep learning curve, there are way less ‘gotchas’ in go in terms of memory management. To be more specific, go’s compiler is smart enough to put variables that are not referenced after function return into stack. But most of the time, you can assume that an object is living in the heap.

When distributing code, go relies on a superior “decentralized publishing” strategy. This means that the whole go ecosystem is not depending on a single source for dependencies (like npm), rather they allow owners of packages to distribute them in the medium that is most convenient or useful to them.

Nits

No ternary operator?

That’s the whole nit. No ternary operator! I appreciate the reasoning behind this decision. However, I found it a bit drastic to force the use of if-else statements in every case. It would have been more pleasant to write ternary statement in simple operations.

Passing by references and value

The semantics of passing arguments to a function is very simple in go: Everything is passed by as value. When you pass a reference, its value (which is an address in memory) is copied and the copied value is used in the scope of the function. On the other hand, this means that there is a lot of implicit copy action going on.

For example:

type TypeA struct {
  Value int
}

func copy_implicitly(obj TypeA) *TypeA {
  return &obj
}

func main() {
  obj1 := TypeA { Value: 3 }
  obj2 := copy_implicitly(obj1)
  obj2.Value = 4
  fmt.Println(obj1.Value) // Prints 3
  fmt.Println(obj2.Value) // Prints 4 because obj2 is a copied object
}

I must emphasize, this doesn’t mean every parameter should be passed in the form of a reference. Please don’t do premature optimization, make sure you can prove the performance gain in a case by case basis.

Named function arguments

I think it is a huge boost to readability when it is possible to make a function arguments as clear as possible. For example, in JS destructuring arguments is very powerful:

getStocks({ symbol: 'AAPL', includeHistoricalData: true, minimumPrice: 5 });

The same thing looks very unintelligible without it, and you probably would look like a surprised pikachu meme after reading it:

getStocks('AAPL', true, 5)

Obviously, it is possible to do the same in go by writing a ‘parameter’ struct, but somehow it is not as practical:

type StocksQuery struct {
  stock string
  includeHistoricalData bool
  minimumPrice int
}

func getStocks(query StocksQuery) {
  // ...
}

getStocks(StocksQuery {stock: "AAPL", includeHistoricalData: true, minimumPrice: 5})

Love-hate relationship

Error handling

The convention of error management in go is just returning an error value from a function. It resembles the C’s way of error handling.

// functions return tuples, often containing error and return value
func createFile(directory string) error {
  ...
  if (directoryMissing) {
    return errors.New("Directory doesn't exist")
  }
  ...
  return nil
}

func main() {
  err := createFile("/tmp")
  // return value is checked to make sure that everything went as expected
  if (err != nil) {
    panic(err)
  }
}

This makes the code simpler and, as opposed to throwing errors like in many mainstream languages, it makes it clear that in which ways a certain call can go wrong. This is a huge plus point.

However, the errors are very easy to ignore:

func risky_function() error {
  return errors.New("Freeeeeeze! This is an error.")
}

func main {
  risky_function()
  // Aren't you forgotting something, buddy?
  // This is perfectly fine 🔥
}

It is inferior to monadic error handling that rust and many functional programming languages offer. For that reason, it is harder to make use of a static checkers for error handling.

Not a fan

Namespace, imports and project structure

go projects consist of modules and packages. A module basically defines the boundaries of your code, and the packages help to divide them into meaningful blocks. They are in a way similar to namespaces.

Unfortunately, for a long time, there wasn’t an official recommendation to how to structure a project. Unlike many other programming languages, the folder structure is insignificant when importing a package or a module. Therefore, various patterns have emerged and it is common to encounter those out in the wild.

In the meantime, there is an official guide: https://go.dev/doc/modules/layout

In short, it argues to have packages and executables in their own directories. Basically, packages live at the root level. If the packages are only used internally, they are placed under internal directory. If there are more than one executable, they exist within cmd directory.

A sample go project looks like as follows:

project/
  go.mod
  modname.go
  modname_test.go
  pkg1/
    pkg1.go
    pkg1_test.go
  internal/
    ... internal packages
  cmd/
    cmd1/
      main.go
    cmd2/
      main.go

If you want to import some other package into your code, you simply do this:

import "github.com/alicanerdogan/mymodule"

Here it is implicit that you can refer to [github.com/alicanerdogan/mymodule](http://github.com/alicanerdogan/mymodule) with mymodule. This causes a lot of confusion for beginners. In addition, this increase the likelihood of having name collisions.

My second issue with the imports is that the convention of public and private members of a package. All public members start with uppercase. This decision baffles me. Many things in go are decided to be verbose and explicit as possible. But here we have a very subtle convention.

Struct tags

The go is an explicitly written language, but certain things are at odds with this principle. Namespacing is one of them, but the biggest offender in my opinion is the struct tags. They are too ‘magical’ for my taste. They make it hard to notice any mistakes.

The struct tags are simply annotations that are used in the runtime. The instructions are decoded with the help of reflect api. The most common example you can see in the wild is json struct tags:

// now encoding/json library can use this information to parse the struct properly
type Response struct {
    Page   int      `json:"page"`
}

The problem is, the semantics of the struct tags depend on the third party library. The compiler and type safety is out of the equation, which means that there is no static checking and no discoverability.

Outright evil

Initializations

This is my biggest beef with go. It leads so many wrong assumptions and naturally bugs. And it feels so wrong after my battle scars with c++. What is the issue? go initializes fields with defaults.

var number int // this is 0
var float_number float64 // this is 0 too
var str string // this is ""
var b bool // this is false

But nobody in their right mind left variables explicitly uninitialized, right? RIGHT!???

Natalie Portman is surprised because she didn't expect intentional evil

Let’s say I want to parse a JSON string with a number field:

package main

import (
  "encoding/json"
  "fmt"
)

type Response struct {
  Page   int      `json:"page"`
}

func main() {
  str := `{"page": null}`
  res := Response{}
  json.Unmarshal([]byte(str), &res)
  fmt.Println(res.Page) // Print 0 😱
}

Because the variable res is initialized with 0, the value of Page will be 0, although the response is invalid and the page field does not exist.

You might say: json.Unmarshal returns an error, clearly that’s user’s mistake. But, no, according to json.Unmarshall all is good here.

You can update the struct so that the Page field is a pointer, but this is so not a sustainable solution:

type Response struct {
  Page   *int      `json:"page"`
  // This works, but should we make everything pointer field now?
}

Verdict

I like go. It is boring, but sturdy. It is fast to compile and fast to run. Its standard library is one of the best of its kind. If you don’t want to take risks or if your requirements don’t demand you to squeeze every CPU cycle, pick go. It is mature, and it has proven its worth over and over.

Your code will never feel satisfactory, and you won’t have many opportunities to come up with obscure abstraction. But that’s the beauty of it. This will humble you as a developer.