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:
NewAccountreturns two values, because making an account can now fail. It is the same convention asnewDog, and it is how a type checks its values from the start. A package usually offers aNewThingfunction like this, or justNewwhen the package is named after its main type.checkAmountis lower case. It is a helper, as in Chapter 25, and other packages cannot call it.- The fields can be read through
Owner()andBalance(). By convention, a method that reads a field is calledBalance, notGetBalance.
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:
- What does it know? Its fields. An account knows its balance.
- What can it do? Its methods. Deposit, withdraw, check.
- 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*healthand an error.maxHealthmust be above 0, and health starts full.heal(n)raises health, but never abovemaxHealth.damage(n)lowers it, but never below0.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.