Slices

Before diving into make(), let’s explain a definition of a slice. A slice is an abstraction type backed by an array. When we create a slice, basically, we create a pointer to backing array. The length is the number of elements that the slice contains and capacity is the length of backing array.

When using make(), we need to provide the required length and optional capacity. Let’s check out an example:

package main

import "fmt"

func main() {
	slice := make([]int, 2, 4)
	fmt.Println("Len:", len(slice), "Cap:", cap(slice), "Slice:", slice)
}

The output of this code will be:

Len: 2 Cap: 4 Slice: [0 0]

Notice the slice is [0 0] instead of 4 zeros. When we provide a length(size), our slice is initialized to empty values (zero in this case). Capacity is allocated but unused.

What is the point of using length and capacity?

Well, if we create an empty slice like this var slice []int, the size and capacity are set to 0. When we do append, go will do the magic and check backing array. If an array is full (yes, it is), it will extend it, now the size and capacity will be 1. In the next append call, it will extend again and again and again…and since the array is a fixed size, that means for every iteration, it will create a new array with a capacity of old array + X (where X is the number to add for new capacity) and copy all elements from old to new.

This is a very expensive operation!

To showcase how expensive, let’s benchmark it. Here, we have 2 benchmarks. BenchmarkSliceWithSize creates a million length slice while BenchmarkSliceWithoutSize will just append it(dynamic allocation). Both functions are doing the same thing - creating a slice and adding incremented index j

func BenchmarkSliceWithSize(b *testing.B) {
	size := 1_000_000
	for i := 0; i < b.N; i++ {
		slice := make([]int, size)
		for j := 0; j < size; j++ {
			slice[j] = j
		}
	}
}

func BenchmarkSliceWithoutSize(b *testing.B) {
	size := 1_000_000
	for i := 0; i < b.N; i++ {
		var slice []int
		for j := 0; j < size; j++ {
			slice = append(slice, j)
		}
	}
}

Results?

goos: linux
goarch: amd64
cpu: AMD Ryzen 9 5900X 12-Core Processor            
BenchmarkSliceWithSize-24                   1424            825403 ns/op
BenchmarkSliceWithoutSize-24                 301           3956058 ns/op

That’s almost 480% speed increase by just adding a length for a slice!! To summarize, a rule of thumb:

If you are creating a slice and know how many elements you will have, provide a length and/or capacity

Examples where you might increase performance:

  • When you are transferring from one data type to another (db model to view model etc..)
  • When you are fetching records from database/api with limit (you will always fetch 10 elements)
  • When you know the initial size but you are not sure about total size(use capacity :))

Channels & Maps

Unlike slices, channels and maps are different. When we make a channel, there are 2 options

  • Unbuffered channel
  • Buffered channel

Unbuffered channel

package main

func main() {
	ch := make(chan bool)

	ch <- true
	// this will cause a deadlock, since there is no consumer.
	ch <- false
}

Buffered channel

package main

func main() {
	ch := make(chan bool, 2)

	ch <- true
	// no deadlock, capacity is 2
	ch <- false
}

If we inspect the len of a channel, we will see that in both cases it’s always 0. capacity is different, in first case is 0 and in second is 2. If you want to check if a channel is buffered and how much, you should use cap() method.

Map in go provides an unordered collection of key-value pairs in which all the keys are distinct.

A map is just a hash table. The data is arranged into an array of buckets. Each bucket contains up to 8 key/elem pairs. If more than 8 keys hash to a bucket, we chain on extra buckets. When the hashtable grows, we allocate a new array of buckets twice as big. Buckets are incrementally copied from the old bucket array to the new bucket array.

In go, runtime.hmap represents the map, which you can view it here

So, the same thing as slices, if we don’t provide a size, the runtime needs to “struggle” with allocation. Let’s see on benchmark:

package main

import "testing"

func BenchmarkMapWithSize(b *testing.B) {
	size := 1_000_000
	for i := 0; i < b.N; i++ {
		m := make(map[int]int, size)
		for j := 0; j < size; j++ {
			m[j] = j
		}
	}
}

func BenchmarkMapWithoutSize(b *testing.B) {
	size := 1_000_000
	for i := 0; i < b.N; i++ {
		m := make(map[int]int)
		for j := 0; j < size; j++ {
			m[j] = j
		}
	}
}
goos: linux
goarch: amd64
cpu: AMD Ryzen 9 5900X 12-Core Processor            
BenchmarkMapWithSize-24               24          48046318 ns/op
BenchmarkMapWithoutSize-24            12          95607595 ns/op

Which is almost 200% speed increase by just adding a length for a map.

While it’s not double as slices, it’s because the map points to a bucket of an array that has the size of 8, instead of a backing array so it performs much faster. One thing you can also note is that maps do not have a cap() method, only len() which tells you how many elements (keys) are in the map.

So now, you are probably wondering why to use append and why not assigning to a direct index. For this, let’s look at an example in the go source code located here: browser.go. It’s a small snippet:

// Commands returns a list of possible commands to use to open a url.
func Commands() [][]string {
	var cmds [][]string
	if exe := os.Getenv("BROWSER"); exe != "" {
		cmds = append(cmds, []string{exe})
	}
	switch runtime.GOOS {
	case "darwin":
		cmds = append(cmds, []string{"/usr/bin/open"})
	case "windows":
		cmds = append(cmds, []string{"cmd", "/c", "start"})
	default:
		if os.Getenv("DISPLAY") != "" {
			// xdg-open is only for use in a desktop environment.
			cmds = append(cmds, []string{"xdg-open"})
		}
	}
	cmds = append(cmds,
		[]string{"chrome"},
		[]string{"google-chrome"},
		[]string{"chromium"},
		[]string{"firefox"},
	)
	return cmds
}

Here we don’t see a clear length of a cmds. If we set an environment variable, we will add it string slices (that’s 1 length). Depending on browser, we will add different command - it can be 1 or 0 length (if we move to default and no display env is set) and finnaly, whatever we fill in cmds, we will add list of browser which is 4. So total size can be somewhere from 4 to 6. What if we add a new browser? We will also need to add +1 in our make() length command.

This is prone to errors and bugs. For a such small optimization, it’s not worth it. Plus, it will look complicated and not easy to read.

That’s it!

Happy coding!