Gofinity
GitHub
lesson 08 / 22~13m read

Maps

Go's hash table: the comma-ok lookup, the nil map that reads but cannot be written, random range order, and grouping with map[K][]V.

Maps

A map is Go's hash table, and it is the second of the two collection types you will write every day. A slice indexes by position; a map indexes by anything comparable.

scores := map[string]int{"go": 3, "rust": 2}
scores["zig"] = 1
delete(scores, "rust")
fmt.Println(len(scores))   // 2

The type is map[K]V. K must be comparable - it has to support == - so strings, numbers, booleans, pointers, channels, interfaces and structs or arrays built only from those are all legal keys. Slices, maps and functions are not, so map[[]string]int does not compile. V has any type at all, including another map or a slice.

Making one

The zero value of a map is nil, and a nil map is not an empty map you can grow. It is readable and nothing else:

var m map[string]int
fmt.Println(m["a"])   // 0
fmt.Println(len(m))   // 0
for k := range m {}   // zero iterations
delete(m, "a")        // no-op
m["a"] = 1            // panic: assignment to entry in nil map

Every operation is defined and harmless except the one that writes. That asymmetry is deliberate: it means a struct with a nil map field can be read without a nil check, and it means the panic arrives the first time someone actually tries to store something.

To get a usable map, use a literal or make:

a := map[string]int{}                 // empty literal
b := make(map[string]int)             // the same thing
c := make(map[string]int, 100)        // room for ~100, as a hint

The size argument to make is a capacity hint only. There is no cap for maps, and the hint changes nothing you can observe except how often the map has to grow while you fill it.

Reading

A read never fails. A key that is not there gives the zero value of the value type:

counts := map[string]int{}
counts["nothing"]      // 0, and the map is still empty

Nothing was inserted by that read. This is why counting is so short in Go:

counts[word]++         // a new word starts at zero

When you need to tell "absent" from "present and zero" - and with a value type like int or bool or string you often do - use the two-result form:

v, ok := counts["nothing"]   // 0, false

ok is the only thing that distinguishes them. That comma-ok shape is the same one you have already seen from a type assertion and a channel receive; Go reuses it wherever a value might not be there.

Combined with an if statement's initialiser it reads as one thought:

if v, ok := counts[word]; ok {
	fmt.Println(word, "appeared", v, "times")
}

Writing and deleting

m[k] = v inserts or overwrites - there is no separate insert. delete(m, k) removes the entry and does nothing at all if the key was absent, so it returns nothing; if you want to know whether the key was there, look before you delete.

You cannot take the address of a map element. &m[k] does not compile, and neither does assigning to a field of a struct value stored in a map:

type point struct{ x, y int }
m := map[string]point{"a": {1, 2}}
m["a"].x = 3        // does not compile

The reason is that the map may move its entries when it grows, so a pointer into one could not stay valid. The two ways round it are to read, change and write back, or to store map[string]*point and mutate through the pointer.

Maps are references

A map value is a pointer to the hash table. Assigning one, or passing it to a function, copies that reference and not the contents:

func add(m map[string]int, k string) { m[k]++ }

counts := map[string]int{}
add(counts, "go")
fmt.Println(counts["go"])   // 1

No pointer parameter is needed, and no return value. The flip side is that "copying" a map does not copy anything: two variables naming the same map see each other's writes. maps.Clone gives you a shallow copy, and if the values are slices or maps themselves, shallow is not enough - each one has to be copied too, or the copy and the original still share their contents.

Because a map is a reference and not a value, maps are not comparable. == is only defined against nil; use maps.Equal to compare contents.

Range order is random

This is the rule that catches everyone:

for k, v := range m {
	fmt.Println(k, v)      // a different order every run
}

Go randomises the iteration order on purpose, so that code cannot come to depend on an order the implementation never promised. Any output that has to be stable gets sorted on the way out:

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])
}

A map holds the data; a sorted slice is how you present it. Adding a key during a range is allowed but the new key may or may not be visited; deleting one you have not reached yet means it will not be.

Sets, and maps of slices

Go has no set type. A map with an empty value stands in for one, and the two usual spellings are map[string]bool and map[string]struct{} - the second stores nothing at all per entry, at the cost of a slightly noisier literal:

seen := map[string]struct{}{}
seen["go"] = struct{}{}
if _, ok := seen["go"]; ok { /* ... */ }

For grouping, map[K][]V is the shape, and three rules line up to make it a one-liner:

groups[key] = append(groups[key], value)

The missing key reads as a nil slice, append on a nil slice allocates, and the assignment stores the result. No "does this key exist" branch is needed.

The assignment is the part to be careful about. The map holds a copy of the slice header, and append may return a different one, so append(groups[key], value) on its own compiles, does work, and throws the answer away.

Further reading

  • Go maps in action - the blog post on comma-ok, deleting while ranging, and why iteration order is random.
  • Map types - the spec on key comparability, the nil map, and what len and delete do.
  • Effective Go: maps - the idioms, including sets and the missing-key zero value.
  • maps - the standard library helpers for cloning a map and iterating its keys and values.

Practise

Three challenges. The first is the mechanics: comma-ok against a stored zero, a fallback lookup, delete reporting what it found, sorted keys, and building a map from parallel slices. The second is the classic word count - tokenise, normalise, tally, then sort by count and break ties by word, because a top-three straight out of a range loop is a different top-three each run. The third is map[string][]string: appending into a missing entry, a nil map that must not panic, and a deep copy and a merge whose tests fail if the result borrows a backing array from its input.

practise

Practise what you learned

Work through these in order. Each one asks a little more than the last.

Start Map Basics
  1. Map BasicsEasy~20m+50 XP
  2. Word CountMedium~25m+100 XP
  3. Map of SlicesMedium~30m+100 XP