06 - Many Things at Once¶
What this session is¶
About an hour. You'll learn the two most-used "collection" types in Go: slices (an ordered list of things, like a shopping list) and maps (a lookup table, like a dictionary where you look up a word to find its meaning). Almost every real Go program uses both, constantly.
Why you need these¶
Until now, every variable has held one thing. One name. One age. One person. Real programs deal with many things. A list of users. A count of how many times each word appears in a document. The temperatures for each day of the week. You need types built for collections.
Go gives you three: arrays, slices, and maps. Slices and maps are what you'll actually use. Arrays exist, but you'll see them rarely - we'll mention them only enough to recognize them.
Slices: ordered lists¶
package main
import "fmt"
func main() {
names := []string{"Alice", "Bob", "Chioma"}
fmt.Println(names)
fmt.Println(names[0]) // Alice - slices are 0-indexed
fmt.Println(names[2]) // Chioma
fmt.Println(len(names)) // 3
}
Type and run.
What's new:
[]string{...}- a slice of strings. The[]says "a list of";stringsays "of strings".names[0]- read the first element. Indexing starts at 0, not 1. (names[2]is the third element.)len(names)- built-in function that gives the number of elements.
Common mistake: going off the end. names[3] would crash the program (Go calls this a "panic" - we'll meet it later). Always know how many elements you have before indexing.
Adding to a slice: append¶
Slices grow:
names := []string{"Alice", "Bob"}
names = append(names, "Chioma")
fmt.Println(names) // [Alice Bob Chioma]
append(slice, value) returns a new slice with the value added. You almost always reassign back to the same variable: names = append(names, "Chioma").
Two notes that will save you confusion later:
appendreturns a value; it doesn't change the slice in place. Forgetting thenames =part is a common bug - yourappenddoes nothing visible.- You can append several values:
append(names, "D", "E", "F").
Looping over a slice: range¶
You've seen for i := 0; i < len(names); i++ style loops on page 03. For slices, there's a friendlier form:
Output:
range names gives you two values per iteration: the index and the value at that index. If you don't need the index, use _:
The underscore _ means "I don't care about this." Go won't compile if you create a variable and don't use it (page 02) - _ is the official way to say "throw this away."
A slice of structs¶
Combine what you know:
type Person struct {
Name string
Age int
}
people := []Person{
{Name: "Alice", Age: 30},
{Name: "Bob", Age: 25},
{Name: "Chioma", Age: 35},
}
for _, p := range people {
fmt.Println(p.Name, "is", p.Age)
}
A slice can hold any type - structs you defined, other slices, anything.
Maps: looking things up by key¶
A map is a collection where each entry has a key (the thing you look up by) and a value (the thing you get back).
package main
import "fmt"
func main() {
ages := map[string]int{
"Alice": 30,
"Bob": 25,
"Chioma": 35,
}
fmt.Println(ages["Alice"]) // 30
fmt.Println(ages["Bob"]) // 25
fmt.Println(len(ages)) // 3
}
What's new:
map[string]int{...}- a map whose keys arestrings and whose values areints. Read it as "a map FROM string TO int."ages["Alice"]- look up the value for the key"Alice".
Add or update an entry:
Delete an entry:
The "comma ok" pattern: is the key there?¶
What if you look up a key that isn't in the map?
You get the zero value for the value type - 0 for int, "" for string, etc. (We met zero values in page 05.) That's sometimes fine, but often you need to know whether the key was there or not. Use this form:
score, ok := ages["Zara"]
if ok {
fmt.Println("found:", score)
} else {
fmt.Println("not in the map")
}
The second return value (we named it ok) is a bool: true if the key was present, false if not. This is called the "comma ok" pattern. You'll see it constantly in Go code.
Looping over a map¶
Important
the order is not guaranteed. If you run the program twice, the output order may differ. This is on purpose - Go randomizes map iteration order so you don't accidentally rely on an order that the language doesn't promise.
If you need ordered output, sort the keys first:
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])
}
That make([]string, 0, len(ages)) creates a slice of strings with length 0 and capacity for len(ages) elements - a small optimization that avoids the slice growing as we append. You'll see make more in real code; for the basics, []string{} works the same.
Arrays (briefly, so you recognize them)¶
You'll occasionally see code with a number inside the brackets:
That's an array - fixed size (7, here), declared at compile time. Once made, you can't grow it. In practice, you'll use slices instead. The only time arrays matter for beginner-level Go is when you're reading code that uses them - recognize the shape, then mentally translate to "this is just a fixed-size collection."
Going deeper¶
The basics above are enough to write real Go. But there are five things that surprise people about slices and maps, and you'll hit each one at some point. Reading this section ahead of time saves you a debugging session.
A slice is a tiny struct, not a list¶
When you write s := []int{1, 2, 3}, Go creates two things:
- An array somewhere in memory holding
1, 2, 3. - A slice header - a tiny struct with three fields: a pointer to that array, a length (
3), and a capacity (also3).
That slice header is what s actually is. When you pass s to a function, you copy the header - pointer, length, capacity - but not the underlying array. Two slice variables can point at the same array. That's usually fine, until it isn't (see the next section).
You can check capacity at any time:
s := []int{1, 2, 3}
fmt.Println(len(s), cap(s)) // 3 3
s = append(s, 4)
fmt.Println(len(s), cap(s)) // 4 6 (Go grew the backing array; doubled it)
append is the one place this gets visible. If there's room (cap > len), append writes into the existing array. If there isn't, append allocates a new bigger array, copies the old data over, and returns a slice header pointing at the new one. That's why you always write s = append(s, x) - the returned header may be different from the input header.
The aliasing gotcha¶
Because slices share the underlying array, this surprises people:
a := []int{1, 2, 3, 4, 5}
b := a[:3] // b is {1, 2, 3}, sharing a's array
b[0] = 99
fmt.Println(a) // [99 2 3 4 5] -- a changed too!
b is a new slice header, but its pointer is into a's array. Writes through b modify the same memory.
The same effect bites with append:
a := []int{1, 2, 3, 4, 5}
b := a[:3] // len=3, cap=5
b = append(b, 99) // b's cap was 5; append wrote into a's memory
fmt.Println(a) // [1 2 3 99 5] -- the "4" got overwritten
If you ever pass a sub-slice to a function that might append, copy it first:
The Go standard library does this all over the place. It's worth recognizing.
Map iteration order is randomized on purpose¶
The order changes every run. Go does this deliberately to stop you from accidentally relying on order. If you need deterministic order, sort the keys:
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
fmt.Println(k, m[k])
}
This is one of the top "passes on my laptop, fails in CI" bug sources for Go beginners.
Tell Go the size when you know it¶
If you know roughly how many items you'll have, say so:
// Bad: 10 allocations as the slice grows
s := []int{}
for i := 0; i < 1000; i++ {
s = append(s, i)
}
// Good: 1 allocation
s := make([]int, 0, 1000)
for i := 0; i < 1000; i++ {
s = append(s, i)
}
Same for maps:
In hot paths this is a real performance win. In cold paths it's a discipline that makes intent clear to readers.
nil slices behave (mostly) like empty slices¶
var s []int // nil slice
fmt.Println(len(s)) // 0
s = append(s, 1) // works! append handles nil
for v := range s { // works on empty too
fmt.Println(v)
}
A nil slice has no backing array. An empty slice ([]int{}) has a header but length zero. For most purposes they're interchangeable - len, range, and append all work the same.
The one place it matters is JSON: nil slices marshal to null, empty slices marshal to []. If your API consumer expects [], return []int{}, not nil.
Exercise¶
In a new file wordcount.go:
Write a program that counts how many times each word appears in a sentence.
-
Start with this sentence:
"the quick brown fox jumps over the lazy dog the end". Hardcode it as a string. -
Split it into words. Use:
strings.Fieldssplits a string on whitespace and returns a[]string. -
Build a
map[string]intwhere each key is a word and each value is how many times it appeared. -
Print each word and its count, one per line.
Expected output (order may differ):
Stretch: print the words sorted alphabetically. (Use the sorted-keys pattern above.)
What you might wonder¶
"Why is indexing zero-based?" Historical and mathematical reasons that took root in C and spread. Slightly painful at first, then automatic. You'll stop thinking about it in two weeks.
"What's the difference between a slice and an array, really?"
A slice is a view into a backing array, with its own length and capacity. The slice can grow (via append); the underlying array doesn't (it gets replaced with a bigger one when needed). For now: use slices, ignore arrays. The full picture lands when you read more Go code.
"Can a map's keys be any type?"
Almost. Keys must be types that can be compared with ==. Strings, numbers, booleans, structs of those - yes. Slices, maps, functions - no (they can't be compared). 95% of the time the answer is "string" or "int."
"What if I append to a slice that other code is also looking at?"
You'll meet a subtle bug eventually. Don't worry about it now. Rule of thumb: don't hold onto a slice across an append you didn't do yourself.
Done¶
You can now:
- Build ordered lists with slices: create, index, len, append, range.
- Use the i, v := range slice and _, v := range slice patterns.
- Build lookup tables with maps: create, read, write, delete.
- Check whether a key exists with the comma-ok pattern.
- Iterate a map, sort keys when order matters.
- Recognize an array when you see one (and convert it mentally to "fixed-size slice").
Most of the data shape work in Go is done with slices and maps. You now have the basic vocabulary that every Go program uses.
Next page: how Go handles things going wrong - the "errors as values" pattern.
Next: Errors → 07-errors.md