~/blog/are_slices_copied_by_value_or_reference
Published on

Are slices copied by value or by reference?

1610 words9 min read
Authors
  • avatar

Consider the following programme (Go playground link). How is it that multiplyByTwo has amended the slice but duplicate hasn't? How can a slice be both passed by reference (multiplyByTwo) and passed by value (duplicate) without using pointers?

That's exactly what we'll be walking through in this article.

func main() {
	s := []int{1,2,3,4}

	fmt.Println("before multiplyByTwo: ", s) // [1 2 3 4]

	multiplyByTwo(s)

	fmt.Println("after multiplyByTwo: ", s) // 2 4 6 8

	duplicate(s)

	fmt.Println("after duplicate: ", s) // 2 4 6 8
}

// multiplyByTwo accepts a slice of ints and multiplies each
// index by two.
func multiplyByTwo(s []int) {
	for i, n := range s {
		s[i] = n*2
	}
}

// duplicate accepts a slice of ints and duplicates each entry
// exactly once
func duplicate(s []int) {
	l := len(s)
	for i:=0; i<l; i++ {
	  s = append(s, s[i])
	}
}

A slice is not an array

While there are many similarities between arrays and slices (e.g. they can both be used with len and cap, and both types can be indexed), there are some fundamental differences.

func main() {
	slc := []int{1,2,3,4,5}
	arr := [5]int{1,2,3,4,5}

	fmt.Printf("Slice length: %d, Slice Capacity: %d \n", len(slc), cap(slc)) // Slice length: 5, Slice Capacity: 5
	fmt.Printf("Array length: %d, Array Capacity: %d \n", len(arr), cap(arr)) // Array length: 5, Array Capacity: 5

	fmt.Printf("Slice First: %d \n", slc[0]) // Slice First: 1
	fmt.Printf("Array First: %d \n", arr[0]) // Array First: 1
}

Note: The following is not a comprehensive guide to the differences/similarities between arrays and slices, only enough to assist with the remainder of this article.

Arrays

Arrays are fixed length. When declaring an array you state the size in-between the square brackets ([5]int{}). When declaring the size, it becomes part of its type (e.g. [8]int and [4]int are different, incompatible types).

package main

func main() {
	a := [5]int{1,2,3,4,5}

	// Some logic here

	a = [3]int{1,2,3} // Compilation Error: cannot use [3]int{...} (type [3]int) as type [5]int in assignment
}

Slices

Slices are not fixed length, do not require an initial size on initialisation (though, one can be provided) and can be extended.

package main

import (
	"fmt"
)

func main() {
	a := []int{1,2,3}

	fmt.Printf("len: (%d) cap: (%d) \n", len(a), cap(a)) // len: (3) cap: (3)

	a = append(a, []int{4,5,6}...)

	fmt.Printf("len: (%d) cap: (%d) \n", len(a), cap(a)) // len: (6) cap: (6)
}

Anatomy of a slice

First, let's take a look at the slice struct.

type slice struct {
	array unsafe.Pointer
	len   int
	cap   int
}

Behind every slice is an array.

  • The len field corresponds to the slice length (the number of elements the slice contains).
  • The cap field corresponds to the slice capacity; the number of elements in the underlying array.

These fields do not always match.

package main

import (
	"fmt"
	"reflect"
	"unsafe"
)

func main() {
	// Create slice with length of 0 and capacity of 5
	slc := make([]int, 0, 5)

	printSlc(slc) // len: (0), cap: (5), array pointer: (824634232608)
}

func printSlc(slc []int) {
	hdr := (*reflect.SliceHeader)(unsafe.Pointer(&slc))
	fmt.Printf("len: (%d), cap: (%d), array pointer: (%d) \n", len(slc), cap(slc), hdr.Data) // len: (0), cap: (5), array pointer: (0xc00000c030)
}

Taking inspiration from the diagrams seen in the slices intro article, our slc slice looks like this:

Slice representation

This is all important to understand because when the number of items in the slice (len) exceeds the capacity of the underlying array (cap) a new array needs to be created. The following example shows how the array pointer remains the same when the capacity is unchanged, but as soon as we append an additional item, exceeding the capacity, a new array is created with double the capacity and our array pointer changes to it.

package main

import (
	"fmt"
	"reflect"
	"unsafe"
)

func main() {
	// Create slice with length of 0 and capacity of 5
	slc := make([]int, 0, 5)

	printSlc(slc) // len: (0), cap: (5), array pointer: (824634232608)

	/*
		Append 5 new items to slc, which has an underlying array with a capacity of 5, so won't need
		to create a new array.
	*/
	slc = append(slc, []int{1, 2, 3, 4, 5}...)

	printSlc(slc) // len: (5), cap: (5), array pointer: (824634232608)

	/*
		Append a sixth item to slc, which will exceed the underlying arrays capacity and will result in a
		new array being created with a capcity double what it was.
	*/
	slc = append(slc, 6)

	printSlc(slc) // len: (6), cap: (10), array pointer: (824635408384)
}

func printSlc(slc []int) {
	hdr := (*reflect.SliceHeader)(unsafe.Pointer(&slc))
	fmt.Printf("len: (%d), cap: (%d), array pointer: (%d) \n", len(slc), cap(slc), hdr.Data) // len: (0), cap: (5), array pointer: (0xc00000c030)
}

This is what controls whether a function can alter the slice or not. When a function accepts a slice as a parameter, what is actually passed is the slice header (i.e. the slice struct we discussed earlier).

That means the function, via the slice header, has access to the underlying array.

If the function doesn't change the capacity of the underlying array (e.g. the earlier multiplyByTwo example), the changes will be persisted.

If it does change the capacity of the underlying array, and thus requires a new array to be created, those changes are not persisted to the original slice and would have to be returned for the caller to use the new slice.

References