Go Basics #5 Collections — array, slice, map

7 min read

In #4 Functions, Multiple Return, error Type you saw functions and errors. This time — the tools for grouping data: Go’s three collection types.

The three collections at a glance #

LengthMutableGeneral use
arrayfixedOKrarely used
slicegrowableOKoverwhelmingly common
mapgrowableOKvery common for key-value

Array — fixed length #

basic array
var nums [3]int            // [0 0 0]
nums[0] = 10
nums[1] = 20
fmt.Println(nums)           // [10 20 0]

primes := [5]int{2, 3, 5, 7, 11}
fmt.Println(len(primes))    // 5

Length is part of the type. [3]int and [5]int are different types.

different lengths are different types
var a [3]int
var b [5]int
// a = b   ✗ different types

Because of that constraint, arrays are awkward as a general data structure. Slices are used almost everywhere. You usually only meet arrays directly in cases like:

  • Fixed sizes such as hash results ([32]byte for SHA-256)
  • Very narrow performance optimizations

Slice — the real protagonist of variable length #

basic slice
nums := []int{1, 2, 3, 4, 5}
fmt.Println(len(nums))     // 5

nums = append(nums, 6)      // append to the end
fmt.Println(nums)           // [1 2 3 4 5 6]

[]int (no length inside the brackets) is a slice type. The length is decided at runtime and changes freely.

Creating — make #

creating with make
s1 := make([]int, 5)      // length 5, all zero — [0 0 0 0 0]
s2 := make([]int, 0, 10)  // length 0, capacity 10

The third argument is the capacity. It’s central to how slices work — more on that in a moment.

Indexing and slicing #

slicing
s := []int{10, 20, 30, 40, 50}

s[0]       // 10
s[1:3]     // [20 30] — [start, end)
s[:3]      // [10 20 30]
s[2:]      // [30 40 50]
s[:]       // entire slice

Similar to Python/JS. Start inclusive, end exclusive.

How slices work — length vs capacity #

This is the heart of understanding Go slices.

A slice is internally made of three parts.

  1. Pointer — points at the actual data (an array)
  2. Length (len) — current number of elements
  3. Capacity (cap) — size of the underlying space
len vs cap
s := make([]int, 3, 5)
fmt.Println(len(s), cap(s))   // 3 5

s = append(s, 100)
fmt.Println(len(s), cap(s))   // 4 5

s = append(s, 200)
fmt.Println(len(s), cap(s))   // 5 5

s = append(s, 300)
fmt.Println(len(s), cap(s))   // 6 10  ← cap grows automatically (usually doubles)

When capacity runs out — append allocates a new array and copies. It usually doubles. In hot append paths, pre-sizing the cap is efficient.

pre-sizing cap
result := make([]int, 0, 1000)   // no reallocation up to 1000
for i := 0; i < 1000; i++ {
	result = append(result, i*i)
}

Slice gotcha — sharing the same array #

A slice is pointer + length + capacity, so two slices can point at the same array.

sharing
a := []int{1, 2, 3, 4, 5}
b := a[1:4]              // [2 3 4]

b[0] = 999
fmt.Println(a)           // [1 999 3 4 5] ← a changed too
fmt.Println(b)           // [999 3 4]

b is a view onto part of a. Changes through one are reflected in the other.

This causes a lot of bugs. When you really need a copy, use copy or the append trick.

real copy
a := []int{1, 2, 3}

// copy
b := make([]int, len(a))
copy(b, a)

// append
c := append([]int{}, a...)

b[0] = 999
fmt.Println(a)   // [1 2 3] (unaffected)

Common slice patterns #

common patterns
s := []int{1, 2, 3, 4, 5}

// append at the end
s = append(s, 6)
s = append(s, 7, 8, 9)             // multiple

// concatenate another slice
s2 := []int{10, 20}
s = append(s, s2...)               // ... spread

// length
n := len(s)

// emptiness check
isEmpty := len(s) == 0

// remove element at index i (preserving order)
i := 2
s = append(s[:i], s[i+1:]...)

// remove ignoring order (swap with last, then truncate)
s[i] = s[len(s)-1]
s = s[:len(s)-1]

nil slice — almost identical to an empty slice #

nil slice
var s []int     // nil
fmt.Println(len(s))    // 0
fmt.Println(s == nil)  // true

s = append(s, 1)        // OK — append accepts a nil slice

Declaring without initializing leaves it nil. But — almost every operation including append and len is safe on a nil slice. You usually don’t have to think about it.

Map — key-value #

basic map
ages := map[string]int{
	"Curtis": 30,
	"Alice":  25,
}

ages["Bob"] = 35           // add
fmt.Println(ages["Curtis"])   // 30
delete(ages, "Alice")     // remove
fmt.Println(len(ages))     // 2

Type spelled as map[keyType]valueType.

Creating — make #

empty map
ages := make(map[string]int)
ages["Curtis"] = 30

// or a literal
empty := map[string]int{}

Key existence — comma-ok #

The pattern you’ll use most.

checking a key
ages := map[string]int{"Curtis": 30}

// plain access — zero value when missing
fmt.Println(ages["missing"])    // 0 (potentially confusing)

// comma-ok pattern — confirms presence
if age, ok := ages["Curtis"]; ok {
	fmt.Println("present:", age)
} else {
	fmt.Println("missing")
}

map[key] can return two values — the value and the presence flag. If the value itself is the zero value, plain access can’t distinguish. Comma-ok is the standard.

Iteration — no order #

iterating a map
for name, age := range ages {
	fmt.Println(name, age)
}

Map iteration in Go has a different order each time. It’s intentionally randomized (to keep code from depending on order). When you need a sorted order, gather keys into a slice and sort.

sorted iteration
import "sort"

keys := make([]string, 0, len(ages))
for k := range ages {
	keys = append(keys, k)
}
sort.Strings(keys)

for _, k := range keys {
	fmt.Println(k, ages[k])
}

nil map — beware #

A bare var m map[string]int is a nil map — reads are OK, writes panic.

nil map gotcha
var m map[string]int
fmt.Println(m["key"])     // 0 (read OK)
fmt.Println(len(m))      // 0 (OK)

m["key"] = 1               // ✗ panic: assignment to entry in nil map

Initialize with make or a literal before writing. Different from how nil slices behave.

Common map patterns #

map patterns
counts := make(map[string]int)

words := []string{"a", "b", "a", "c", "b", "a"}
for _, w := range words {
	counts[w]++              // missing keys start from zero (0) and +1
}
fmt.Println(counts)          // map[a:3 b:2 c:1]

Auto-zero on missing keys is natural for counting.

Building a set — map[T]struct #

Go has no set type — you build one with a map valued by an empty struct.

set via empty struct
seen := make(map[string]struct{})

seen["a"] = struct{}{}
seen["b"] = struct{}{}

if _, ok := seen["a"]; ok {
	fmt.Println("already in")
}

An empty struct takes 0 bytes of memory, which makes it the standard choice for set-like usage. Using bool works the same logically, but an empty struct is more accurate in terms of memory.

Passing collections to functions #

Slices have reference semantics #

slice parameter
func modify(s []int) {
	s[0] = 999          // visible to the caller
}

nums := []int{1, 2, 3}
modify(nums)
fmt.Println(nums)        // [999 2 3]

The slice header is copied by value, but the pointer inside still points at the same underlying array. Mutations to the data are visible to the caller.

That said, append results may be different — if append allocates a new array, the caller’s slice header doesn’t change.

append gotcha
func appendBad(s []int) {
	s = append(s, 100)    // may be a new array — invisible to the caller
}

nums := []int{1, 2, 3}
appendBad(nums)
fmt.Println(nums)         // [1 2 3] — nothing appended

// Recommended — return the result
func appendGood(s []int) []int {
	return append(s, 100)
}
nums = appendGood(nums)

Maps have reference semantics #

map parameter
func modify(m map[string]int) {
	m["new"] = 100         // visible to the caller
}

Maps are simpler than slices in their reference semantics. Changes inside the function are always visible.

Wrap-up #

What we covered:

  • array — fixed length, rarely used
  • slice — growable, the choice for almost every collection
  • A slice is pointer + length + capacity. When cap runs out a new array is allocated
  • Sharing-the-same-array pitfall — copy or append([]T{}, ...) for a real copy
  • nil slices are safe, nil maps panic on write
  • map — map[key]value, comma-ok for presence
  • Map iteration is in a different order every time
  • Build a set with map[T]struct{}
  • Slices/maps have reference semantics — function changes are visible to callers

In the next post (#6 Structs and Methods) we cover Go’s user-defined types — struct and the methods bound to them, including pointer receivers.

X