paint-brush
Let’s Have a Talk About Go Slices by@lvl0nax
101 reads

Let’s Have a Talk About Go Slices

by Dmitrii GautselJune 12th, 2023
Read on Terminal Reader
Read this story w/o Javascript
tldt arrow

Too Long; Didn't Read

Go's slice data structure is a more flexible alternative to arrays. Slices are dynamically resizable and can grow in size when needed. We should always keep some pitfalls and edge cases in mind.
featured image - Let’s Have a Talk About Go Slices
Dmitrii Gautsel HackerNoon profile picture

Today we will discover Slices, one of the core data structures in Go. It is a flexible and powerful structure that helps developers work with element collections. However, it's only possible to cover this topic with a basic understanding of arrays because the slice is essentially an abstraction above arrays in Go.


Disclaimer: This article was conceived not only as an attempt to structure and share information but also to practice English. While working on the article, I used official documentation and AI tools like Grammarly to correct my mistakes. So, the whole text went through this tool and was corrected according to recommendations.


Arrays

An array is a fixed-size data structure that can store a collection of elements of the same type. For example, the type [4]int represents an array of four integers. And the size of an array is determined and can't be changed.


 var a [4]int // an array of 4 integers with four zero-value items 
 a[1] = 5 // [0 5 0 0] assigning 5 to the second element 


Also, an array literal could be specified like this:

ar := [4]int{1, 2} 
// [1 2 0 0] array of 4 elements - 2 predefined and 2 zero-value elements    


We don't need to initialize all elements explicitly; it always keeps a zero value for all uninitialized items. And the in-memory representation of [4]int we have four integer values laid out sequentially:


arrays in Go


And one more important point, Go's arrays are always values. When you want to pass your array to a function, Go will always make a copy unless you pass a pointer to the array.


package main

import "fmt"

func main() {
	var a [4]int
	a[1] = 5
	fmt.Printf("before a array: %p, %v \n", &a, a)
	// before a array: 0xc0000b2000, [0 5 0 0]
	checkArray(a)
	fmt.Printf("after a array: %p, %v \n", &a, a)
	// after a array: 0xc0000b2000, [0 5 0 0]
}

func checkArray(ar [4]int) {
	ar[2] = 4
	fmt.Printf("func array: %p, %v \n", &ar, ar)
	// func array: 0xc0000b2060, [0 5 4 0]
}


As you can see, the values of the arrays in the main function and checkArray are the same, but the pointers (0xc00…) are different. It means that in the checkArray function, we deal with a copy of the original array.


Despite this limitation, arrays in Go are still a powerful tool for working with collections of data, especially when the size of the collection is known in advance and can’t be changed, for example, UUID - acde070d-8c4c-4f0d-9d8a-162843c10333. However, when it comes to working with collections that may need to be resized or manipulated dynamically, Go provides the slice data structure as a more flexible alternative to arrays.


Slices

So, now we have a basic picture of arrays and can try to understand what slices are and how to work with them. The main advantage and difference between slices and arrays in Go are that slices are dynamically resizable and can grow in size when needed. And you can see it when you specify a slice: var nums []int. It will be a collection of integers with dynamic size.



A slice literal can be declared in a similar way to an array, but without specifying the size of the collection:

letters := []string{"a", "b", "c", "d"}


Alternatively, slices can be declared by using the built-in make function. The make takes two required parameters: a type and a length, and one optional - capacity.


Here is an example of slice creation via the make function:

nums := make([]int, 5, 8) // [0 0 0 0 0]
len(nums) // 5 - len: built-in function shows the length of nums
cap(nums) // 8 - cap: built-in function shows the capacity of nums


When the function make is called, it allocates an array and returns the refers to that array. Let's go a bit deeper and check what it looks like under the hood.


In Go, a slice is a data structure composed of three components: a pointer to the underlying array, the length, and the capacity of the slice. Using the example you provided, let's break down the slice structure:


slice in Go


The pointer refers to the first element of the underlying array related to the slice. The length represents the number of items in the slice, which is the actual number of items currently being used. The capacity represents the maximum number of elements that the slice can hold before it needs to be resized.


When we create a slice, Go automatically allocates an underlying array to hold its elements. As the slice is used, the pointer, length, and capacity are updated to reflect the current state of it. When the slice needs to grow beyond its current capacity, Go allocates a new, larger underlying array, copies the elements from the old array to the new one, and updates the slice's pointer, length, and capacity to reflect the latest state of the slice.


So, now we know at least three ways to declare an empty slice:

var nums1 []int
nums2 := []int{}
nums3 := make([]int, 0, 4)


And sometimes, you need to check if a slice is empty. Because the zero value of the slice is nil, you may decide that comparing with nil is a good way to check if a slice is empty, but you have to be careful here. It’s better to use built-in len function for this purpose.


Let’s check it:

package main

import (
	"fmt"
)

func main() {
	var nums1 []int
	nums2 := []int{}
	nums3 := make([]int, 0, 4)
	fmt.Println("nums1 is nil:", nums1 == nil)
    // nums1 is nil: true
	fmt.Println("nums2 is nil:", nums2 == nil)
    // nums2 is nil: false
	fmt.Println("nums3 is nil:", nums3 == nil)
    // nums3 is nil: false

	fmt.Println("nums1 is empty:", len(nums1) == 0)
    // nums1 is empty: true
	fmt.Println("nums2 is empty:", len(nums2) == 0)
    // nums2 is empty: true
	fmt.Println("nums3 is empty:", len(nums3) == 0)
    // nums3 is empty: true
}



Let’s look at another way to declare a slice.


It can also be formed by “slicing” an existing slice or array. Slicing can be done by specifying a half-open range with two indices separated by a colon. For example, the expression nums[2:5] creates a slice including elements 2 through 4 of nums (the indices of the resulting slice will be 0 through 2).


"Slicing" slice in Go


nums1 := make([]int, 5, 8)
nums1[0] = 0
nums1[1] = 1
nums1[2] = 2
nums1[3] = 3
nums1[4] = 4
nums2 := nums1[2:5]
// nums1 - [0 1 2 3 4] len - 5, cap - 8
// nums2 -     [2 3 4] len - 3, cap - 6


There is one more option for creating a new structure with its pointer, length, and capacity, but the underlying array will be the same as for the original slice. The main difference is that pointer refers to another element of the array. So knowing this, we can easily explain the following behavior:


package main

import "fmt"

func main() {
	nums1 := make([]int, 5, 8) // [0 0 0 0 0]
	nums2 := nums1[2:5]        //     [0 0 0]
	nums2[2] = 99

	fmt.Println("nums1: ", nums1) // nums1: [0 0 0 0 99]
	fmt.Println("nums2: ", nums2) // nums2:     [0 0 99]
}


As we can see, modifying the second slice, in this particular case, also changes the original slice. And it happens because we modify the underlying array, which is the same for both slices. And this is one of the pitfalls which we should always remember working with slices.


Now we need to talk about one of the most useful features of Go's slices, the append function, which allows you to add elements to a slice dynamically. The append function is a built-in function that takes a slice and one or more elements as input and returns a new slice with the additional items appended to the end. This article will explore the append function in detail, including how it works, common use cases, and potential pitfalls to avoid. It has the signature:


func append(s []T, x ...T) []T


where T stands for the element type of the slice. Let's check how it works.


package main

import "fmt"

func main() {
	nums1 := []int{}
	fmt.Println("nums1: ", nums1, "len: ", len(nums1), "cap: ", cap(nums1))
	nums1 = append(nums1, 1)
	fmt.Println("nums1: ", nums1, "len: ", len(nums1), "cap: ", cap(nums1))
	nums1 = append(nums1, 2)
	fmt.Println("nums1: ", nums1, "len: ", len(nums1), "cap: ", cap(nums1))
	nums1 = append(nums1, 3)
	fmt.Println("nums1: ", nums1, "len: ", len(nums1), "cap: ", cap(nums1))
	nums1 = append(nums1, 4, 5) 
	fmt.Println("nums1: ", nums1, "len: ", len(nums1), "cap: ", cap(nums1))
}

// nums1:  []          len:  0 cap:  0
// nums1:  [1]         len:  1 cap:  1
// nums1:  [1 2]       len:  2 cap:  2
// nums1:  [1 2 3]     len:  3 cap:  4
// nums1:  [1 2 3 4 5] len:  5 cap:  8
}


So as we can see from the example above, length and capacity grow whenever we append an element to the slice. Basically, it means that in this case, every time we append an element, we check if there is enough space in the underlying array, and if so, we just put the item in the next free slot in the array. However, if there are no free slots, Go allocates memory for a double-sized array, copies all elements from the original array to the new one, and then adds the new item to the new backing array. That's why we can see how the capacity doubles whenever we reach the limit of the underlying array. It also means that if we know the size of the slice, it's better to allocate enough memory when we create the slice, for example, with the third parameter - capacity - of the make function.


And again, we should remember that appending to a "sliced" slice could modify the original slice. Let's see this "tricky" behavior in the following example with code:


nums1 := make([]int, 6, 6) // [0 0 0 0 0 0] len: 6, cap: 6
// let's fill the slice with numbers
for i := 0; i < len(nums1); i++ {
	nums1[i] = i
}                   // nums1 -> [0 1 2 3 4 5]  
nums2 := nums1[2:5] // nums2 ->     [2 3 4]  len: 3, cap: 4

nums2 = append(nums2, 22)
// nums2 ->     [2 3 4 22]
// nums1 -> [0 1 2 3 4 22] - we also changed the original slice


As we can see, while the backing array has enough capacity to append new elements, the original and sliced slices use the same piece of memory.


Let's continue with the example above, with both slices at the capacity limit.

nums1 = append(nums1, 6)
// nums1 -> [0 1 2 3 4 22 6]
// nums2 ->     [2 3 4 22]

nums2 = append(nums2, 33)
// nums1 -> [0 1 2 3 4 22 6]  len: 7, cap: 12
// nums2 ->     [2 3 4 22 33] len: 5, cap: 8


When we append to each slice, we allocate a new array for each of the slices as underlying arrays are different for them. But anyway, the behavior with sliced-slice looks counter-intuitive, and there is a way to avoid this.


Let's fix it:

nums1 := make([]int, 6, 6) // [0 0 0 0 0 0] len: 6, cap: 6
// let's fill the slice with numbers
for i := 0; i < len(nums1); i++ {
	nums1[i] = i
}                     // nums1 -> [0 1 2 3 4 5]
nums2 := nums1[2:5:5] // nums2 ->     [2 3 4]  len: 3, cap: 3

nums2 = append(nums2, 22)
// nums1 -> [0 1 2 3 4 5]
// nums2 ->     [2 3 4 22] len: 4, cap: 6


In this example, we use full slice expression to create nums2 slice. It has this annotation a[low : high : max] , and it makes a slice with the following attributes: low basically shows the index of the first element, len = high - low, and cap = max - low. And now, when we append to the nums2, we allocate a new backing array, and modification of the nums2 slice no longer affects the original slice.


Let’s take a look at another example of using slices:

package main

import "fmt"

func main() {
	nums := []int{1, 2, 3, 4}
	fmt.Printf("nums before: %p, %v \n", &nums, nums)
	modify(nums)
	fmt.Printf("nums after: %p, %v \n", &nums, nums)  
}

func modify(list []int) {
	fmt.Printf("modifying list: %p, %v \n", &list, list) 
	list[2] = 100
}

// nums before:    0xc000010030, [1 2 3 4]
// modifying list: 0xc000010060, [1 2 3 4] 
// nums after:     0xc000010030, [1 2 100 4]


What happened here?


We created a slice - nums- and passed the slice to the modify function by value. As you can see, the pointer to the slice has been changed in the second output, which means that we copied the slice, but again slice is just a struct with three components - pointer, length, and capacity. Go copied this struct, but not the backing array, and now when we modify this copied slice, we change the underlying array for the original slice. To fix this issue, we can create a new slice and copy all elements to this slice with, for example, another built-in function - copy.


package main

import "fmt"

func main() {
	nums := []int{1, 2, 3, 4}
	fmt.Printf("nums before: %p, %v \n", &nums, nums)
	modify(nums)
	fmt.Printf("nums after: %p, %v \n", &nums, nums)
}

func modify(list []int) {
	newSlice := make([]int, len(list))
	copy(newSlice, list)
	fmt.Printf("before list: %p, %v \n", &newSlice, newSlice)
	newSlice[2] = 100
	fmt.Printf("after list: %p, %v \n", &newSlice, newSlice) 
}

// nums before: 0xc0000a8018, [1 2 3 4] 
// before list: 0xc0000a8048, [1 2 3 4] 
// after list:  0xc0000a8048, [1 2 100 4]
// nums after:  0xc0000a8018, [1 2 3 4] 

Now we copied the slice and the underlying array, and the issue has gone.


Conclusion

Slices are powerful data structures in Go that provide a flexible and efficient way to work with collections of elements. With their built-in resizing support and rich set of methods, slices offer a versatile alternative to arrays particularly well-suited for dynamic data. However, as with any programming construct, there are pitfalls to be aware of when working with slices. In this article, we have explored the most common traps associated with slices, such as misunderstanding how they are implemented, copying slices by value, etc. By understanding these pitfalls and following best practices for working with slices, you can avoid common errors and maximize this powerful data structure in your Go programs.


The lead image for this article was generated by HackerNoon's AI Image Generator via the prompt "Slices"