Avoiding Pitfalls in Go
Go is relatively young, as far as programming languages go; it will be 14 years old this November. For comparison, C++, which Go directly competes with in the space of compiled general-purpose programming languages, is 38.
Go brings a lot of exciting features to the table that help it stand out: channels and coroutines (er, goroutines) as first class citizens, structural typing, type embedding, garbage collection, etc. But it also has its share of growing pains: if C++ is difficult because of its age, Go is difficult because of its youth. Its standard library leaves a bit to be desired, and its ecosystem isn’t as rich as maybe we’d like. Sometimes we end up having to implement something ourselves because there’s no off-the-shelf library to do it for us. And the language design isn’t without its controversies (Famously, the language didn’t get Generics support until it was old enough to babysit.)
Still, the language has enough usefulness and charm that I’m happy that Dolt, the world’s first version controlled database, is written in Go. By using Go, we get the benefits of a modern language, although we also share in its growing pains. It has a learning curve, to be sure.
While it shares many syntactic similarities to C-like languages, it isn’t one, and acting like it is is an easy way to end up deep in a pit without a shovel. Programmers coming from a language like C++ or Java need to take extra note of these pitfalls.
Every example I give here is something that I have personally experienced. These lessons were learned the hard way. Learn from my mistakes.
Range
Go uses the range
keyword for iterating over slices. It looks like this:
values := []string{"a", "b", "c"}
for index, value := range values {
fmt.Println(index, value)
}
Result:
0, a
1, b
2, c
Having both the index and value is useful because it lets you write both traditional C-style for loops, as well as more modern for-each loops.
You can also only declare a single variable for the range. Surprisingly, doing this gives you the index, not the element of your slice! Anyone used to use for-each style loops (or coming from a language like Python, which only has for-each loops) is in for a rude awakening. If you’re lucky your collection type will be incompatible with int
and you’ll get a compile time error. But if it’s a collection of ints?
values := []int{4, 8, 15, 16, 23, 42}
for value := range values {
fmt.Println(value)
}
Result:
0
1
2
3
4
5
No errors. No warnings. Just chaos.
Destructuring Multiple Return Values
One of Go’s star features is support for functions that return multiple values. Usually this is used to return an optional error value (since Go doesn’t have exceptions, and its closest equivalent, panics, is heavily discouraged.)
func doRiskyOperation() (Result, error) { … }
Note that these functions don’t return tuples. Tuples are not a built-in type. As such, you can’t store the combined return value in a variable, you can’t index into it, and you can’t pass the result into another function unless that function takes exactly the same types, in the same order.
resultAndError := doRiskyOperation() // not allowed
result := doRiskyOperation()[0] // not allowed
fmt.Println("Output: ", doRiskyOperation()) // not allowed
fmt.Println(doRiskyOperation()) // this one, strangely, is allowed
The only things you can do with multiple return values is:
- Destructure and assign them to newly declared variables (using
:=
orvar
) - Assign them to existing variables (using
=
) - Pass them to a function that takes exactly the same number and type of parameters, in the same order.
- Return them from a function that returns exactly the same number of type of parameters, in the same order.
There’s a subtle footgun here with options (1) and (2): there’s no mix-and-matching between those two options: either every variable on the left-hand side is declared, or none of them are.
With this in mind, consider the following example that applies a series of transformations to an object, returning a slice containing the result of each transformation:
type Transformation func(Node) (Node, error)
func applyTransformations(n Node, ts []Transformation) ([]Node, error) {
var steps []Node
for _, t := range ts {
n, err := t(n)
if err != nil {
return nil, err
}
steps = append(steps, n)
}
return steps, nil
}
This code looks correct, and it even compiles without warning… but it doesn’t work. Instead of applying each transformation in sequence, it applies each transformation to the original. This is because the line n, err := t(n)
is a declaration statement. It declares two new variables: err
which stores the potential error, and n
which stores the new node, shadowing the original variable n
. This new variable only exists within that iteration of the range, and so its new value won’t be seen in the next iteration.
The first instinct to fix this would be to replace the :=
in the line n, err := t(n)
with =
, but that won’t work either. At least this time you’ll get a compile time error, telling you that you’re attempting to assign to err
without declaring it.
The problem is in trying to mix-and-match a declaration with an assignment. We can get around this by making both components an assignment:
type Transformation func(Node) (Node, error)
func applyTransformations(n Node, ts []Transformation) ([]Node, error) {
var steps
for _, t := range ts {
var err error
n, err = t(n)
if err != nil {
return nil, err
}
steps = append(steps, n)
}
return steps, nil
}
Or by making both components a declaration:
type Transformation func(Node) (Node, error)
func applyTransformations(n Node, ts []Transformation) ([]Node, error) {
var steps
for _, t := range ts {
newNode, err := t(n)
n = newNode
if err != nil {
return nil, err
}
steps = append(steps, n)
}
return steps, nil
}
Both of these require a little extra verbosity, so pick your poison.
Type Embedding
We’ve already written about the pitfalls of Type Embedding here, but like a late-night infomercial, “Wait! There’s more!”
When a struct embeds a type, it inherits all of the fields and methods of that type. This isn’t inheritance in the traditional object-oriented sense: it’s basically syntactic sugar for defining those members on the outer struct and delegating those calls/accesses to the inner object. It lets you avoid having to explicitly access the inner object.
type Nameable interface {
Name() string
}
type Node struct{
name string
}
func (n Node) Name() string {
return n.name
}
type Wrapper struct {
Node
}
tim := Wrapper{Node{"Tim"}}
// The expressions below are equivalent
tim.Name() // prints "Tim"
tim.Node.Name() // prints "Tim"
// As are these
tim.name // prints "Tim"
tim.Node.name // prints "Tim"
Using type embedding, it’s easy to get in the habit of just always excluding the name of the inner type. After all, assuming that the outer struct doesn’t have any members with names that conflict with the inner object, it should always be safe to pass in the outer struct anywhere one of its interfaces is expected, right?
Not exactly. Because type assertions are still a thing.
func Print(n Nameable) {
wrapper, ok := n.(Wrapper)
if ok {
fmt.Printf("Wrapper: %s", n.Name())
} else {
fmt.Printf(n.Name())
}
}
Print(tim.Node) // prints "Tim"
Print(tim) // prints "Wrapper: Tim"
Style guides may argue about whether or not type assertions are a good practice or not. But sometimes they’re the best way to avoid overcomplicating your interfaces or your dependency graph, so we use them. And while embedding a type might fool the compile time type-checker, you can’t fool type assertions.
Value receivers
When you implement a method in Go, you have two options:
func (n Node) Name() string {...} // Value Receiver
func (n *Node) Name() string {...} // Pointer Receiver
This behaves the same way as passing a value vs a pointer as a regular function parameter: passing by value creates a copy of the struct, and passing by pointer doesn’t. As long as you remember this and treat it just like you would treat a regular function parameter, you’re safe. But it’s easy to forget: after all, most languages only have pointer receivers for methods. It’s easy to get in the habit of assuming that your method receiver is a pointer even when it’s not, which results in this handy foot-gun:
func (n Node) SetName(newName string) {
n.name = newName
}
n := Node{name: "Tim"}
n.SetName("Aaron")
fmt.Println(n.Name) // prints "Tim"!
The SetName
method above does nothing. Because methods with value receivers create a copy of the receiver object, and changes made to the object won’t be seen by the caller.
Again, it’s fine if you remember that a non-pointer receiver behaves just like any other non-pointer parameter. But this is still different from every other mainstream object-oriented language, where receivers are always pointers.
In practice, you almost always want to use pointer receivers in your methods: they prevent an expensive copy and they have fewer pitfalls. Unfortunately, they’re also the ones that require you to remember to add the *
, effectively making value receivers the “default”.
Nil
Nil in Go isn’t just the null pointer value; it’s the zero value for many types, including slices and maps.
It’s perfectly fine to declare a slice without assigning it a value: it gets assigned nil, which is the same as the empty slice:
var slice []int
fmt.Println(len(slice)) // prints 0
slice = append(slice, 42) // okay
fmt.Println(len(slice)) // prints 1
But be careful trying to do the same with maps:
var gpa map[string]float
fmt.Println(len(gpa)) // prints 0
m["Neal"] = 4.0 // nil panic!
Why is this a problem when the slice example is fine? Didn’t I just say that nil is the zero value for maps just like it is for slices? Well, it is… but nil is also immutable.
Slices are like arrays in other languages: their contents can be modified, but they can’t be resized. That’s why append
is a function instead of a method: instead of mutating the slice, it creates a new slice with an increased size and returns it. (This is an oversimplification of how slices work, but is correct enough. You can read more about how slices actually work here.)
Maps don’t work this way. Insertion and deletion are operations that modify the value (and size) of the map without creating a new one. The nil
map is still a valid map, just like the nil
slice is still a valid slice. You can even read from it safely (although it’s empty so there’s not much to read). But you can’t insert into it because it’s not backed by an initialized data structure: there’s no allocated space to insert into!
If you want to insert into a map, you have to initialize it like so:
gpa := make(map[string]float)
fmt.Println(len(gpa)) // prints 0
m["Neal"] = 4.0 // okay!
fmt.Println(len(gpa)) // prints 1
Conclusion
This article might give the impression that I don’t like Go. That’s not the case at all! Go has several practical features that I’d love to see other languages learn from; I never would have been bit by the aforedescribed type embedding issue if I didn’t enjoy using type embedding. But anyone coming from a traditional inheritance-based language needs to learn, one way or the other, that while Go looks like those other languages, it isn’t one. You’ll be a much more effective Gopher once you wrap your head around what the language is and isn’t.
And you’ll have a lot more fun too.
Anyways, if you enjoyed this blog, feel free to check out our others! Or if you want to get in touch with us directly, you can always join our Discord or follow us on Twitter.