7. Slices, Maps, and Strings
Three types carry nearly all the data in a Go program. None of them is complicated, and each has one behavior that will surprise you once.
7.1 Arrays
An array’s length is part of its type. [3]int and [4]int are different
types, a function taking one will not accept the other, and the length must be a
constant. Arrays are values, so assigning one copies every element, as section
5.1 showed.
var a [3]int // three zeros
b := [3]string{"x", "y"} // third is ""
c := [...]int{1, 2, 3, 4} // length 4, counted by the compiler
You will rarely declare one. Arrays exist so that slices can be built on them,
and they appear directly in the places where a fixed size is the point, like a
[32]byte hash.
7.2 Slices
A slice is a view into an array: a pointer to an element, a length, and a
capacity. []int is a slice of ints, with no length in the type.
// Length, capacity, and what append does when it runs out.
package main
import "fmt"
func main() {
var s []int // nil, but usable
fmt.Println(s == nil, len(s), cap(s))
for i := range 5 {
s = append(s, i)
fmt.Printf("len=%d cap=%d %v\n", len(s), cap(s), s)
}
t := make([]int, 3, 10) // length 3, capacity 10
fmt.Println(len(t), cap(t), t)
u := s[1:3] // elements 1 and 2
fmt.Println(u, len(u), cap(u))
v := make([]int, len(s))
copy(v, s) // an independent copy
v[0] = 99
fmt.Println(s, v)
}
true 0 0
len=1 cap=1 [0]
len=2 cap=2 [0 1]
len=3 cap=4 [0 1 2]
len=4 cap=4 [0 1 2 3]
len=5 cap=8 [0 1 2 3 4]
3 10 [0 0 0]
[1 2] 2 7
[0 1 2 3 4] [99 1 2 3 4]
The zero value of a slice is nil, and a nil slice is a working empty slice:
len is 0, range runs zero times, and append works. You almost never need
to initialize a slice before appending to it, and you should compare against
len(s) == 0 rather than s == nil, because an empty non-nil slice is a
different value that behaves identically.
append returns a new slice and you must use the result. When the existing
capacity has room, it writes into the array already there and hands back a
longer view. When it does not, it allocates a bigger array, copies, and hands
back a slice of that. The growth above went 1, 2, 4, 4, 8; the exact schedule is
not specified and has changed between releases, so rely on it being amortized
constant time and not on any particular number.
make([]T, len, cap) allocates a slice of a given length with room to grow.
When you know roughly how many elements are coming, make([]int, 0, n) and then
appending avoids every intermediate copy, and it is the one slice optimization
worth doing by reflex.
s[i:j] takes elements i up to but not including j, either index
omissible. The result shares storage with s, so writing through one is
visible through the other, and its capacity runs to the end of the underlying
array, so s[1:3] above reports a capacity of 7. This is also a way to leak: a
small slice of a large array keeps the whole array alive. copy(dst, src)
copies the overlap of the two and returns how many elements moved, and it is
how you get an independent copy.
Now the surprise.
// append may or may not share storage with the slice it was given.
package main
import "fmt"
func main() {
base := make([]int, 3, 8) // room to spare
a := append(base, 10)
b := append(base, 20) // reuses the same array as a
fmt.Println(a, b) // both end in 20
full := make([]int, 3) // no room to spare
c := append(full, 10)
d := append(full, 20) // has to allocate, so c is untouched
fmt.Println(c, d)
}
[0 0 0 20] [0 0 0 20]
[0 0 0 10] [0 0 0 20]
a and b were appended from the same base slice. Because base had spare
capacity, both appends wrote to the same array position, and b’s write
clobbered a’s. In the second pair there was no spare capacity, so each append
allocated and the results are independent. The behavior of append therefore
depends on a capacity you may not have thought about, and the same code is
correct or wrong depending on how the slice was made.
The rule that keeps you out of it: treat the slice you passed to append as
invalid afterwards. Write s = append(s, x) and never keep the old value
around. When you genuinely need to append to a shared base twice, cut the
capacity with the three-index form, base[:3:3], which forces the allocation.
The slices package has the operations you would otherwise write by hand:
slices.Sort, slices.Contains, slices.Index, slices.Equal,
slices.Reverse, slices.Max, slices.SortFunc. Use them.
7.3 Maps
A map is a hash table from a comparable key type to any value type.
// Maps: creation, the comma-ok form, deletion, and ordered iteration.
package main
import (
"fmt"
"maps"
"slices"
"strings"
)
func main() {
counts := map[string]int{}
for _, w := range strings.Fields("the quick the lazy the end") {
counts[w]++ // missing keys read as the zero value
}
n, ok := counts["the"]
fmt.Println(n, ok)
m, ok := counts["missing"]
fmt.Println(m, ok)
delete(counts, "lazy")
fmt.Println(len(counts))
// Iteration order is randomized, so sort the keys when order matters.
for _, k := range slices.Sorted(maps.Keys(counts)) {
fmt.Printf("%s=%d ", k, counts[k])
}
fmt.Println()
var nilMap map[string]int
fmt.Println(nilMap["anything"], len(nilMap)) // reads are fine
}
3 true
0 false
3
end=1 quick=1 the=3
0 0
counts[w]++ works on a key that is not there, because reading a missing key
gives the zero value rather than an error. That makes counters and accumulators
short, and it means an index expression cannot tell you whether the key exists.
The comma-ok form does: v, ok := m[k] sets ok to whether the key was
present. Use it whenever zero is a legitimate stored value.
delete(m, k) removes a key and does nothing if it was not there. len(m)
counts the entries. clear(m) empties it.
Iteration order is randomized, deliberately and on every run, so that nobody
writes code depending on an order the implementation does not promise. When you
need a stable order, sort the keys, which slices.Sorted(maps.Keys(m)) does in
one line.
The nil map is the one type whose zero value is not fully usable. Reading from it and taking its length work, and writing to it panics:
// Reading a nil map is fine. Writing to one is not.
package main
import "fmt"
func main() {
var m map[string]int
fmt.Println(m["absent"], len(m)) // 0 0
m["absent"] = 1 // panics
}
0 0
panic: assignment to entry in nil map
So a map field in a struct needs a make(map[K]V) before first write, while a
slice field does not. That asymmetry catches everyone once.
Map elements are not addressable — you cannot write &m[k] or m[k].field = v
— because a rehash can move them. When the value is a struct you want to modify,
either store a pointer, map[string]*Thing, or read the struct out, change it,
and put it back.
7.4 Strings
A string is an immutable sequence of bytes. It is conventionally UTF-8 but the language does not enforce that; a string can hold any bytes at all.
// A string is bytes; ranging over it yields runes.
package main
import (
"fmt"
"unicode/utf8"
)
func main() {
s := "héllo"
fmt.Println(len(s), utf8.RuneCountInString(s))
fmt.Println(s[1]) // a byte, not a character
for i, r := range s {
fmt.Printf("%d:%q ", i, r)
}
fmt.Println()
r := []rune(s)
fmt.Println(len(r), string(r[1]), string(r[4:]))
fmt.Println([]byte(s)[:3])
}
6 5
195
0:'h' 1:'é' 3:'l' 4:'l' 5:'o'
5 é o
[104 195 169]
len(s) is a count of bytes, and s[i] is the i-th byte, of type
byte. "héllo" is six bytes and five characters because é takes two, so
len says 6 and s[1] is 195, the first of those two bytes rather than a
character.
Ranging over a string decodes UTF-8 as it goes and gives you the byte index and
the rune at it. That is why the indices above are 0, 1, 3, 4, 5 and skip 2. A
malformed byte yields utf8.RuneError and advances by one, so the loop always
terminates.
Three conversions, all of which allocate and copy. []byte(s) gives the bytes.
[]rune(s) decodes into a slice of code points, for when you need to index or
slice by character. string(bs) and string(rs) go back.
utf8.RuneCountInString(s) counts characters without allocating.
Strings are immutable, so s[0] = 'H' does not compile, and s += t in a loop
allocates a new string every time. For anything more than a few concatenations
use strings.Builder:
// Building a string without quadratic copying.
package main
import (
"fmt"
"strings"
)
func main() {
var b strings.Builder
for i := range 5 {
fmt.Fprintf(&b, "%d,", i)
}
fmt.Println(strings.TrimSuffix(b.String(), ","))
fmt.Println(strings.Join([]string{"a", "b", "c"}, "-"))
fmt.Println(strings.Repeat("ab", 3))
fmt.Println(strings.ToUpper("go"), strings.Contains("seafood", "foo"))
fmt.Println(strings.Split("a,b,,c", ","))
fmt.Printf("%q\n", strings.Fields(" spaced out "))
}
0,1,2,3,4
a-b-c
ababab
GO true
[a b c]
["spaced" "out"]
A strings.Builder is an io.Writer, so fmt.Fprintf writes to it, the
neatest way to assemble formatted output. Its zero value is ready to use.
The strings package is worth ten minutes with go doc strings. Note the two
splitters that differ: strings.Split("a,b,,c", ",") keeps the empty field and
returns four elements, while strings.Fields splits on runs of whitespace and
returns none. strings.Cut is the modern way to split on the first separator.
7.5 Iterators
Since Go 1.23, range accepts a function of the right shape. That is how the
standard library hands back sequences without building a slice. You saw one
above: maps.Keys(m) yields keys, and slices.Sorted collects a sequence into
a sorted slice. slices.Values, slices.All, and maps.All are the others
you will meet first.
Writing your own means returning a func(yield func(T) bool), and you can go a
long way without ever doing it. Consume them now; write them when a type of
yours has a natural sequence and building a slice for it would be wasteful.
Exercise 7-1. Write func unique(xs []int) []int preserving first-seen
order. Do it with a map for membership and a slice for order.
Exercise 7-2. Write a word-frequency program: read standard input, count
words case-insensitively, and print the ten most common with their counts.
slices.SortFunc will do the ranking.
Exercise 7-3. Write func reverse(s string) string that works on "héllo".
Then check what it does to a string containing an emoji, and find out what a
combining character is.
Exercise 7-4. Predict the output of append on a slice made with
make([]int, 0, 4) versus one made with []int{}, then run it.