Table of Contents:

Mutating Collections

package main

import (
    "fmt"
)

// PrintSlice will print all the values of the slice to standard output.
func PrintSlice(x []int) {
    for _, y := range x {
        fmt.Printf("%d, ", y)
    }
    fmt.Println("\n----")
}

// Add attempts to append a value to the slice.
func Add(x []int, y int) {
    x = append(x, y)
}

func main() {
    x := []int{1}
    Add(x, 2)
    PrintSlice(x)

    // expand the slice to its full capacity.
    x = x[:cap(x)]
    PrintSlice(x)

    // restart, this time using make and a pre-chosen capacity.
    x = make([]int02)
    x = append(x, 1)
    Add(x, 2)
    PrintSlice(x)

    // expand the slice to its full capacity.
    x = x[:cap(x)]
    PrintSlice(x)
}

Prints:

1, 
----
1, 
----
1, 
----
1, 2, 

What on earth is going on here?

So a slice in Golang is actually a pointer to an array with a fixed size. The slice records a virtual start and end index of the array. The array has a fixed size.

When you have a slice append a value, the behavior depends on if the underlying array is large enough to accept the new value.

When the “append” operation is called, and the underlying array lacks the capacity for a new item:

  • It creates a new array, with a larger capacity
  • Then it copies all values from the old array to the new one
  • Then it inserts the new value that was just appended.
  • Then it has the slice point to that new array.

When a slice is passed to a function, you are passing the slice by value, which means it is copied in, but the slice itself holds a pointer (reference) to the underlying array. So this means the operations performed by the called function may or may not be visible to higher functions.

Operations visible to higher code on the stack:

  • altering a value in the slice

Not visible:

  • appending a value

So let’s step through the example.

Step 1

x := []int{1}
Add(x, 2)

When the “Add” function is called, it tries to append to the slice it has. The underlying array of the slice is fixed to size 1, so the append operation creates a new array with at least a capacity of 2, and adds the new value. This only altered the slice that is known inside the “Add” function, which was a copy. Consequently, higher functions have no insight into the change made by the “Add” function, and it effectively does nothing.

Step 2

x = x[:cap(x)]

In the main function, the slice “x” was actually unaltered by the “Add” function, so its local x still has a capacity of just 1. Expanding it here does nothing, the change made by the “Add” function before was made to a copy of the underlying array.

Step 3

x = make([]int, 0, 2)
x = append(x, 1)
Add(x, 2)

This time, we make a slice whose underlying array has a capacity of 2. In the local main function, we append the first value, and this alters the slice. Essentially, the slice x holds a start index of 0, and an end index of 1.

When the Add method is called, it receives a copy of the slice x, and this copy holds a pointer to the underlying array of size 2. Since the underlying array has a capacity exceeding the length of the slice, the append operation in Add does not need to make a new copy of the array. It simply sets the value of the next item in the underlying array, adds one to the length of its local slice, and returns.

However, the Add function had a copy of the slice, so the calling main code was not affected by this. main still has a slice that considers its length to be just 1, but it does point to the same underlying array.

Step 4

x = x[:cap(x)]

This time, the change made by the Add function becomes visible, because it altered the underlying array of x. The full capacity of the slice was 2 from the start, so when the main code expands the length of the slice x, it actually sees the change made by the Add method before.