Tarkistetaan oppitunnin käyttöoikeutta...

Modeling Data

Store Many Tasks with Slices

Video ja luettava29 minEsikatselu

Katselet ilmaista esikatseluoppituntia.

Goal

By the end of this lesson, Tasker can hold many tasks. You add an interactive mode, tasker shell, where you can add, list, and complete tasks, and Tasker remembers them until you quit.

What you will learn

  • How to create a slice and add items with append
  • How to read items by index and loop over a slice with range
  • How to take a part of a slice with s[a:b]
  • Why a slice shares memory with the slice it came from
  • The range copy trap, and how to change items in a slice correctly
  • How to read lines from the keyboard with bufio.Scanner

Why this matters

Each run of tasker add starts with no tasks and ends immediately, so the program cannot remember anything. An interactive mode keeps the program running, so the tasks stay in memory while you work. In Module 3, you save them to a file so they survive after the program ends.

Step 1: Learn slices in the playground

Create play/main.go (or reuse the folder from Lesson 2.1):

package main

import "fmt"

func main() {
	var names []string
	fmt.Println(names == nil, len(names))

	names = append(names, "Ana")
	names = append(names, "Ben", "Chen")
	fmt.Println(names, len(names))

	fmt.Println(names[0], names[len(names)-1])
	fmt.Println(names[1:3], names[:1], names[1:])

	for i, name := range names {
		fmt.Println(i, name)
	}
}

Run it with go run ./play. Output:

true 0
[Ana Ben Chen] 3
Ana Chen
[Ben Chen] [Ana] [Ben Chen]
0 Ana
1 Ben
2 Chen

Line by line:

  • var names []string creates an empty slice of strings. Its zero value is nil, and its length is 0. A nil slice is safe to use. You can read its length, loop over it, and append to it.
  • append adds one or more items to the end and returns the new slice. Always assign the result back: names = append(names, ...). If you forget, the new item is lost.
  • names[0] is the first item. names[len(names)-1] is the last one. Indexes start at 0. If you read an index that does not exist, the program crashes with a message like index out of range.
  • names[1:3] is a part of the slice, from index 1 up to, but not including, index 3. You can leave out either number: [:1] starts at the beginning and [1:] goes to the end. You already used os.Args[2:] in Lesson 1.4.
  • for i, name := range names loops over every item. i is the index and name is the value. Use _ if you do not need one of them.

You can also create a slice that already has items:

nums := []int{10, 20, 30}

Step 2: Slices share memory

Add this to play/main.go:

part := names[0:2]
part[0] = "Anna"
fmt.Println(names)

Output:

[Anna Ben Chen]

You changed part, but names changed too. A slice does not hold its own items. It is a small view of an array, a fixed block of memory, behind it. names[0:2] creates a new view of the same array, so both slices see the same items.

This makes slicing very fast, because Go never copies the data. But remember the rule: a part of a slice shares memory with the original.

Length and capacity. A slice has a length (len), the number of items it holds now. It also has a capacity (cap), how many items fit in the array behind it. When append needs more room than the capacity, it creates a bigger array and copies the items. You can watch this with fmt.Println(len(s), cap(s)) after each append. The exact numbers can change between Go versions. You rarely need to think about capacity, but it explains why you must always use the slice that append returns.

Step 3: The range copy trap

Add this to play/main.go:

nums := []int{10, 20, 30}
for _, n := range nums {
	n = n * 2
}
fmt.Println(nums)

for i := range nums {
	nums[i] = nums[i] * 2
}
fmt.Println(nums)

Output:

[10 20 30]
[20 40 60]

In the first loop, n is a copy of each item. Changing the copy does not change the slice. This is the same copying rule you saw with structs in Lesson 2.1.

To change an item, use its index: nums[i] = .... This rule matters for Tasker. To mark a task as done, you must change tasks[i].Done, not a copy of the task.

Step 4: Add helper functions for a list of tasks

Create task/list.go:

package task

// NextID returns the ID for a new task: one more than the highest ID in the list.
func NextID(tasks []Task) int {
	highest := 0
	for _, t := range tasks {
		if t.ID > highest {
			highest = t.ID
		}
	}
	return highest + 1
}

// IndexOf returns the position of the task with the given ID, or -1 if there is no such task.
func IndexOf(tasks []Task, id int) int {
	for i, t := range tasks {
		if t.ID == id {
			return i
		}
	}
	return -1
}

Both functions only read the tasks, so the range copy is fine here.

IndexOf returns -1 when it finds nothing. That is a common pattern in many languages. In Lesson 2.4, you replace it with the Go way: returning an error.

Step 5: Build the interactive shell

Create a new file shell.go in the main package:

package main

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

	"tasker/task"
)

func runShell() {
	var tasks []task.Task
	scanner := bufio.NewScanner(os.Stdin)

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

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

		switch words[0] {
		case "add":
			if len(words) < 2 {
				fmt.Println("error: add needs a title")
				continue
			}
			t := task.New(task.NextID(tasks), strings.Join(words[1:], " "))
			tasks = append(tasks, t)
			fmt.Println("Added:", task.Format(t))
		case "list":
			if len(tasks) == 0 {
				fmt.Println("No tasks yet.")
			}
			for _, t := range tasks {
				fmt.Println(task.Format(t))
			}
		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
			}
			i := task.IndexOf(tasks, id)
			if i == -1 {
				fmt.Printf("error: no task with ID %d\n", id)
				continue
			}
			tasks[i].Done = true
			fmt.Println("Completed:", task.Format(tasks[i]))
		case "quit":
			fmt.Println("Bye!")
			return
		default:
			fmt.Printf("error: unknown command %q\n", words[0])
		}
	}
}

In main.go, add a new case to the switch:

case "shell":
	runShell()

Add a line to the help text too:

  shell         Start an interactive session

Build and start the shell:

go build
./tasker shell

Try a session:

Tasker shell. Commands: add <title>, list, done <id>, quit
> add Buy milk
Added: 1. [ ] Buy milk
> add Learn   slices
Added: 2. [ ] Learn slices
> done 1
Completed: 1. [x] Buy milk
> done 9
error: no task with ID 9
> list
1. [x] Buy milk
2. [ ] Learn slices
> quit
Bye!

Step 6: How the shell works

for { ... } is a loop with no condition. It runs until something stops it. Go has only one loop keyword, for, and it covers every kind of loop.

bufio.NewScanner(os.Stdin) reads input one line at a time. os.Stdin is standard input, the stream your keyboard writes to. It is the input partner of stdout and stderr.

scanner.Scan() waits for the next line. It returns false when there is no more input, for example when you press Ctrl+D (Ctrl+Z then Enter on Windows). Then break leaves the loop.

strings.Fields splits a line at spaces and removes extra spaces. That is why Learn slices became Learn slices.

continue skips the rest of the loop and starts the next round. return leaves the whole function, which ends the shell.

tasks[i].Done = true changes the task inside the slice, using its index. This is the lesson from Step 3.

Why it works: slices are Go's main collection

Go has few collection types: arrays, slices, and maps. In practice, you use slices for almost every list. A slice is cheap to pass to functions, because Go copies only the small view and not the items. append grows the slice when needed. Because a slice can share memory, Go code always assigns the result of append and changes items by index.

Exercises

  1. Count tasks. Add a count command that prints how many tasks are open and how many are done.
  2. Undo. Add an undo <id> command that marks a done task as open again.
  3. Delete a task. Add a delete <id> command. Hint: tasks = append(tasks[:i], tasks[i+1:]...) removes the item at index i. The ... passes the items of a slice one by one. Then try slices.Delete(tasks, i, i+1) from the standard slices package, which does the same thing more clearly.
  4. Show only open tasks. Add list open, which prints only tasks that are not done. Build a new slice with append inside a loop.
  5. Pipe input. Run printf 'add A\nadd B\nlist\n' | ./tasker shell. Your shell reads lines from a pipe exactly as it reads from the keyboard. Why does this work?

Checkpoint

You are ready for the next lesson if you can:

  • Create a slice, add to it with append, and read items by index
  • Explain why names = append(names, x) must assign the result
  • Explain why changing a part of a slice can change the original
  • Change an item in a slice inside a loop, and explain why range alone does not work
  • Read lines from standard input with bufio.Scanner