In Go, slices are one of the most important data structures, providing developers with a way to work with and manage collections of data similar to what we use at zen8labs. In this blog, I will introduce some internal aspects of slices and highlight some pitfalls to avoid when using slices in Go.
What is a slice?
A slice is a dynamic array, meaning you can extend and shrink its length as needed, whereas arrays in Go are fixed-length data types. Moreover, slices offer all the benefits of indexing, iteration, and garbage collection optimizations because the underlying memory is allocated in sequential blocks.
Slices are objects that reference an underlying array. Go requires three pieces of metadata to initialize a slice: a pointer to the underlying array, the length of the elements the slice can access, and the capacity, which indicates the number of elements the slice can accommodate for growth. By using the built-in functions len(<slice_here>)
and cap(<slice_here>)
, we can inspect the length and capacity information of the slice.
1. Slice internals
Firstly, let’s examine the header value of a slice through the snippet code below:
slice := []int{1, 2, 3}
first_element := &slice[0]
fmt.Printf("%p\\n", slice) // 0xc000198000
fmt.Printf("%p", first_element) // 0xc000198000
=> Header value of a slice point to first element of an underlying array
2. Slicing a slice
When working with slices, understanding the correlation between the length and capacity of a slice helps us avoid some bugs related to slicing a slice.
// slice's length = 5 and slice's capacity = 5.
slice := []int{1, 2, 3, 4, 5}
// slicing
newSlice := slice[1:3]
Here, we have two slices that share the same underlying array. However, each slice views the underlying array differently.
As you can see, the length of newSlice is 2 and the capacity is 4. Can you guess how the length and capacity of a slice are calculated? Let’s check the example below:
// slice's length = 5 and slice's capacity = 5.
slice := []int{1, 2, 3, 4, 5}
// slicing
newSlice := slice[1:3] // i = 1 and j = 3
// For newSlice[i:j] with an underlying array of capacity k
// Length: j - i = 3 - 1 = 2
// Capacity: k - i = 5 - 1 = 4
fmt.Println(len(slice) == 2) // true
fmt.Println(cap(slice) == 4) // true
Now, you know that two slices share the same underlying array. Changing elements in one slice can affect the other slices.
slice := []int{1, 2, 3, 4, 5}
newSlice := slice[1:3]
newSlice[1] = 8
fmt.Println(slice) // [1 2 8 4 5]
fmt.Println(newSlice) // [2 8]
3. Growing slices
Growing slices uses an append function. The append function will return a new slice with the changes. The length of a new slice is increased, and the capacity may or may not be affected, depending on the available capacity of the original slice.
The append operation is a smart function for increasing the capacity of the underlying array. The capacity is doubled whenever the existing capacity of the slice is below 1,000 elements. Once the number of elements exceeds 1,000, the capacity increases by a factor of 1.25, or 25%. This growth algorithm may change in the language over time.
3.1. Length equals to capacity
slice := []int{1, 2, 3}
newSlice := append(slice, 4)
fmt.Println(slice) // [1 2 3]
fmt.Println(newSlice) // [1 2 3 4]
firstElementOfSlice := slice[0]
firstElementOfNewSlice := newSlice[0]
fmt.Println(firstElementOfSlice) // 1
fmt.Println(firstElementOfNewSlice) // 1
fmt.Println(&firstElementOfSlice) // 0xc00018e048
fmt.Println(&firstElementOfNewSlice) // 0xc00018e060
When the underlying array of a slice lacks available capacity, the append function generates a new underlying array. It then copies the existing referenced values and assigns the new value.
After this append operation, newSlice is given its own underlying array, and the capacity of the array is doubled from its original size.
3.2. Length less than capacity
slice := []int{1, 2, 3, 4, 5}
newSlice := slice[1:3]
newSlice = append(newSlice, 6)
As you can observe, when a new element is appended to newSlice, the third element in the original slice is updated. It’s crucial to take note of this behavior, especially when you share a slice across multiple parts of your program, unless you explicitly intend to modify it.
To prevent impacting other slices that share the same underlying array, you can go back to scenario of 1. Length equals capacity. This scenario shows that when appending a new element to a slice, it will create its own underlying array. But how can you achieve this? Don’t worry, Go supports the three-index slice when slicing a slice.
// slice's length = 5 and slice's capacity = 5.
slice := []int{1, 2, 3, 4, 5}
// slicing
newSlice := slice[2:3:3]
// For newSlice[i:j:k] or [2:3:3]
// Length: j - i = 3 - 2 = 1
// Capacity: k - i = 3 - 2 = 1
fmt.Println(len(newSlice) == 1) // true
fmt.Println(cap(newSlice) == 1) // true
// Append a new element to the slice.
newSlice = append(newSlice, 6)
4. Passing slices between functions
Passing a slice into a function means passing a copy of the header value of the original slice but it shares the same underlying data.
slice := make([]int, 1000)
slice = add(slice)
func add(slice []int) []int {
...
return slice
}
Up to this point, you’ve learned that passing a slice can be more efficient than passing an array into a function. This is because passing a slice only involves creating a new header value that points to the same underlying array as the original slice, without duplicating the underlying data (which would be an inefficient operation).
Conclusion
Overall, slices play a pivotal role in Go programming, offering flexibility, efficiency, and ease of use in managing and manipulating data collections. By mastering slice manipulation techniques, developers can build more robust and scalable applications in Go (reference: Go in Action by William Kennedy). If you want to learn about all areas IT related, then reach out to us at zen8labs!
Tung Vu, Software Engineer