Go Basics #5 Collections — array, slice, map
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 #
| Length | Mutable | General use | |
|---|---|---|---|
| array | fixed | OK | rarely used |
| slice | growable | OK | overwhelmingly common |
| map | growable | OK | very common for key-value |
Array — fixed length #
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)) // 5Length is part of the type. [3]int and [5]int are different types.
var a [3]int
var b [5]int
// a = b ✗ different typesBecause 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]bytefor SHA-256) - Very narrow performance optimizations
Slice — the real protagonist of variable length #
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
#
s1 := make([]int, 5) // length 5, all zero — [0 0 0 0 0]
s2 := make([]int, 0, 10) // length 0, capacity 10The third argument is the capacity. It’s central to how slices work — more on that in a moment.
Indexing and 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 sliceSimilar 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.
- Pointer — points at the actual data (an array)
- Length (len) — current number of elements
- Capacity (cap) — size of the underlying space
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.
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.
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.
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 #
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
#
var s []int // nil
fmt.Println(len(s)) // 0
fmt.Println(s == nil) // true
s = append(s, 1) // OK — append accepts a nil sliceDeclaring 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 #
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)) // 2Type spelled as map[keyType]valueType.
Creating — make
#
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.
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 #
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.
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.
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 mapInitialize with make or a literal before writing. Different from how nil slices behave.
Common 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.
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 #
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.
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 #
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 —
copyorappend([]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.