Learn Go · Part 4 of 11

Arrays, Slices, Maps and Loops

Arrays, slices that can grow, maps, and all the ways to write a for loop in Go.

Video 19:30–26:36 · 6 topics · 14 exercises

All parts · this is part 4
  1. 1.Introduction to Go
  2. 2.Constants, Variables and Basic Data Types
  3. 3.Functions and Control Structures
  4. 4.Arrays, Slices, Maps and Loops
  5. 5.Strings, Runes and Bytes
  6. 6.Structs and Interfaces
  7. 7.Pointers
  8. 8.Goroutines
  9. 9.Channels
  10. 10.Generics
  11. 11.Building an API

Extras

This part starts at 19:30 in the video.

Arrays

An array in Go is:

  • A fixed length. Once you make it, it can't grow or shrink.
  • All the same type.
  • Indexable, starting at 0.
  • Stored in one continuous chunk of memory.

Let's go through those with some code:

main.gogo
package main

import "fmt"

func main() {
	var intArr [3]int32 // three int32s, all starting at 0
	fmt.Println(intArr) // [0 0 0]

	intArr[1] = 123
	fmt.Println(intArr[0])   // 0
	fmt.Println(intArr[1:3]) // [123 0]

	// Each element sits right after the last one, 4 bytes apart.
	fmt.Println(&intArr[0])
	fmt.Println(&intArr[1])
	fmt.Println(&intArr[2])

	// Other ways to make one:
	var a [3]int32 = [3]int32{1, 2, 3}
	b := [3]int32{1, 2, 3}
	c := [...]int32{1, 2, 3} // the compiler counts them for you
	fmt.Println(a, b, c)
}

The [3] is the length, and int32 is the type of everything in it. Since we didn't set any values, each element gets the zero value for int32, which is 0.

Now about that memory part. An int32 is 4 bytes, so an array of three of them gets 12 bytes in a row. If you print the addresses with & (more on that in part 7), you'll see they go up by 4 each time. That's nice for the computer: it only has to remember where the first one is, and it can find the rest by counting.

One thing that surprises people: arrays are values. Assign one to another variable or pass it into a function and the whole thing gets copied.

main.gogo
package main

import "fmt"

func main() {
	original := [3]int{1, 2, 3}
	copied := original // the whole array gets copied
	copied[0] = 100

	fmt.Println(original)                    // [1 2 3]
	fmt.Println(copied)                      // [100 2 3]
	fmt.Println(original == [3]int{1, 2, 3}) // true: arrays can be compared with ==
}

Practice

  • PracticeIs it a copy?

    A function sets the first element of a [3]int to 99, and main prints the array after calling it. Guess what it prints, then run it. Did it change? Why?

    Practice with AI
  • Warm-upAdd them up

    Read five ints into an array, then loop through it and print the total.

    Practice with AI

Slices

According to the Go docs, slices are just wrappers around arrays. So under the hood there's still an array, but the slice gives you some extra stuff on top, the big one being that it can grow. To make one, just leave the length out of the brackets:

main.gogo
package main

import "fmt"

func main() {
	intSlice := []int32{4, 5, 6}                                         // no length in the brackets: it's a slice
	fmt.Printf("length %v, capacity %v\n", len(intSlice), cap(intSlice)) // 3, 3

	intSlice = append(intSlice, 7)
	fmt.Printf("length %v, capacity %v\n", len(intSlice), cap(intSlice)) // 4, 6

	// fmt.Println(intSlice[4]) // panic: index out of range, even though cap is 6

	more := []int32{8, 9}
	intSlice = append(intSlice, more...) // ... spreads a slice into append
	fmt.Println(intSlice)                // [4 5 6 7 8 9]

	withCap := make([]int32, 3, 8)                   // length 3, room for 8
	fmt.Println(withCap, len(withCap), cap(withCap)) // [0 0 0] 3 8
}

So what's going on with append? The slice starts with an array that can hold exactly 3 values. When we append a 4th, Go checks if there's room. There isn't, so it makes a brand new, bigger array, copies the values over, and adds the new one. That's why you always write s = append(s, x): you might be getting back a slice pointing at a totally different array.

That's also where length and capacity come from. Length is how many values the slice has. Capacity is how many fit in the array underneath before it needs a new one. After that append we've got length 4, capacity 6. Those extra 2 spots exist, but you can't touch them, so intSlice[4] is still out of range.

You can also make a slice with make, giving it a length and, if you want, a capacity. If you have a rough idea of how big it's going to get, setting the capacity up front saves Go from making new arrays over and over. We'll time that at the end of this part.

Practice

  • PracticeEvens only

    Write evens(nums []int) []int that returns a new slice with just the even numbers.

    Practice with AI
  • Warm-upWatch it grow

    Start with an empty slice and append 20 numbers one at a time, printing len and cap after each. When does the capacity jump?

    Practice with AI
  • StretchReverse it

    Write reverse(s []int) that flips a slice in place, without making a new one.

    Practice with AI

Slices share memory

You can take a piece of a slice with s[start:end]. The start is included, the end isn't. But here's the catch: the new slice doesn't get its own copy. It points at the same array as the original, so changing one changes the other.

main.gogo
package main

import "fmt"

func main() {
	nums := []int{1, 2, 3, 4, 5}
	middle := nums[1:4] // elements 1, 2 and 3: the end isn't included
	middle[0] = 200

	fmt.Println(middle) // [200 3 4]
	fmt.Println(nums)   // [1 200 3 4 5]: same array underneath

	own := make([]int, len(middle))
	copy(own, middle) // a real copy, with its own array
	own[0] = 2
	fmt.Println(own, middle) // [2 3 4] [200 3 4]
}

If you need a slice that's really yours, make one and use copy (or slices.Clone, which does both in one go).

And here's a weird one. If there's spare capacity, append uses it instead of making a new array. So two appends to the same slice can step on each other:

main.gogo
package main

import "fmt"

func main() {
	base := make([]int, 3, 10) // length 3, but room for 10
	a := append(base, 1)       // fits, so it uses base's array
	b := append(base, 2)       // also fits... in the same spot

	fmt.Println(a[3], b[3]) // 2 2: b overwrote what a put there
}

Practice

  • Warm-upGuess the output

    Make nums := []int{1, 2, 3, 4}, take part := nums[:2], set part[1] = 50 and print both. Guess first, then run it.

    Practice with AI
  • PracticeA safe firstN

    Write firstN(s []int, n int) []int that gives back the first n elements (or all of them, if there are fewer) in a way the caller can change without touching the original.

    Practice with AI

Maps

A map is a bunch of key value pairs where you look up a value by its key. You can make one with make, or fill it in right away:

main.gogo
package main

import "fmt"

func main() {
	var empty map[string]uint8 = make(map[string]uint8)
	fmt.Println(empty) // map[]

	ages := map[string]uint8{"Adam": 23, "Sarah": 45}
	fmt.Println(ages["Adam"])  // 23
	fmt.Println(ages["Jason"]) // 0: not there, so you get the zero value

	age, ok := ages["Jason"]
	if ok {
		fmt.Printf("The age is %v\n", age)
	} else {
		fmt.Println("Invalid name")
	}

	ages["Jason"] = 31
	delete(ages, "Adam")
	fmt.Println(len(ages)) // 2
}

map[string]uint8 means the keys are strings and the values are uint8s. Now, you have to be careful here. If you look up a key that isn't in the map, Go doesn't give you an error. It gives you the zero value for the type. So ages["Jason"] is 0, and you can't tell if Jason is a newborn or just isn't in there.

Luckily, maps give back an optional second value, a bool that's true if the key was actually there. People usually call it ok. To remove something, use delete. It changes the map in place, so it doesn't return anything.

One more thing to watch out for. If you declare a map but never make it, it's nil, and writing to it crashes:

main.gogo
package main

import "fmt"

func main() {
	var scores map[string]int // declared, but never made: it's nil
	fmt.Println(scores["x"])  // 0: reading a nil map is fine
	// scores["x"] = 1        // panic: assignment to entry in nil map

	scores = map[string]int{} // now it's a real (empty) map
	scores["x"] = 1
	fmt.Println(scores)
}

Practice

  • Warm-upCount the words

    Write wordCount(words []string) map[string]int that counts how many times each word shows up.

    Practice with AI
  • PracticeIs it there or is it zero?

    With a map of item names to stock counts where one item has 0 in stock, write lookup(stock map[string]int, item string) string that tells apart "out of stock" and "we don't sell that".

    Practice with AI
  • StretchGroup by length

    Write groupByLength(words []string) map[int][]string that groups words by how many letters they have.

    Practice with AI

Loops

Go only has one loop keyword, for, but you can write it a bunch of different ways. To go through a map, array or slice, use range:

main.gogo
package main

import "fmt"

func main() {
	ages := map[string]uint8{"Adam": 23, "Sarah": 45}
	for name, age := range ages {
		fmt.Printf("Name: %v, Age: %v\n", name, age) // order changes run to run
	}

	intArr := []int32{1, 2, 3}
	for i, v := range intArr {
		fmt.Printf("Index: %v, Value: %v\n", i, v)
	}
	for _, v := range intArr { // don't need the index? use _
		fmt.Println(v)
	}

	i := 0
	for i < 3 { // Go's "while" loop
		fmt.Println(i)
		i++
	}

	for { // loops forever until something breaks out
		if i >= 5 {
			break
		}
		i++
	}

	for j := 0; j < 10; j++ { // start; keep going while; after each loop
		if j%2 == 0 {
			continue // skip the rest of this loop and go to the next one
		}
		fmt.Println(j)
	}

	for k := range 3 { // Go 1.22+: 0, 1, 2
		fmt.Println(k)
	}
}

So the different flavors are:

You writeIt does
for i, v := range sGoes through a slice, array or map (or string, part 5, or channel, part 9)
for i < 10Keeps going while the condition is true. This is Go's while loop
for { }Goes forever, until you break out
for i := 0; i < 10; i++The classic: start, keep going while, then what to do after each loop
for i := range 10Counts 0 to 9 (Go 1.22 and up)

break gets you out of the loop completely, and continue skips to the next time around. And i++ is just short for i = i + 1.

Notice the map loop: run it a few times and the order changes. Go doesn't keep maps in any order, and it actually shuffles them on purpose so you don't start relying on one. If you need them in order, sort the keys:

main.gogo
package main

import (
	"fmt"
	"maps"
	"slices"
)

func main() {
	ages := map[string]int{"Sarah": 45, "Adam": 23, "Jason": 31}
	for _, name := range slices.Sorted(maps.Keys(ages)) {
		fmt.Println(name, ages[name]) // Adam, Jason, Sarah, every time
	}
}

Practice

  • Warm-upCount to 5, four ways

    Read a number n, then print 1 to n using a three-part for loop, a while-style loop, an infinite loop with break, and range.

    Practice with AI
  • PracticeFind the first one

    Write indexOf(s []string, target string) int that returns the index of target, or -1. Stop looping as soon as you find it.

    Practice with AI
  • StretchPrint a map in order

    Write printByScore(scores map[string]int) that prints the names and scores sorted by score, highest first, with ties in alphabetical order. Hint: sort.Slice or slices.SortFunc will help.

    Practice with AI

Preallocating capacity

As promised, let's see how much setting the capacity ahead of time actually helps. We'll append a million values to two slices: one that starts empty with no capacity, and one that starts empty but with room for a million.

main.gogo
package main

import (
	"fmt"
	"time"
)

func timeLoop(slice []int, n int) time.Duration {
	start := time.Now()
	for len(slice) < n {
		slice = append(slice, 1)
	}
	return time.Since(start)
}

func main() {
	n := 1_000_000
	empty := []int{}
	preallocated := make([]int, 0, n)

	fmt.Println("no capacity:  ", timeLoop(empty, n))
	fmt.Println("preallocated: ", timeLoop(preallocated, n))
}

In the video the one with no capacity took about three times as long, and depending on your machine it could be even worse. Every time it runs out of room, Go has to make a bigger array and copy everything over, and that adds up.

Practice

  • PracticeMake it once

    Fill in squares(n int) []int, which gives back the squares of 1 to n, so it makes the slice once with make and the right capacity, and never has to grow. The tests check that the result's capacity is exactly n.

    Practice with AI