Õppetunnile juurdepääsu kontrollimine...

Saving to Disk

Pointers and Methods: Build a Task Store

Video ja lugemine37 minEelvaade

Vaatad tasuta eelvaate õppetundi.

Goal

By the end of this lesson, Tasker has a Store type that owns all tasks. The shell no longer manages a map and an ID counter. It asks the store to add, find, and complete tasks. You also give Task its own String method, so fmt.Println prints a task correctly by itself.

What you will learn

  • What a pointer is, and how to use & and *
  • How to let a function change the caller's struct with a pointer
  • How to attach methods to your own types
  • The difference between value receivers and pointer receivers
  • How unexported fields protect the data inside a type
  • What a nil pointer is, and why it crashes when you use it

Why this matters

In Lesson 2.4, the shell still holds two pieces of state: the tasks map and the nextID counter. Both must stay in sync, and any code can change them by mistake. In this module, you save tasks to a file, which adds more state, such as the file path. It is time to put the data and the code that works with it together in one type.

Step 1: Pointers in the playground

Replace play/main.go with this:

package main

import "fmt"

type Counter struct {
	Count int
}

func addOneCopy(c Counter) {
	c.Count++
}

func addOnePointer(c *Counter) {
	c.Count++
}

func main() {
	x := 10
	p := &x
	fmt.Println(*p)
	*p = 20
	fmt.Println(x)

	c := Counter{}
	addOneCopy(c)
	fmt.Println(c.Count)
	addOnePointer(&c)
	fmt.Println(c.Count)
}

Run it with go run ./play. Output:

10
20
0
1

A pointer holds the memory address of a value, not the value itself.

  • &x means "the address of x". So p points to x.
  • *int is the type "pointer to an int". The type of p is *int.
  • *p means "the value that p points to". Reading *p gives 10. Writing *p = 20 changes x, because p points to x.

Now look at the two functions. addOneCopy gets a copy of the struct, so the change is lost. This is the copying rule from Lesson 2.1. addOnePointer gets a pointer to the original, so its change stays.

Inside addOnePointer, the code writes c.Count++, not (*c).Count++. When you use a pointer to a struct, Go follows the pointer for you automatically.

Step 2: Methods

A method is a function attached to a type. Add these to play/main.go, above main:

func (c Counter) Show() string {
	return fmt.Sprintf("count is %d", c.Count)
}

func (c *Counter) Increment() {
	c.Count++
}

Add this at the end of main:

c.Increment()
fmt.Println(c.Show())

Output:

count is 2

The part in parentheses before the method name is the receiver. It works like a parameter, but you call the method with a dot: c.Increment().

There are two kinds of receivers:

  • Value receiver (c Counter): the method gets a copy. Use it for methods that only read the value.
  • Pointer receiver (c *Counter): the method gets a pointer to the original. Use it for methods that change the value.

You wrote c.Increment(), not (&c).Increment(). Go takes the address for you when a method needs a pointer.

A simple rule: if any method of a type needs a pointer receiver, give all its methods pointer receivers. Mixing them makes code harder to reason about. Small types that never change, like Task, can use value receivers.

Step 3: nil pointers

Add this at the end of main:

var empty *Counter
fmt.Println(empty == nil)
empty.Increment()

Output:

true
panic: runtime error: invalid memory address or nil pointer dereference

The zero value of a pointer is nil. It points to nothing. When Increment tries to change c.Count, there is no counter to change, so the program crashes. Before you use a pointer that could be nil, make sure it points to a real value. A constructor such as NewStore in the next step solves this, because it always returns a pointer to a ready value.

Remove these three lines before you continue.

Step 4: Give Task a String method

Open task/task.go. Change the Format function into a method. Only the first two lines change:

// String returns one line that describes the task.
func (t Task) String() string {
	status := "[ ]"
	if t.Done {
		status = "[x]"
	}
	line := fmt.Sprintf("%d. %s %s", t.ID, status, t.Title)
	for _, tag := range t.Tags {
		line += " #" + tag
	}
	return line
}

The name String is special. When you print a value with fmt.Println or %v, and its type has a String() string method, fmt calls that method. So now you can write:

fmt.Println("Added:", t)

and get Added: 1. [ ] Buy milk #shop. In Module 4, you learn the feature that makes this work: interfaces.

In main.go, change task.Format(t) to t.

Step 5: Create the Store type

Create task/store.go:

package task

import (
	"maps"
	"slices"
)

// Store keeps tasks in memory and gives each new task a unique ID.
type Store struct {
	tasks  map[int]Task
	nextID int
}

// NewStore returns an empty store that is ready to use.
func NewStore() *Store {
	return &Store{
		tasks:  make(map[int]Task),
		nextID: 1,
	}
}

// Add validates the title, creates a task, and stores it.
func (s *Store) Add(title string, tags []string) (Task, error) {
	if err := ValidateTitle(title); err != nil {
		return Task{}, err
	}
	t := New(s.nextID, title, tags)
	s.tasks[t.ID] = t
	s.nextID++
	return t, nil
}

// Get returns the task with the given ID.
func (s *Store) Get(id int) (Task, error) {
	t, ok := s.tasks[id]
	if !ok {
		return Task{}, ErrNotFound
	}
	return t, nil
}

// Complete marks the task with the given ID as done.
func (s *Store) Complete(id int) (Task, error) {
	t, err := s.Get(id)
	if err != nil {
		return Task{}, err
	}
	if t.Done {
		return t, ErrAlreadyDone
	}
	t.Done = true
	s.tasks[id] = t
	return t, nil
}

// List returns all tasks, sorted by ID.
func (s *Store) List() []Task {
	var list []Task
	for _, id := range slices.Sorted(maps.Keys(s.tasks)) {
		list = append(list, s.tasks[id])
	}
	return list
}

// Len returns the number of tasks in the store.
func (s *Store) Len() int {
	return len(s.tasks)
}

Look at the design:

  • The fields are unexported. tasks and nextID start with small letters, so code outside the task package cannot touch them. The shell cannot change nextID by mistake or put a task in the map without validation. The only way in is through the methods. This is called encapsulation.
  • NewStore returns *Store. &Store{...} creates a store and returns its address. Every caller shares the same store, and the map is always created, so the nil map panic from Lesson 2.3 cannot happen.
  • All methods use pointer receivers. Add and Complete change the store, so they need pointers. Get, List, and Len only read, but they follow the rule from Step 2: one type, one kind of receiver.
  • Complete calls s.Get. Methods can call other methods. The "not found" rule now lives in one place.
  • List returns a new slice. The caller gets its own copy of the tasks. Changes to that slice do not change the store.

Step 6: Clean up the old functions

The store replaces several functions from Module 2.

In task/actions.go, delete the Add and Complete functions. Keep ValidateTitle and ParseID.

In task/list.go, delete SortedIDs, and change CountByTag so it accepts a slice of tasks instead of a map:

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

Remove the maps and slices imports from list.go. go build tells you if an import is unused.

Step 7: Use the store in the shell

Update shell.go. The start of runShell becomes shorter:

func runShell() {
	store := task.NewStore()
	scanner := bufio.NewScanner(os.Stdin)

Update the cases that use tasks:

		case "add":
			title, tags := task.SplitTags(words[1:])
			t, err := store.Add(title, tags)
			if err != nil {
				fmt.Println("error:", err)
				continue
			}
			fmt.Println("Added:", t)
		case "list":
			filter := ""
			if len(words) == 2 {
				filter = strings.TrimPrefix(strings.ToLower(words[1]), "#")
			}
			shown := 0
			for _, t := range store.List() {
				if filter != "" && !slices.Contains(t.Tags, filter) {
					continue
				}
				fmt.Println(t)
				shown++
			}
			if shown == 0 {
				fmt.Println("No tasks found.")
			}

In the done case, change task.Complete(tasks, id) to store.Complete(id), and print t instead of task.Format(t). In the tags case, change task.CountByTag(tasks) to task.CountByTag(store.List()).

Build and test:

go build
./tasker shell
> add Buy milk #shop
Added: 1. [ ] Buy milk #shop
> add Fix bike #home #shop
Added: 2. [ ] Fix bike #home #shop
> done 1
Completed: 1. [x] Buy milk #shop
> done 1
error: task is already done
> list
1. [x] Buy milk #shop
2. [ ] Fix bike #home #shop
> tags
#home: 1
#shop: 2
> quit
Bye!

Tasker works exactly as before. The difference is in the design: the shell only talks to the store, and the store protects its own data.

Why it works: types with behavior

Go has no classes. Instead, you combine three simple tools:

  • a struct holds the data,
  • methods define what the type can do,
  • unexported fields hide the details.

This gives you most of what classes give you in other languages, with fewer rules. You will use this pattern for every important part of Tasker: the file storage in this module, the HTTP handlers in Part 2, and the database repository after that.

Exercises

  1. A Delete method. Add func (s *Store) Delete(id int) error to the store. It returns ErrNotFound for a missing task. Add a delete <id> command to the shell.
  2. A Reopen method. Add Reopen(id int) (Task, error), which marks a done task as open again. Return a new error ErrNotDone if the task is not done.
  3. A value receiver. Add func (t Task) HasTag(tag string) bool to Task. Use it in the shell instead of slices.Contains.
  4. Prove the copy. Call store.List(), change the title of the first task in the returned slice, then call store.List() again. Is the title in the store changed? Explain why.
  5. Break it on purpose. Change Add to use a value receiver: func (s Store) Add(...). Add two tasks in the shell. What IDs do they get? Explain the result, then change it back. Hint: the map is shared, but nextID is a copy.

Checkpoint

You are ready for the next lesson if you can:

  • Explain what &x, *int, and *p mean
  • Write a function that changes the caller's struct with a pointer
  • Write methods with value receivers and pointer receivers, and explain when to use each
  • Explain how unexported fields protect the data in Store
  • Explain why a nil pointer crashes, and how NewStore prevents it