Understand the slice
In Golang, a slice is a data structure that derives from an array.
The slice’s main feature is the ability to grow in size (unlike an array).
A slice could be defined as a struct:
type Slice struct {
ptr *unsafe.Pointer
len uint
cap uint
}
Let’s break it down.
The most important field is ptr
, a pointer to an underlying array.
The underlying array is an array of the slice’s type.
For a slice:
slice := []int{1, 2, 3}
The underlying array is represented as:
ua := [3]int{1, 2, 3}
len
holds the actual length of the slice. Both the slice and array above have a length of 3.cap
is the capacity of the underlying array, which is also 3 in this example.
Easy.
So why are slices tricky?
All the gotchas derive from two aspects of the language combined:
:=
always creates a copy.ptr
is a pointer that points to a location in memory; it isn’t the array itself.
The garbage collector sweeps objects that are no longer accessible, meaning there’s no reference to them.
Appending to a slice that has reached its maximum capacity results in the allocation of a new underlying array with doubled capacity (until it reaches 1024, after which the slice grows by 25%).
All the items need to be copied to the new array.
Gotchas
Inefficient declaration
Let’s consider this snippet:
myslice := []int{} // Allocating an empty slice: len = 0, cap = 0
for i := 0; i < 1_000_000; i++ {
myslice = append(myslice, i) // Appending 1 million times
}
Keeping in mind why slices are tricky, we can easily spot that the underlying array will be reallocated many times, giving extra work to the CPU and GC.
If we know beforehand how many elements will be in a slice, declare it with make(T, len, cap)
:
slice1 := make([]int, 0, 1_000_000) // Allocating an empty slice: len = 0, cap = 1,000,000
for i := 0; i < 1_000_000; i++ {
slice1 = append(slice1, i) // Appending 1 million times
}
This way, we avoid unnecessary memory reallocations.
Memory leaks
Here’s another snippet:
func leakSomeMemory() []int {
n := 1_000_000
slice1 := make([]int, n, n)
return slice1[:2] // Return a new slice with just the first 2 elements of slice1
}
If we were to return slice1[:2]
from the function, what would be the capacity of this slice?
It’s easy to overlook, but its capacity will be 1,000,000
. Why? Because the returned slice points to the same underlying array as slice1
.
If we’d like to return a slice with just 2 elements and a capacity of 2, we would need to use copy()
:
func leakSomeMemory() []int {
n := 1_000_000
slice1 := make([]int, n, n)
slice2 := make([]int, 2)
copy(slice2, slice1)
return slice2
}
This way, we ensure that the returned slice has its own underlying array with the desired capacity, avoiding potential memory leaks.