Tarkistetaan oppitunnin käyttöoikeutta...

Modeling Data

Find and Group Tasks with Maps

Video ja luettava37 minEsikatselu

Katselet ilmaista esikatseluoppituntia.

Goal

By the end of this lesson, Tasker stores tasks in a map, so it finds any task by its ID in one step. Tasks can have tags such as #home and #shop. You can list tasks by tag and count how many tasks each tag has.

What you will learn

  • How to create a map, add values, read values, and delete values
  • The "comma ok" form, which tells you if a key exists
  • Why the order of a map is random, and how to sort its keys
  • Why you cannot change a struct field directly inside a map
  • How to use a map to count things
  • Why you must create a map with make before you write to it

Why this matters

In Lesson 2.2, IndexOf checked every task, one by one, to find an ID. With 10 tasks, that is fast. With 100,000 tasks, it is slow. A map connects a key to a value and finds any key in one step, no matter how many items it holds. You will use maps for lookups, counting, grouping, caches, and configuration.

Step 1: Learn maps in the playground

Replace play/main.go with this:

package main

import "fmt"

func main() {
	ages := map[string]int{
		"Ana": 31,
		"Ben": 25,
	}
	ages["Chen"] = 40
	fmt.Println(ages["Ana"], len(ages))

	fmt.Println(ages["Dana"])

	age, ok := ages["Dana"]
	fmt.Println(age, ok)

	delete(ages, "Ben")
	fmt.Println(ages)

	for name, age := range ages {
		fmt.Println(name, age)
	}
}

Run it with go run ./play. Output:

31 3
0
0 false
map[Ana:31 Chen:40]
Ana 31
Chen 40

Line by line:

  • map[string]int{...} creates a map whose keys are strings and whose values are integers.
  • ages["Chen"] = 40 adds a key, or changes its value if the key already exists.
  • ages["Ana"] reads a value. len(ages) is the number of keys.
  • ages["Dana"] reads a key that does not exist. It does not crash. It returns the zero value, 0.
  • age, ok := ages["Dana"] is the comma ok form. ok is true if the key exists and false if it does not. Use it whenever "missing" and "zero" mean different things.
  • delete(ages, "Ben") removes a key. If the key does not exist, nothing happens.
  • for name, age := range ages loops over every key and value.

Step 2: Map order is random

Run the playground several times. The order of the last two lines can change between runs:

Chen 40
Ana 31

Go does not keep map keys in any order, and it changes the loop order on purpose. If code depends on map order, it fails sooner or later, so Go makes the problem visible early.

When you need a fixed order, sort the keys first:

import (
	"maps"
	"slices"
)

for _, name := range slices.Sorted(maps.Keys(ages)) {
	fmt.Println(name, ages[name])
}

maps.Keys gives all the keys, and slices.Sorted returns them as a sorted slice. Now the output is the same every time.

Version note: slices.Sorted and maps.Keys need Go 1.23 or newer. Check the go line in your go.mod file. If it says an older version, change it to your installed version, for example go 1.24.

Step 3: Two map rules that surprise beginners

Rule 1: You cannot change a struct field inside a map directly.

tasks := map[int]task.Task{}
tasks[1].Done = true

Go refuses to compile this:

cannot assign to struct field tasks[1].Done in map

A map gives you a copy of the value, and changing a copy does nothing useful. So Go stops you. Instead, read the value, change it, and store it back:

t := tasks[1]
t.Done = true
tasks[1] = t

Rule 2: A nil map can be read, but not written.

var counts map[string]int
counts["home"] = 1

This compiles, but the program crashes:

panic: assignment to entry in nil map

The zero value of a map is nil. A nil map has no storage behind it. Before you write to a map, create it with make or with a literal:

counts := make(map[string]int)
counts := map[string]int{}

Both lines create an empty map that is ready to use.

Step 4: Add tags to Task

Open task/task.go. Add a Tags field to the struct:

type Task struct {
	ID        int
	Title     string
	Done      bool
	Tags      []string
	CreatedAt time.Time
}

Update New so it accepts tags:

// New creates an open task with the given ID, title, and tags.
func New(id int, title string, tags []string) Task {
	return Task{
		ID:        id,
		Title:     title,
		Tags:      tags,
		CreatedAt: time.Now(),
	}
}

Update the end of Format so it shows the tags:

	line := fmt.Sprintf("%d. %s %s", t.ID, status, t.Title)
	for _, tag := range t.Tags {
		line += " #" + tag
	}
	return line

In main.go, the two calls to task.New now need a third argument. Pass nil, which means "no tags":

t := task.New(1, title, nil)

Step 5: Replace the list helpers

Replace task/list.go with this:

package task

import (
	"maps"
	"slices"
	"strings"
)

// SplitTags separates title words from tags. A tag is a word that starts with "#".
func SplitTags(words []string) (string, []string) {
	var titleWords []string
	var tags []string
	for _, w := range words {
		if strings.HasPrefix(w, "#") && len(w) > 1 {
			tags = append(tags, strings.ToLower(w[1:]))
		} else {
			titleWords = append(titleWords, w)
		}
	}
	return strings.Join(titleWords, " "), tags
}

// SortedIDs returns the IDs of all tasks, from lowest to highest.
func SortedIDs(tasks map[int]Task) []int {
	return slices.Sorted(maps.Keys(tasks))
}

// CountByTag returns how many tasks have each tag.
func CountByTag(tasks map[int]Task) map[string]int {
	counts := make(map[string]int)
	for _, t := range tasks {
		for _, tag := range t.Tags {
			counts[tag]++
		}
	}
	return counts
}

NextID and IndexOf are gone. The map replaces IndexOf, and a simple counter replaces NextID.

Look at counts[tag]++ in CountByTag. If tag is new, counts[tag] returns the zero value 0, and ++ makes it 1. If the tag already exists, its count goes up by one. The zero value makes counting with a map very short.

w[1:] works on strings the same way it works on slices. It returns the text without its first character, so #home becomes home.

Step 6: Update the shell

Replace shell.go with this:

package main

import (
	"bufio"
	"fmt"
	"maps"
	"os"
	"slices"
	"strconv"
	"strings"

	"tasker/task"
)

func runShell() {
	tasks := make(map[int]task.Task)
	nextID := 1
	scanner := bufio.NewScanner(os.Stdin)

	fmt.Println("Tasker shell. Commands: add <title> [#tag...], list [#tag], done <id>, tags, quit")
	for {
		fmt.Print("> ")
		if !scanner.Scan() {
			break
		}

		words := strings.Fields(scanner.Text())
		if len(words) == 0 {
			continue
		}

		switch words[0] {
		case "add":
			title, tags := task.SplitTags(words[1:])
			if title == "" {
				fmt.Println("error: add needs a title")
				continue
			}
			t := task.New(nextID, title, tags)
			tasks[t.ID] = t
			nextID++
			fmt.Println("Added:", task.Format(t))
		case "list":
			filter := ""
			if len(words) == 2 {
				filter = strings.TrimPrefix(strings.ToLower(words[1]), "#")
			}
			shown := 0
			for _, id := range task.SortedIDs(tasks) {
				t := tasks[id]
				if filter != "" && !slices.Contains(t.Tags, filter) {
					continue
				}
				fmt.Println(task.Format(t))
				shown++
			}
			if shown == 0 {
				fmt.Println("No tasks found.")
			}
		case "done":
			if len(words) != 2 {
				fmt.Println("error: done needs exactly one task ID")
				continue
			}
			id, err := strconv.Atoi(words[1])
			if err != nil {
				fmt.Printf("error: %q is not a valid task ID\n", words[1])
				continue
			}
			t, ok := tasks[id]
			if !ok {
				fmt.Printf("error: no task with ID %d\n", id)
				continue
			}
			t.Done = true
			tasks[id] = t
			fmt.Println("Completed:", task.Format(t))
		case "tags":
			counts := task.CountByTag(tasks)
			if len(counts) == 0 {
				fmt.Println("No tags yet.")
			}
			for _, tag := range slices.Sorted(maps.Keys(counts)) {
				fmt.Printf("#%s: %d\n", tag, counts[tag])
			}
		case "quit":
			fmt.Println("Bye!")
			return
		default:
			fmt.Printf("error: unknown command %q\n", words[0])
		}
	}
}

What changed:

  • tasks := make(map[int]task.Task) stores tasks by ID. nextID counts up for each new task.
  • add uses SplitTags, then stores the task with tasks[t.ID] = t.
  • list loops over sorted IDs, so tasks always appear in order. With a tag, such as list #home, it skips tasks without that tag. slices.Contains checks if a slice holds a value.
  • done uses comma ok to check that the task exists, then reads, changes, and stores the task back. This is Rule 1 from Step 3.
  • tags is a new command that prints the count for each tag, in sorted order.

Step 7: Try it

go build
./tasker shell

A sample session:

> add Buy milk #shop #home
Added: 1. [ ] Buy milk #shop #home
> add Fix bike #home
Added: 2. [ ] Fix bike #home
> add Read book
Added: 3. [ ] Read book
> add #shop
error: add needs a title
> done 2
Completed: 2. [x] Fix bike #home
> list #home
1. [ ] Buy milk #shop #home
2. [x] Fix bike #home
> list #work
No tasks found.
> tags
#home: 2
#shop: 1
> quit
Bye!

Why it works: choose the structure that fits the question

  • A slice answers "What is the order?" It keeps items in sequence.
  • A map answers "Where is the item with this key?" It finds any key in one step.

Tasker now stores tasks in a map, because its most common question is "Which task has ID 3?" When it needs order, it sorts the keys. Real programs often combine both, for example a map for fast lookups and a sorted slice for display.

Exercises

  1. Delete a task. Add a delete <id> command. Use comma ok to report an error if the ID does not exist, then use delete.
  2. Remove duplicate tags. add Test #home #home stores home twice. Change SplitTags to skip a tag it has already seen. Hint: use a map[string]bool to remember which tags you have added.
  3. Group by tag. Write GroupByTag(tasks map[int]Task) map[string][]Task, which returns the tasks for each tag. Add a groups command that prints each tag, followed by its tasks.
  4. Count done and open. Write a function that returns a map[bool]int, with the number of done tasks under true and open tasks under false.
  5. Break it on purpose. In CountByTag, change make(map[string]int) to var counts map[string]int. Run tags with some tagged tasks. Read the panic message, then fix the code.

Checkpoint

You are ready for the next lesson if you can:

  • Create a map, add and read values, and delete keys
  • Use comma ok to check if a key exists
  • Print a map's contents in a fixed order
  • Change a struct stored in a map, and explain why tasks[1].Done = true does not compile
  • Explain why writing to a nil map crashes, and how to avoid it