Modeling Data
Store Many Tasks with Slices
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
rangecopy 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 []stringcreates an empty slice of strings. Its zero value isnil, and its length is 0. Anilslice is safe to use. You can read its length, loop over it, and append to it.appendadds 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 likeindex 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 usedos.Args[2:]in Lesson 1.4.for i, name := range namesloops over every item.iis the index andnameis 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. Whenappendneeds more room than the capacity, it creates a bigger array and copies the items. You can watch this withfmt.Println(len(s), cap(s))after eachappend. 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 thatappendreturns.
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
- Count tasks. Add a
countcommand that prints how many tasks are open and how many are done. - Undo. Add an
undo <id>command that marks a done task as open again. - Delete a task. Add a
delete <id>command. Hint:tasks = append(tasks[:i], tasks[i+1:]...)removes the item at indexi. The...passes the items of a slice one by one. Then tryslices.Delete(tasks, i, i+1)from the standardslicespackage, which does the same thing more clearly. - Show only open tasks. Add
list open, which prints only tasks that are not done. Build a new slice withappendinside a loop. - 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
rangealone does not work - Read lines from standard input with
bufio.Scanner
