30. Designing a small type

You can define structs, give them methods, make many of them, and embed them. This last chapter of Part 6 has little new syntax. It is about judgement: designing a type that is pleasant and safe to use. The big idea, encapsulation, is simpler than it sounds.

Encapsulation: let the methods guard the data

Encapsulation means keeping a type's data valid by changing it only through its own methods. Other code asks the value to do things, and never reaches in to scramble the fields.

Here is a bank account. The balance should never go negative, and you cannot deposit a negative amount. The methods enforce those rules, and report a refusal with an error, as in Chapter 21. This is exercises/30/01-account.go:

type account struct {
    owner   string
    balance int
}

func newAccount(owner string) *account {
    return &account{owner: owner}
}

func (a *account) deposit(amount int) error {
    if amount <= 0 {
        return fmt.Errorf("cannot deposit %d", amount)
    }
    a.balance = a.balance + amount
    return nil
}

func (a *account) withdraw(amount int) error {
    if amount <= 0 {
        return fmt.Errorf("cannot withdraw %d", amount)
    }
    if amount > a.balance {
        return fmt.Errorf("cannot withdraw %d: the balance is %d", amount, a.balance)
    }
    a.balance = a.balance - amount
    return nil
}

func (a *account) getBalance() int {
    return a.balance
}

Using it:

func main() {
    acc := newAccount("Keiko")

    err := acc.deposit(100)
    if err != nil {
        fmt.Println("Error:", err)
    }

    err = acc.withdraw(30)
    if err != nil {
        fmt.Println("Error:", err)
    }
    fmt.Println(acc.getBalance()) // 70

    err = acc.withdraw(1000)
    if err != nil {
        fmt.Println("Error:", err) // Error: cannot withdraw 1000: the balance is 70
    }
    fmt.Println(acc.getBalance()) // 70
}

The refused withdrawal changes nothing. Because every change goes through a method that checks first, the account can never reach an impossible state. Checking before changing anything matters: a method that returns an error should leave the value as it was.

Open exercises/30/01-account.go. Add a method canAfford(amount int) bool that returns true if the balance is at least amount, without changing it. Use it before a withdraw.

Keeping the rules in one place with a package

There is a hole in this. All that code is in package main, and Chapter 25 said that lower case names are private to their package, not to their type. So any function in the file can reach in, and Go says nothing:

func main() {
    acc := newAccount("Keiko")
    acc.balance = -500
    fmt.Println(acc.getBalance()) // -500
}

The rule only holds if everyone remembers it. To make Go enforce it, move the type into a package of its own. The fields stay lower case, and the type and its methods get capital letters. Here is exercises/30/02-account-package/:

02-account-package/
    go.mod
    main.go
    bank/
        bank.go

bank/bank.go:

// Chapter 30 example: package bank. Every rule about an account is here, and
// other packages cannot reach past these methods to the fields.

package bank

import (
    "errors"
    "fmt"
)

// Account is one bank account. Make one with NewAccount.
type Account struct {
    owner   string
    balance int
}

// NewAccount returns an account with a balance of 0. The owner must not be
// empty.
func NewAccount(owner string) (*Account, error) {
    if owner == "" {
        return nil, errors.New("an account needs an owner")
    }
    return &Account{owner: owner}, nil
}

// checkAmount is a helper for this package only.
func checkAmount(amount int) error {
    if amount <= 0 {
        return fmt.Errorf("the amount %d is not above 0", amount)
    }
    return nil
}

// Deposit adds to the balance. The amount must be above 0.
func (a *Account) Deposit(amount int) error {
    err := checkAmount(amount)
    if err != nil {
        return err
    }
    a.balance = a.balance + amount
    return nil
}

// Withdraw takes from the balance. The amount must be above 0, and no more
// than the balance.
func (a *Account) Withdraw(amount int) error {
    err := checkAmount(amount)
    if err != nil {
        return err
    }
    if amount > a.balance {
        return fmt.Errorf("cannot withdraw %d: the balance is %d", amount, a.balance)
    }
    a.balance = a.balance - amount
    return nil
}

// Owner returns the name on the account.
func (a *Account) Owner() string {
    return a.owner
}

// Balance returns the current balance.
func (a *Account) Balance() int {
    return a.balance
}

Now the type is bank.Account, and main can only use what has a capital letter. If main.go tries the same trick by adding acc.balance = -500 after the first fmt.Println, Go stops:

# accountpackage
.\main.go:60:6: acc.balance undefined (type *bank.Account has no field or method balance, but does have Balance)

Every rule about an account now lives in bank.go. If the balance is ever wrong, there is only one file to read.

Look at what else changed:

  • NewAccount returns two values, because making an account can now fail. It is the same convention as newDog, and it is how a type checks its values from the start. A package usually offers a NewThing function like this, or just New when the package is named after its main type.
  • checkAmount is lower case. It is a helper, as in Chapter 25, and other packages cannot call it.
  • The fields can be read through Owner() and Balance(). By convention, a method that reads a field is called Balance, not GetBalance.

Another package can still write var acc bank.Account or bank.Account{}, because the type is exported, and skip NewAccount. It gets an account with no owner and a balance of 0. The balance rule still holds, because the fields are zero values and the methods still check. When you design a type, make sure that its zero value is safe, or say in a comment that it must be made with New.

Here is the program that uses it. main.go reads commands with readLine from Chapter 21:

// Chapter 30 example: a small command loop that uses package bank.
//
// Run from this folder:
//   cd exercises/30/02-account-package
//   go run .

package main

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

    "accountpackage/bank"
)

var reader = bufio.NewReader(os.Stdin)

// readLine prints the prompt and gives back one trimmed line. The error is
// not nil when there is no more input.
func readLine(prompt string) (string, error) {
    fmt.Print(prompt)
    text, err := reader.ReadString('\n')
    line := strings.TrimSpace(text)
    if err != nil && line == "" {
        return "", err
    }
    return line, nil
}

// doAmount runs "deposit" or "withdraw" with the amount the user typed.
func doAmount(acc *bank.Account, verb string, amountText string) {
    amount, err := strconv.Atoi(amountText)
    if err != nil {
        fmt.Println("That is not a whole number.")
        return
    }

    if verb == "deposit" {
        err = acc.Deposit(amount)
    } else {
        err = acc.Withdraw(amount)
    }
    if err != nil {
        fmt.Println("Error:", err)
        return
    }
    fmt.Println("Balance:", acc.Balance())
}

func main() {
    acc, err := bank.NewAccount("Keiko")
    if err != nil {
        fmt.Println("Error:", err)
        return
    }
    fmt.Println("Account for", acc.Owner())

    for {
        line, err := readLine("> ")
        if err != nil {
            fmt.Println()
            fmt.Println("No more input.")
            return
        }

        words := strings.Fields(line)
        if len(words) == 1 && words[0] == "quit" {
            fmt.Println("Goodbye.")
            return
        }
        if len(words) == 1 && words[0] == "balance" {
            fmt.Println("Balance:", acc.Balance())
            continue
        }
        if len(words) == 2 && (words[0] == "deposit" || words[0] == "withdraw") {
            doAmount(acc, words[0], words[1])
            continue
        }
        fmt.Println("Commands: deposit <amount>, withdraw <amount>, balance, quit")
    }
}

Run it from its folder:

cd exercises/30/02-account-package
go run .

A session:

Account for Keiko
> deposit 100
Balance: 100
> withdraw 30
Balance: 70
> withdraw 1000
Error: cannot withdraw 1000: the balance is 70
> deposit -5
Error: the amount -5 is not above 0
> deposit abc
That is not a whole number.
> hello
Commands: deposit <amount>, withdraw <amount>, balance, quit
> balance
Balance: 70
> quit
Goodbye.

All the checking of amounts is in bank, and main only has to print what comes back. When the input runs out, it prints No more input., and stops.

A clean public surface

Think of a type as having two sides:

  • The inside: its fields and helper functions. These are details that may change.
  • The outside: the exported methods that other code calls. This is a small, clear set of actions: Deposit, Withdraw, Balance.

A good type keeps the outside small and obvious. A user of bank.Account needs only a few verbs, and never needs to know that the balance lives in a field called balance. Store it in cents tomorrow, and as long as the methods behave the same, nothing else breaks. Go's exported and unexported names draw this line for you.

Name methods as actions

Methods do things, so name them with verbs: Deposit, Withdraw, move, attack, reset. Fields hold things, so name them with nouns: balance, name, hp. acc.Withdraw(30) should read like an instruction, because it is one. The exception is a method that only reports a value, which is named after that value, like Balance.

Three questions for any type

When you design a type, ask:

  1. What does it know? Its fields. An account knows its balance.
  2. What can it do? Its methods. Deposit, withdraw, check.
  3. What must always stay true? The rules the methods protect. The balance is never negative.

For a type with rules, put it in its own package, and have New and the methods return errors when asked to break a rule. Answer those three questions, and the type almost writes itself.

Homework

Homework starters are in exercises/30/homework/. Each of the first three runs as it is and prints a placeholder line. The challenge is a module, so cd into its folder and use go run ., as in Chapter 25.

Problem 1 — Counter

Open exercises/30/homework/01-counter.go. Design a counter that starts at 0, with increment(), get() and reset(). Other code should change the count only through those methods. Show it counting, reading and resetting.

Problem 2 — Clamped health

Open exercises/30/homework/02-health.go. Design a health type:

  • newHealth(maxHealth) returns a *health and an error. maxHealth must be above 0, and health starts full.
  • heal(n) raises health, but never above maxHealth.
  • damage(n) lowers it, but never below 0.
  • get() reads it.

heal and damage return an error when n is below 0. The rule to protect: health stays between 0 and maxHealth.

Problem 3 — Light switch

Open exercises/30/homework/03-switch.go. Design a lightSwitch that is on or off. toggle() flips it, and isOn() reports its state. Start it off, toggle it a few times, and print the state after each. The type cannot be called switch, because that is a Go keyword.

Challenge — Stack with a guard

Folder: exercises/30/homework/04-stack/.

main.go is finished. Finish the package in stack/stack.go, with New(), Push(v), Pop() and Size(). The rule to protect: Pop() on an empty stack must not crash. It returns an error instead. The field that holds the items has a lower case name, so that main cannot reach it.

Stuck or finished? Open the homework solutions page.