Golang Project Structure

Tips and tricks for writing and structuring Go code

Rob Pike’s Go Proverbs (Part One)

Language

  • unknown

by

This post is the start of an upcoming three-part series which will explain twelve Go proverbs. In this context, the word “proverb” is just used as a fancier term for a “short, snappy saying”.

The proverbs that we’ll be discussing come from a talk given by Rob Pike in the early days of Go. Rob is famous in the Go community for his important contributions to our programming language’s design and development.

An image of Rob Pike giving his presentation on Go proverbs.
A screenshot of Canadian superstar programmer Rob Pike giving his original talk about Go proverbs.

Each proverb is intended to encapsulate a little piece of insight that can be applied to Go programming. Think of them as friendly advice from a senior programmer who understands the complexities and intricacies of the Go programming language well.

(I will post links to part two and part three of this series on Rob Pike’s Go proverbs when they are released.)

Don’t Communicate by Sharing Memory; Share Memory by Communicating

One of the best features of the Go programming languages is its goroutines, which work like lightweight threads, allowing blocks of code to run concurrently.

This proverb emphasizes the importance of using channels to communicate between goroutines, rather than by having multiple goroutines accessing the same global variables.

When we share the same area of memory between multiple goroutines, we can introduce race conditions and other synchronization issues.

On other hand, when an object is sent over a channel, access is only retained in the original scope if a pointer to it has been declared, so there need be no risk of the same data being modified at the same time.

Concurrency Is Not Parallelism

Programmers who are new to the concepts often think that concurrency and parallelism mean roughly the same thing, relating to code that does two or more things at once.

However, it’s important to understand that they are actually two quite distinct concepts.

Just because a section of code is written with a concurrent architecture doesn’t mean that it can necessarily also run in parallel, i.e. executing different pieces of code at the same time.

For example, if your computer only has one processor thread, then it will never be able to run goroutines in parallel, because it can’t perform more than one task at once.

To quote Rob Pike’s own words:

Concurrency is a way of structuring your program to make it easier to understand and scalable, and parallelism is simply the execution of multiple goroutines [at the same time].

Since these two concepts are very important to the Go programming language, we should be very careful to distinguish between them when we use them.

It is possible to write concurrent code in Go that does not run in parallel (even though it isn’t possible to write parallel code that doesn’t incorporate concurrency), as shown in the example below:

package main

import (
	"fmt"
	"runtime"
	"sync"
)

func init() {
	runtime.GOMAXPROCS(1) // only use one processor thread
}

func main() {
	var numbers []int
	var wg sync.WaitGroup

	for i := 0; i < 10; i++ {
		wg.Add(1)
		
		go func(i int) {
			defer wg.Done()

			numbers = append(numbers, i)
		}(i)
	}

	wg.Wait()

	fmt.Println(numbers)
}

The order that the ten goroutines above will run in is not guaranteed, but we can be sure that no more than one of them will run at any one time, because we used the runtime.GOMAXPROCS function to limit the number of operating-system threads that can be used to execute our Go code simultaneously.

Channels Orchestrate; Mutexes Serialize

As we mentioned when discussing the first proverb, channels allow us to pass data between goroutines. When Rob Pike says that channels “orchestrate”, he means that they can be used to create a complex system of architecture that involves data routinely being passed from one goroutine to another.

The use of a select statement within a for loop can be used to determine what task a goroutine should perform, depending on what data it is sent.

On the other hand, if we really want to share data between multiple goroutines, rather than passing it, then we should use a mutex to ensure that access to the shared data happens in sequence. The use of a mutex means that only one thing can be done at any one time with that data.

It is not necessary to use mutexes and channels together: when data is sent over a channel, it is automatically synchronized and only one goroutine can access the data at a time. In fact, combining the two can lead to unnecessary complexity and potentially introduce awkward bugs in your code.

The Bigger the Interface, the Weaker the Abstraction

An interface should be as simple as possible. In other words, an interface should only contain methods that are necessary to do the specific job intended by the interface.

In a language like Java, it’s traditional to define more exhaustive interfaces.

However, when writing Go code, it is better to create multiple small interfaces, which are each intended for slightly different purposes, rather than defining one big mega-interface that tries to cover too many cases.

Since interfaces are satisfied implicitly in Go (rather than explicitly in Java), it is easy for a struct to implement many interfaces at one.

Two or more individual interfaces can, however, be combined together as a single interface, if they both tend to be used in similar instances. An example of this can be seen in the "io" package, where the ReadWriter interface is simply defined as the combination of a Reader interface and a Writer interface:

package "io"

type ReadWriter interface {
	Reader
	Writer
}

type Reader interface {
	Read(p []byte) (n int, err error)
}

type Writer interface {
	Write(p []byte) (n int, err error)
}

By keeping our interfaces as small and focused as possible, we are ensuring that we are working with abstractions that are easier to reason about and less likely to cause unexpected problems.

There is an old English proverb (a traditional one, not one of Rob Pike’s Go proverbs) that says “a Jack of all trades is a master of one”, which suggests that it is better to be a good specialist than a bad generalist. We should remember this when we define interfaces in Go.

Make the Zero Value Useful

We have already discussed zero values in detail on this website before — so I’d recommend that you revisit that post, if you need a refresher on what they are and how they work.

Whenever we define a custom type, we should aim to make it useful without any explicit initialization. In other words, if we’re defining a struct, then the zero value of each of its fields should be used as reasonable defaults.

An example of this from the standard library is the bytes.Buffer type, which contains unexported fields that do not need to be initialized. It can be used to concatenate bytes as soon as it is instantiated, as shown in the example below:

package main

import (
	"bytes"
	"fmt"
)

func main() {
	var buffer bytes.Buffer

	buffer.WriteByte('H')
	buffer.WriteByte('e')
	buffer.WriteByte('l')
	buffer.WriteByte('l')
	buffer.WriteByte('o')
	buffer.WriteByte('!')

	fmt.Printf("%s\n", buffer.Bytes())
}

We did not have to call a constructor function to set up the buffer variable; we could simply define it and use it right away.

By ensuring that the zero value is immediately useful, we are reducing the amount of boilerplate code that we need to include, ensuring that our programs are written in a cleaner and more concise way.

interface{} Says Nothing

The interface{} type in Go is known as the “empty interface”, since it has no methods defined on it.

However, we shouldn’t assume that it’s worthless, merely because it’s empty. In fact, it is one of the most important interfaces because it may hold values of absolutely any type.

Since the interface{} type doesn’t tell us anything about a value’s underlying type, or the methods that it supports, we should be careful when using interface{} values, and always use type assertions or other techniques to extract the underlying type and its associated methods when necessary.

We shouldn’t assume that an empty interface has any methods available, even if we know that we are always going to pass values that possess certain methods: if this is the case, we should use more strictly defined interfaces instead.

We should only use empty interfaces when we’re genuinely unsure about what kind of type a section of code may have to work with. If we find ourselves using empty interfaces simply to attempt to bypass Go’s typing system for no important reason, then we are reinventing a duck-typed language, and we should probably be using a programming language like Python or JavaScript instead of Go.

Finally, it’s important to note that since Rob Pike first delivered his talk in 2015, the Go programming language has evolved, and we now have the any type available to use. This is just an alias for the empty interface.

Leave a Reply

Your email address will not be published. Required fields are marked *