Slices, Maps and the Aliasing That Surprises You

Article · 13:00

Go has arrays, slices and maps. You will use slices and maps - the array exists mainly so that slices have something to point at, and understanding that explains every slice surprise, including what append really does and when two slices share memory.

Slices, Maps and the Aliasing That Surprises You

Go has arrays, slices and maps. You will use slices and maps; the array exists mainly so that slices have something to point at, and understanding that is what explains every slice surprise.

package main

import "fmt"

func main() {
	// An array has a FIXED length, and the length is part of its type.
	var array [3]int
	array[0] = 1
	fmt.Printf("%T %v\n", array, array)

	// A slice is a view onto an array: a pointer, a length and a capacity.
	slice := []int{1, 2, 3}
	fmt.Printf("%T len=%d cap=%d %v\n", slice, len(slice), cap(slice), slice)

	// Assigning an array COPIES it. Assigning a slice shares the backing array.
	other := array
	other[0] = 99
	fmt.Println("array:", array, "other:", other)

	alias := slice
	alias[0] = 99
	fmt.Println("slice:", slice, "alias:", alias)
}

append, and the copy you did not ask for

append returns a slice. If the backing array has spare capacity it writes in place; if not, it allocates a bigger array and copies. Both behaviours are correct, and the difference is visible when two slices share an array.

package main

import "fmt"

func main() {
	base := make([]int, 3, 5)          // len 3, cap 5
	fmt.Printf("len=%d cap=%d %v\n", len(base), cap(base), base)

	// Room to spare: append writes into the SAME array.
	grown := append(base, 4)
	grown[0] = 99
	fmt.Println("base:", base, "grown:", grown, "- they share an array")

	// No room: append allocates a new one, and they stop sharing.
	full := []int{1, 2, 3}
	fmt.Printf("before: len=%d cap=%d\n", len(full), cap(full))
	extended := append(full, 4)
	extended[0] = 99
	fmt.Println("full:", full, "extended:", extended, "- separate arrays now")

	// Which is why you always assign the result back:
	full = append(full, 5)
	fmt.Println(full)

	// A slice of a slice shares memory too, and holds the WHOLE array alive.
	big := make([]byte, 1_000_000)
	small := big[:10]
	fmt.Println(len(small), cap(small), "- the megabyte cannot be collected")

	independent := make([]byte, 10)
	copy(independent, big[:10])         // copy is how you break the link
	fmt.Println(len(independent), cap(independent))
}

Slicing, and the three-index form

package main

import "fmt"

func main() {
	letters := []string{"a", "b", "c", "d", "e"}

	fmt.Println(letters[1:3])      // b c - the end is exclusive
	fmt.Println(letters[:2])
	fmt.Println(letters[3:])
	fmt.Println(letters[:])

	// The capacity of a slice runs to the end of the backing array, so an
	// append can overwrite what comes after.
	head := letters[:2]
	fmt.Printf("len=%d cap=%d\n", len(head), cap(head))
	head = append(head, "OVERWRITTEN")
	fmt.Println(letters)

	// The three-index form caps it, so append must allocate instead.
	fresh := []string{"a", "b", "c", "d", "e"}
	safe := fresh[:2:2]
	safe = append(safe, "appended")
	fmt.Println(fresh, safe)
}

Maps

package main

import (
	"fmt"
	"sort"
)

func main() {
	stock := map[string]int{"apples": 3, "pears": 0}

	// Reading a missing key gives the zero value, not an error.
	fmt.Println(stock["apples"], stock["plums"])

	// The comma-ok idiom distinguishes "absent" from "present and zero".
	if count, ok := stock["pears"]; ok {
		fmt.Println("pears present, count", count)
	}
	if _, ok := stock["plums"]; !ok {
		fmt.Println("plums absent")
	}

	stock["plums"] = 7
	delete(stock, "pears")
	fmt.Println(len(stock))

	// Iteration order is RANDOMISED, deliberately - so nobody can depend on it.
	keys := make([]string, 0, len(stock))
	for key := range stock {
		keys = append(keys, key)
	}
	sort.Strings(keys)
	for _, key := range keys {
		fmt.Printf("  %s=%d\n", key, stock[key])
	}

	// A map value is not addressable: you cannot modify a struct field in place.
	type counter struct{ n int }
	counters := map[string]counter{"a": {1}}
	// counters["a"].n++            // will not compile
	c := counters["a"]
	c.n++
	counters["a"] = c
	fmt.Println(counters)

	// A map of pointers avoids that, at the cost of nil handling.
	pointers := map[string]*counter{"a": {1}}
	pointers["a"].n++
	fmt.Println(pointers["a"].n)
}

Passing slices and maps to functions

A slice header is copied, but the array it points at is not - so a function can change the elements but cannot change the caller's length. A map is a reference, so a function can add to it.

package main

import "fmt"

func main() {
	numbers := []int{1, 2, 3}
	mutate(numbers)
	fmt.Println("after mutate:", numbers)      // changed

	appendTo(numbers)
	fmt.Println("after appendTo:", numbers)    // NOT changed

	numbers = appendAndReturn(numbers)
	fmt.Println("after returning:", numbers)   // changed, because it was returned

	stock := map[string]int{}
	addTo(stock)
	fmt.Println("map:", stock)                 // changed: maps are references
}

func mutate(values []int)  { values[0] = 99 }
func appendTo(values []int) { values = append(values, 4); _ = values }
func appendAndReturn(values []int) []int { return append(values, 4) }
func addTo(m map[string]int) { m["added"] = 1 }

What to take away

  • An array is a value and copies; a slice is a view and shares.
  • append may write in place or reallocate - always assign the result back.
  • A small slice of a big array keeps the whole array alive; copy breaks the link.
  • s[:2:2] caps the capacity so an append cannot overwrite what follows.
  • Reading a missing map key gives the zero value; v, ok := m[k] tells you which.
  • Map iteration order is deliberately random - sort the keys when order matters.