Flexible Go Slice Data Structures

GoGoBeginner
Practice Now

Introduction

In the previous section, we discussed arrays in Go. However, arrays have limitations: once declared and initialized, their length cannot be changed. Therefore, arrays are not widely used in daily programming. In contrast, slices are more commonly used and provide a more flexible data structure.

Knowledge Points:

  • Define a slice
  • Initialize a slice
  • Operations on slices, i.e., add, delete, modify, and search
  • Expand a slice
  • Slice truncation
  • Multi-dimensional slices

Skills Graph

%%%%{init: {'theme':'neutral'}}%%%% flowchart RL go(("`Go`")) -.-> go/BasicsGroup(["`Basics`"]) go(("`Go`")) -.-> go/DataTypesandStructuresGroup(["`Data Types and Structures`"]) go(("`Go`")) -.-> go/FunctionsandControlFlowGroup(["`Functions and Control Flow`"]) go(("`Go`")) -.-> go/ObjectOrientedProgrammingGroup(["`Object-Oriented Programming`"]) go/BasicsGroup -.-> go/variables("`Variables`") go/DataTypesandStructuresGroup -.-> go/slices("`Slices`") go/FunctionsandControlFlowGroup -.-> go/functions("`Functions`") go/DataTypesandStructuresGroup -.-> go/pointers("`Pointers`") go/DataTypesandStructuresGroup -.-> go/structs("`Structs`") go/ObjectOrientedProgrammingGroup -.-> go/struct_embedding("`Struct Embedding`") subgraph Lab Skills go/variables -.-> lab-149077{{"`Flexible Go Slice Data Structures`"}} go/slices -.-> lab-149077{{"`Flexible Go Slice Data Structures`"}} go/functions -.-> lab-149077{{"`Flexible Go Slice Data Structures`"}} go/pointers -.-> lab-149077{{"`Flexible Go Slice Data Structures`"}} go/structs -.-> lab-149077{{"`Flexible Go Slice Data Structures`"}} go/struct_embedding -.-> lab-149077{{"`Flexible Go Slice Data Structures`"}} end

What is a Slice

Slices are similar to arrays; they are containers that hold elements of the same data type. However, arrays have limitations: once declared and initialized, their length cannot be changed. Although arrays have their use cases, they are not as flexible. Therefore, slices are more commonly used in day-to-day programming.

In Go, slices are implemented using arrays. A slice is essentially a dynamic array that can change in length. We can perform operations such as adding, deleting, modifying, and searching for elements in a slice, which cannot be done with an array.

Define a Slice

The initialization syntax for a slice is very similar to that of an array. The main difference is that the length of the element does not need to be specified. Let's take a look at the following code:

// Declare an array with a length of 5
var a1 [5]byte
// Declare a slice
var s1 []byte

A Slice is a Reference to an Array

If we declare an array of type int, the zero value for each element of the array will be 0.

However, if we declare a slice, the zero value for the slice will be nil. Let's create a file called slice.go to verify this:

touch ~/project/slice.go

Enter the following code:

package main

import "fmt"

func main() {
    var a [3]int
    var s []int

    fmt.Println(a[0] == 0) // true
    fmt.Println(s == nil)  // false
}

After running the code, the following output will be displayed:

true
true

We have created an array a and a slice s. We compare the first element of the array a with zero, and check whether the slice s is nil.

As we can see, when we declare a slice, its zero value is nil. This is because slices do not store any data; they only reference arrays. The slice points to the underlying array structure. Let's take a look at the data structure of a slice.

Data Structure of a Slice

A slice is a composite data type, which can also be called a structure (struct). It is a composite type composed of fields of different types. There are three types in the internal structure of a slice: a pointer, a length, and a capacity.

type slice struct {
    elem *type
    len  int
    cap  int
}

As mentioned earlier, the structure references the underlying array. The elem pointer points to the first element of the array, and the type is the type of the referenced array element.

len and cap represent the length and capacity of the slice, respectively. We can use the len() and cap() functions to get the length and capacity of the slice.

The following image shows that the slice references an underlying array of type int, and has a length of 8 and a capacity of 10:

image

When we define a new slice, the elem pointer is initialized to the zero value (i.e., nil). We will introduce the concept of pointers in subsequent experiments. For now, let's just note that a pointer points to the memory address of a value. In the above image, the elem pointer points to the address of the first element of the underlying array.

Truncating Arrays or Slices

Since the underlying structure of a slice is an array, we can extract a specified length of the array as a reference for the slice. The following code segment demonstrates this:

package main

import "fmt"

func main() {
    // Define an integer array with a length of 10
    a := [10]int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}

    // Declare an empty slice
    var s1 []int

    fmt.Println("Slice s1 is empty: ", s1 == nil)

    // Use array truncation to obtain the slice
    s1 = a[1:5]
    s2 := a[2:5]
    s3 := a[:]

    fmt.Println("Slice s1, s2, and s3 are empty: ", s1 == nil, s2 == nil, s3 == nil)
    fmt.Println("Elements in array a: ", a)
    fmt.Println("Elements in array a: ", s1)
    fmt.Println("Elements in array a: ", s2)
    fmt.Println("Elements in array a: ", s3)
}

The output is as follows:

Slice s1 is empty:  true
Slice s1, s2, and s3 are empty:  false false false
Elements in array a:  [0 1 2 3 4 5 6 7 8 9]
Elements in array a:  [1 2 3 4]
Elements in array a:  [2 3 4]
Elements in array a:  [0 1 2 3 4 5 6 7 8 9]

In this program, we first declare and initialize the array a, and then use array truncation to assign a part of the array to the empty slice s1. By doing so, we create a new slice.

s1[1:5] represents the creation of a slice of the array. The range of the slice is from index 1 of array a to index 5, excluding the fifth element.

Note: In programming languages, the first element is 0, not 1. Similarly, the second element in the array has an index of 1.

We use the := operator to assign the truncated array directly to the slice s2. The same applies to s3, but no range is specified, so it truncates all the elements of the array.

The image below represents the truncation. Note that the green part of the slice represents a reference to the blue array. In other words, they both share the same underlying array, which is a.

image

The truncation syntax for slices is as follows:

[start:end]

Both start and end are optional arguments. When we want to get all the elements of the array, we can omit both start and end arguments. This is demonstrated in the s3 := a[:] in the previous program.

If we want to retrieve all the elements after a certain index, we can omit the end parameter. For example, a1[3:] will retrieve all the elements starting from index 3.

To retrieve all the elements before a certain index, we can omit the start parameter. For example, a1[:4] will extract all elements from index 0 to index 4, excluding the element at index 4.

In addition to extracting the slice from an array, we can also extract a new slice from an existing one. The operation is the same as with arrays. The following is a simple example:

package main

import "fmt"

func main() {
    // Define an integer array with a length of 10
    a := [10]int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}

    // Create the initial slice s1
    var s1 []int
    s1 = a[1:7]
    fmt.Printf("Slice s1: %d\tLength: %d\tCapacity: %d\n", s1, len(s1), cap(s1))

    // Extract a new slice s2 from the initial slice s1
    s2 := s1[2:4]
    fmt.Printf("Slice s1: %d\tLength: %d\tCapacity: %d\n", s2, len(s2), cap(s2))
}

The output is as follows:

Slice s1: [1 2 3 4 5 6]	Length: 6	Capacity: 9
Slice s1: [3 4]	Length: 2	Capacity: 7

In this program, we obtained slice s1 by truncating array a. The range of s1 is from index 1 to index 7. By using :=, we extracted a new slice s2 from s1. Since the truncation of s1 is contiguous, the new slice s2 will also be contiguous.

We notice that the capacity of a slice changes as we truncate it. The rules are as follows:

If we truncate a slice with a capacity of c, the length of s[i:j] will be j-i, and the capacity will be c-i.

For s1, the underlying array is a, and the capacity of a[1:7] is 9 (i.e., 10-1).

For s2, the underlying array is the same as that of s1. Since the truncated part has a capacity of 9 in the previous step, the capacity of s1[2:4] becomes 7 (i.e., 9-2).

Modifications to Slice Values Simultaneously Affect the Values of Underlying Array Elements

Since the slice does not store data, but only references an array, modifying the value of a slice will also simultaneously change the value of the underlying array. Let's demonstrate this:

package main

import "fmt"

func main() {
    // Define an integer array with a length of 10
    a := [10]int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}

    s1 := a[2:5]
    s2 := a[:]

    fmt.Println("Before Modification: ")
    fmt.Println("Elements in array a: ", a)
    fmt.Println("Elements in array a: ", s1)
    fmt.Println("Elements in array a: ", s2)

    // Modify the value at index 2 of the slice s1 to 23
    s1[2] = 23

    fmt.Println("After Modification: ")
    fmt.Println("Elements in array a: ", a)
    fmt.Println("Elements in array a: ", s1)
    fmt.Println("Elements in array a: ", s2)
}

The output is as follows:

Before Modification:
Elements in array a:  [0 1 2 3 4 5 6 7 8 9]
Elements in array a:  [2 3 4]
Elements in array a:  [0 1 2 3 4 5 6 7 8 9]
After Modification:
Elements in array a:  [0 1 23 3 4 5 6 7 8 9]
Elements in array a:  [2 3 23]
Elements in array a:  [0 1 23 3 4 5 6 7 8 9]

In this program, both the slice s1 and s2 reference the array a. When the slice s1 modifies the value at index 2 to 23, the values of the array a and the slice s2 are also updated.

We can see that the value at index 2 of the array is modified to 23, which results in the modification of the value at index 4 of the slice s2.

This would cause difficult-to-debug bugs in program development. Therefore, in daily programming, we should try to avoid multiple slices referencing the same underlying array to the greatest extent possible.

Appending Elements to a Slice

In this section, we will introduce the append function, which is used to add elements to a slice. The syntax is as follows:

func append(slice []Type, elems ...Type) []Type

The first argument is the slice slice, and the remaining arguments are the elements to be added to the slice. The []Type at the end indicates that the append function will return a new slice with the same data type as slice.

The elems after ... represents that this is a variadic parameter, which means that one or more parameters can be input.

Here is an example of using the append function:

package main

import "fmt"

func main() {
    // Define an integer array with a length of 10
    a := [10]int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}

    s1 := a[1:7]
    fmt.Printf("Initial s1 value: %d\tLength: %d\tCapacity: %d\n", s1, len(s1), cap(s1))

    s1 = append(s1, 12)
    fmt.Printf("Modified s1 value: %d\tLength: %d\tCapacity: %d\n", s1, len(s1), cap(s1))

    s1 = append(s1, 14, 14)
    fmt.Printf("Modified s1 value: %d\tLength: %d\tCapacity: %d\n", s1, len(s1), cap(s1))
}

The output is as follows:

Initial s1 value: [1 2 3 4 5 6]	Length: 6	Capacity: 9
Modified s1 value: [1 2 3 4 5 6 12]	Length: 7	Capacity: 9
Modified s1 value: [1 2 3 4 5 6 12 14 14]	Length: 9	Capacity: 9

In this program, we first create slice s1 by truncating array a. The range of s1 is from index 1 to index 7. We use the := operator to assign the return value of append to s1. When we add an element using append, the capacity of the slice will change. The capacity of s1 will be doubled if the number of elements exceeds the capacity.

The expansion rules for slices are as follows:

  • If the underlying array of a slice can accommodate new elements, the capacity of the slice will not change.
  • If the underlying array of a slice cannot accommodate new elements, Go will create a larger array, copy the values of the original slice into the new array, and then add the appended value to the new array.

Note: Slice expansion is not always doubling; it depends on the size of the elements, the number of elements being expanded, and the computer's hardware, among other factors. For more information, please refer to the advanced section on slices.

Deleting Elements in a Slice

Go does not provide keywords or functions to delete elements from a slice, but we can use array truncation to achieve the same functionality, or a more powerful capability.

The following code deletes the element at index 5 in the slice s and assigns it to a new slice s1:

package main

import "fmt"

func main() {
    s := []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}
    // Print the slice `s` before deleting
    fmt.Println(s)

    s1 := append(s[:5], s[6:]...)
    // Print the slices `s` and `s1` after deleting the element at index 5
    fmt.Printf("%d\n%d\n", s, s1)
}

The output is as follows:

[0 1 2 3 4 5 6 7 8 9]
[0 1 2 3 4 6 7 8 9 9]

The key to deleting a specific index lies in the append statement. It appends the elements after index 6 to the elements before index 5.

This operation is equivalent to overwriting in advance. For example, element 5 is overwritten with element 6, element 6 is overwritten with element 7, and so on, until element 9, which is added to the new slice. Therefore, the value of the underlying array at this point is [0 1 2 3 4 6 7 8 9 9].

At this point, if we check the length and capacity of s1, we can see that the length is 9, but the capacity is 10. This is because s1 is a reference to the truncation of s. They share the same underlying array.

package main

import "fmt"

func main() {
    s := []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}
    fmt.Println(s)

    s1 := append(s[:5], s[6:]...)
    // Check the length and capacity of s1
    fmt.Println(len(s1), cap(s1))
    fmt.Printf("\n%d\n%d\n\n", s, s1)

    // Modify the slice s
    s[3] = 22
    fmt.Printf("%d\n%d\n", s, s1)
}

The output is as follows:

[0 1 2 3 4 5 6 7 8 9]
9 10

[0 1 2 3 4 5 6 7 8 9]
[0 1 2 3 4 6 7 8 9 9]

[0 1 2 22 4 5 6 7 8 9]
[0 1 2 22 4 6 7 8 9 9]

Exercise

In addition to deleting elements at specific positions, we can also delete a specific range of elements from a slice using truncation. The operation is the same as deleting an element at a specific index. Let's do a small exercise.

Create a file slice1.go. Create a slice a and initialize it as follows. Then use truncation to create another slice s that does not include elements larger than 3 or smaller than 7. Finally, print the new slice.

a := []int{9, 8, 7, 6, 5, 4, 3, 2, 1, 0}

Output:

[9 8 7 3 2 1 0]

Hint: Pay attention to the starting index of the slice (remember, the index starts from 0).

Requirements: The slice1.go file needs to be placed in the ~/project directory.

Expanding Slices

A slice has two attributes: len and cap. len represents the number of values currently in the slice, while cap represents the maximum number of values the slice can hold.

What happens when the number of elements added to a slice exceeds its capacity? Let's find out together:

package main

import "fmt"

func main() {
    s1 := make([]int, 3)
    s2 := make([]int, 3, 5)
    fmt.Println("Before Append in s1:", s1, "Length:", len(s1), "Capacity:", cap(s1))
    fmt.Println("Before Append in s2:", s2, "Length:", len(s2), "Capacity:", cap(s2))

    s1 = append(s1, 12)
    s2 = append(s2, 22)
    fmt.Println("After Append in s1:", s1, "Length:", len(s1), "Capacity:", cap(s1))
    fmt.Println("After Append in s2:", s2, "Length:", len(s2), "Capacity:", cap(s2))
}

The output is as follows:

Before Append in s1: [0 0 0] Length: 3 Capacity: 3
Before Append in s2: [0 0 0] Length: 3 Capacity: 5
After Append in s1: [0 0 0 12] Length: 4 Capacity: 6
After Append in s2: [0 0 0 22] Length: 4 Capacity: 5

As we can see in this program, when we add elements to a slice and the number of elements exceeds its original capacity, the capacity of the slice is automatically doubled.

The rules for expanding a slice are as follows:

  • If the underlying array of a slice can hold new elements, the capacity of the slice will not change.
  • If the underlying array of a slice cannot hold new elements, Go will create a larger array, copy the values from the original slice to the new array, and then add the appended values to the new array.

Note: Doubling the capacity of a slice is not always the case; it depends on the size of the elements, the number of elements, and the hardware of the computer. For more information, please refer to the advanced section on slices.

Copying Slices

We can use the copy function to duplicate one slice on another. The syntax is as follows:

func copy(dst, src []Type) int

dst is the destination slice, src is the source slice, and the final int indicates the number of elements copied; it is the minimum of len(dst) and len(src).

Note: The copy function does not add elements.

Here is an example:

package main

import "fmt"

func main() {
    s1 := []int{0, 1, 2, 3}
    s2 := []int{8, 9}

    s3 := []int{0, 1, 2, 3}
    s4 := []int{8, 9}

    // Copy s1 to s2
    n1 := copy(s2, s1)
    // Copy s4 to s3
    n2 := copy(s3, s4)

    fmt.Println(n1, s1, s2)
    fmt.Println(n2, s3, s4)
}

The output is as follows:

2 [0 1 2 3] [0 1]
2 [8 9 2 3] [8 9]

In this program, we copied the values of slice s1 to slice s2, and the values of slice s4 to slice s3. The copy function returns the number of elements copied.

We note that the values of s1 and s2 are the same, as well as the values of s3 and s4. The first copy function copies s1[0, 1, 2, 3] to s2[8, 9]. Since the minimum length of s1 and s2 is 2, it copies 2 values. The destination slice s2 is modified to [0, 1].

The second copy function copies s4[8, 9] to s3[0, 1, 2, 3]. Since the minimum length of s3 and s4 is 2, it copies 2 values. As a result, s3 is modified to [8, 9, 2, 3].

Traversing Slices

Traversing a slice is similar to traversing an array. All array traversal methods can be used for slices as well.

Exercise

In this exercise, we will test and reinforce our understanding of traversing slices and arrays.

Create a file slice2.go. Declare an array a1 and a slice s1, and initialize them as follows. Then, iterate over the elements in the array a1 and the slice s1, and print their indices and values.

a1 := [5]int{1, 2, 3, 9, 7}
s1 := []int{1, 8, 12, 1, 3}

Output:

Element a1 at index 0 is 1
Element a1 at index 1 is 2
Element a1 at index 2 is 3
Element a1 at index 3 is 9
Element a1 at index 4 is 7
Element s1 at index 0 is 1
Element s1 at index 1 is 8
Element s1 at index 2 is 12
Element s1 at index 3 is 1
Element s1 at index 4 is 3

Requirements: The slice2.go file needs to be placed in the ~/project directory.

Hint: You can use the 'range' format or the index format to iterate through elements.

Summary

In this section, we have learned about slices and their usage. Comparatively, slices are more flexible and versatile than arrays. When manipulating multiple slices, we need to be careful to prevent unexpected slice operations.

In the next section, we will summarize the structures of strings, arrays, and slices.

Other Go Tutorials you may like