28. Many structs together

Chapters 26 and 27 built one value at a time. Structs are at their most useful with many values of one kind treated as a group: a party of characters, a swarm of enemies. This chapter combines two things you know, a struct with methods and a slice, into the everyday pattern of a slice of structs.

A type to make many of

A small critter type, with two methods. It follows the rules from Chapter 27:

type critter struct {
    name   string
    energy int
}

func (c *critter) play() {
    c.energy = c.energy - 1
    fmt.Printf("%s plays. Energy: %d\n", c.name, c.energy)
}

func (c *critter) isTired() bool {
    return c.energy <= 0
}

Put the structs in a slice

A slice can hold any one type, and that includes your own. Here is a []critter:

critters := []critter{
    {name: "Spark", energy: 3},
    {name: "Bolt", energy: 2},
    {name: "Nova", energy: 1},
}
fmt.Println(critters) // [{Spark 3} {Bolt 2} {Nova 1}]

Inside the literal, Go lets you leave out the type in front of each {. critters is a normal slice from Chapter 22. What is new is that each item has its own fields and methods, which you reach through its position:

critters := []critter{
    {name: "Spark", energy: 3},
    {name: "Bolt", energy: 2},
}

critters[1].energy = 10
critters[1].play()              // Bolt plays. Energy: 9
fmt.Println(critters[0].energy) // 3

Loop and call a method on each

The obvious loop looks like it works:

critters := []critter{
    {name: "Spark", energy: 3},
    {name: "Bolt", energy: 2},
}

for _, c := range critters {
    c.play()
}

Output:

Spark plays. Energy: 2
Bolt plays. Energy: 1

The numbers look right. Now print the slice afterwards, as exercises/28/02-range-copy.go does:

critters := []critter{
    {name: "Spark", energy: 3},
    {name: "Bolt", energy: 2},
}

for _, c := range critters {
    c.play()
}
fmt.Println(critters) // [{Spark 3} {Bolt 2}]

range gives you a copy of each struct. Chapter 22 warned about this for ints. In the loop above, c is a variable holding a copy of the item. c.play() takes the location of that copy, and changes it. The critters in the slice never lose any energy.

It compiles, it runs, and the printed numbers look right on the first round. Nothing tells you that anything is wrong until the second loop prints Energy: 2 again. Taking &c inside the loop does not help either, because that is a pointer to the copy.

There are two fixes. The first is to use the position, as Chapter 22 did:

critters := []critter{
    {name: "Spark", energy: 3},
    {name: "Bolt", energy: 2},
}

for i := range critters {
    critters[i].play()
}
fmt.Println(critters) // [{Spark 2} {Bolt 1}]

critters[i] is the item itself, not a copy. One loop, and each critter tracks its own energy. This is the core move of this chapter: keep a collection, act on it as a group, and let each value track itself.

Open exercises/28/01-critters.go. It makes each critter play once with a position loop, then counts the tired ones. Add a second play loop before the count, and watch the energies drop. Then write the loop as for _, c := range critters, and see what the count says.

A slice of pointers

The second fix is to keep pointers in the slice. The type is []*critter. Now range copies a pointer, and the copy leads to the same critter:

// Chapter 28 example: a slice of pointers. range copies the pointer, and the
// copy still points at the same critter.
// Run with: go run exercises/28/03-pointer-slice.go

package main

import "fmt"

type critter struct {
    name   string
    energy int
}

func newCritter(name string, energy int) *critter {
    return &critter{name: name, energy: energy}
}

func (c *critter) play() {
    c.energy = c.energy - 1
    fmt.Printf("%s plays. Energy: %d\n", c.name, c.energy)
}

func main() {
    critters := []*critter{
        newCritter("Spark", 3),
        newCritter("Bolt", 2),
    }

    for _, c := range critters {
        c.play()
    }
    for _, c := range critters {
        c.play()
    }

    fmt.Println(critters[0].energy) // 1
    fmt.Println(critters[1].energy) // 0
}

Output:

Spark plays. Energy: 2
Bolt plays. Energy: 1
Spark plays. Energy: 1
Bolt plays. Energy: 0
1
0

c.play() now changes the real critter, so the plain range loop is right. A second name for the same critter also stays in step: first := critters[0] shares it, where with a []critter it would be a copy.

Which one to choose? Both are common:

  • Use []critter with a position loop for small values, when each one lives in this one slice.
  • Use []*critter when the values change often, or when something else also needs to hold on to the same value.

In a []*critter an item can be nil, and calling play on it would crash, as Chapter 27 showed. Fill the slice with real critters.

Filtering and counting

It is still a slice, so every loop pattern from Chapter 20 works. Count how many critters are tired:

critters := []critter{
    {name: "Spark", energy: 2},
    {name: "Bolt", energy: 1},
    {name: "Nova", energy: 0},
}

tired := 0
for i := range critters {
    if critters[i].isTired() {
        tired = tired + 1
    }
}
fmt.Println("Tired critters:", tired) // Tired critters: 1

The if calls a method instead of testing a plain variable, and the count pattern is unchanged.

When a loop only reads, the copy from range does no harm, and the short form is fine if you read the fields directly:

critters := []critter{
    {name: "Spark", energy: 2},
    {name: "Bolt", energy: 1},
}

total := 0
for _, c := range critters {
    total = total + c.energy
}
fmt.Println(total) // 3

Adding and removing

append and slicing from Chapter 22 work on a slice of structs just as on a slice of numbers:

critters := []critter{
    {name: "Spark", energy: 3},
    {name: "Bolt", energy: 2},
}

critters = append(critters, critter{name: "Zip", energy: 5}) // a new critter joins
critters = critters[1:]                                      // the first one leaves
fmt.Println(critters)                                        // [{Bolt 2} {Zip 5}]

Sorting

slices.SortFunc from Chapter 24 takes a function that compares two items. For structs, the function compares whichever field you want to sort by. Here is exercises/28/04-sort.go:

// Chapter 28 example: sorting a slice of structs with a named function.
// Run with: go run exercises/28/04-sort.go

package main

import (
    "fmt"
    "slices"
    "strings"
)

type critter struct {
    name   string
    energy int
}

func byEnergy(a critter, b critter) int {
    return a.energy - b.energy
}

func byName(a critter, b critter) int {
    return strings.Compare(a.name, b.name)
}

func main() {
    critters := []critter{
        {name: "Spark", energy: 3},
        {name: "Bolt", energy: 2},
        {name: "Nova", energy: 1},
    }

    slices.SortFunc(critters, byEnergy)
    fmt.Println(critters) // [{Nova 1} {Bolt 2} {Spark 3}]

    slices.SortFunc(critters, byName)
    fmt.Println(critters) // [{Bolt 2} {Nova 1} {Spark 3}]
}

byEnergy returns a negative number if a goes first, as Chapter 24 said. strings.Compare gives the same kind of answer for two strings. For a []*critter, the function's parameters are *critter instead.

Maps of structs

A map can hold structs too, and reading a field is simple: team["spark"].energy. Assigning to one is not. This is exercises/28/06-map-field-error.go, inside main:

team := map[string]critter{
    "spark": {name: "Spark", energy: 3},
}

team["spark"].energy = 2
fmt.Println(team["spark"].energy)

Running it stops with:

# command-line-arguments
exercises\28\06-map-field-error.go:21:2: cannot assign to struct field team["spark"].energy in map

A map hands you a copy of what it holds, and Go refuses to let you change one field of a copy that is about to vanish. For the same reason, you cannot call a pointer method on an item in the map:

team := map[string]critter{
    "spark": {name: "Spark", energy: 3},
}

team["spark"].play()
cannot call pointer method play on critter

There are two fixes, in exercises/28/05-map-of-structs.go. The first is to copy the struct out, change the copy, and put it back:

team := map[string]critter{
    "spark": {name: "Spark", energy: 3},
}

c := team["spark"]
c.energy = 2
team["spark"] = c
fmt.Println(team["spark"].energy) // 2

The second is to keep pointers in the map. Assigning through a pointer does not change what the map holds, so Go allows it:

pets := map[string]*critter{
    "bolt": {name: "Bolt", energy: 2},
}

pets["bolt"].energy = 1
fmt.Println(pets["bolt"].energy) // 1

A missing key in a map of pointers gives the zero value, which is nil, so pets["nova"].energy crashes. Check with the v, ok form from Chapter 23 first:

pets := map[string]*critter{
    "bolt": {name: "Bolt", energy: 2},
}

pet, ok := pets["nova"]
if !ok {
    fmt.Println("No critter called nova.") // No critter called nova.
    return
}
fmt.Println(pet.energy)

As always, range over a map has no order. To print a map of structs, sort the keys first, as in Chapter 23.

Homework

Homework starters are in exercises/28/homework/. Each one runs as it is and prints a placeholder line. All four have the same small hero struct and newHero function at the top.

Problem 1 — A party of heroes

Open exercises/28/homework/01-party.go. Make a slice of three heroes with different names and hit points, then loop over it and print each one's name and HP on its own line.

Problem 2 — Total HP

Open exercises/28/homework/02-total-hp.go. Use the accumulate pattern to add up everyone's HP, and print the team total.

Problem 3 — Who is hurt?

Open exercises/28/homework/03-who-is-hurt.go. Each hero has an hp and a maxHP. Lower one hero's hp through the slice position, then loop over the party and print every hero whose hp is below maxHP.

Challenge — Strongest hero

Open exercises/28/homework/04-strongest.go. Loop over the party and print the name of the hero with the most HP. Use the accumulate-a-maximum idea: remember the best so far, and replace it when you find a stronger one.

Stuck or finished? Open the homework solutions page.