01.1 構造体とメソッド — データと振る舞いをまとめる¶
バックエンド開発では、「データ」と「そのデータに対する操作」をペアで扱うことが基本になります。 たとえば「銀行口座」は、残高(データ)と、入金・出金・残高照会(操作)から成り立っています。
このレッスンでは、手続き型の書き方の限界を確認したうえで、Go でオブジェクトを表す基本
(構造体 struct とメソッド method)を学びます。あわせて、Go 特有のつまずきポイントである
値レシーバとポインタレシーバの違いも体験します。
このレッスンのゴール:
- 構造体で「データ」を定義できる
- 構造体に「メソッド」を定義して「操作」をまとめられる
- 値レシーバとポインタレシーバの違いを説明できる
1. 手続き型の書き方の限界¶
まず、構造体を使わない「手続き型」の銀行口座を想像してください。
// 手続き型: 残高はバラバラの変数、操作は独立した関数
balance := 0
owner := "taro"
func deposit(balance int, amount int) int {
return balance + amount
}
// 注意: balance を毎回関数に渡し、戻り値で受け直す必要がある
問題は、次の 3 点です。
- データと操作が分散する — 残高の変数はここ、操作はあっち、と頭の中の地図が必要
- 誤った組み合わせを防げない —
deposit(owner, 100)のような間違った呼び出しを型で防げない - 状態の不変条件を守るのが難しい — 「残高は負にならない」というルールを、関数の外側から破れてしまう
そこで、データと操作を 1 つにまとめた「箱」を作ります。Go ではこれを 構造体(struct) と呼びます。
2. 構造体とメソッドの定義¶
構造体は type と struct で定義します。メソッドは func (レシーバ) メソッド名(...) の形で、
「この型に属する操作」として定義します。
次のコードは、銀行口座を表す BankAccount です。このセルは関数・型の宣言だけなので、
先頭に %% を付けません(ノートブック内で後から使えるようにするためです)。
import (
"errors"
"fmt"
"strings"
"github.com/janpfeifer/gonb/gonbui"
)
// ErrUnanswered は、練習問題が未回答のときにプレースホルダ関数が返す特別なエラー。
var ErrUnanswered = errors.New("未回答: この関数はまだ実装されていません")
type BankAccount struct {
owner string // 非公開: 外部からは直接触れない
balance int // 非公開: 外部からは直接触れない
}
// コンストラクタ: 正しい初期状態の口座を作る。
// 不変条件「残高は負にならない」は、作成時点でも守る(負の初期残高はプログラマのミス → panic)。
func NewBankAccount(owner string, initial int) *BankAccount {
if initial < 0 {
panic(fmt.Sprintf("NewBankAccount: initial balance must not be negative: %d", initial))
}
return &BankAccount{owner: owner, balance: initial}
}
func (a *BankAccount) Deposit(amount int) error {
if amount <= 0 {
return fmt.Errorf("deposit amount must be positive: %d", amount)
}
a.balance += amount
return nil
}
func (a *BankAccount) Withdraw(amount int) error {
if amount <= 0 {
return fmt.Errorf("withdraw amount must be positive: %d", amount)
}
if amount > a.balance {
return fmt.Errorf("insufficient funds: balance=%d, withdraw=%d", a.balance, amount)
}
a.balance -= amount
return nil
}
func (a *BankAccount) Balance() int {
return a.balance
}
func (a *BankAccount) Owner() string {
return a.owner
}
ここがポイント¶
NewBankAccountはコンストラクタです。初期残高を持った口座を 1 箇所で作ります- コンストラクタは負の初期残高を拒否します(残高が負にならない、という不変条件は作る時点から守る)
Deposit/Withdrawは操作(メソッド)です。ここに「残高は負にならない」というルールが閉じ込められていますownerとbalanceは小文字で始まっています。Go では小文字で始まるフィールドはパッケージ外からアクセス不可(カプセル化の第一歩、詳しくは 01.2 で)
3. 実際に動かしてみる¶
口座を作り、入出金を実行して、残高がどう遷移するかを見てみましょう。 まず「台帳」に操作履歴をためる仕組みを用意します。
type ledgerEntry struct {
op string
amount int
balance int
err string
}
var ledger []ledgerEntry
func record(op string, amount int, err error) {
e := ledgerEntry{op: op, amount: amount, balance: acct.Balance()}
if err != nil {
e.err = err.Error()
}
ledger = append(ledger, e)
}
var acct = NewBankAccount("taro", 1000)
操作履歴を記録し、最後に HTML 表で「見える化」します。gonbui.DisplayHTML はこのコースでよく使う
表示手段です。🔴 GoNBの罠: %% セルをまたぐと package 変数への書き込みが次のセルに反映されない
ことがあるため、記録(record の呼び出し)と表示(renderLedger/DisplayHTML)は同じセル内で
行います。
func renderLedger() string {
var b strings.Builder
b.WriteString(`<table border="1" cellpadding="4" style="border-collapse:collapse"><tr><th>操作</th><th>金額</th><th>残高</th><th>エラー</th></tr>`)
for _, e := range ledger {
b.WriteString(fmt.Sprintf(`<tr><td>%s</td><td>%d</td><td>%d</td><td>%s</td></tr>`,
e.op, e.amount, e.balance, e.err))
}
b.WriteString(`</table>`)
return b.String()
}
%%
record("口座開設", 0, nil)
record("入金 500", 500, acct.Deposit(500))
record("出金 200", 200, acct.Withdraw(200))
record("出金 9999(残高不足)", 9999, acct.Withdraw(9999))
gonbui.DisplayHTML(renderLedger())
gonbui.Sync()
| 操作 | 金額 | 残高 | エラー |
|---|---|---|---|
| 口座開設 | 0 | 1000 | |
| 入金 500 | 500 | 1500 | |
| 出金 200 | 200 | 1300 | |
| 出金 9999(残高不足) | 9999 | 1300 | insufficient funds: balance=1300, withdraw=9999 |
表を読んでください。「出金 9999(残高不足)」の行だけがエラーになり、残高が変わっていません。
この「不正な操作をエラーで止める」ロジックが Withdraw メソッドの中に閉じ込められているからこそ、
外部から口座を壊せないのです。
4. つまずきポイント: 値レシーバ vs ポインタレシーバ¶
メソッド定義で (a *BankAccount) のように * を付けたものをポインタレシーバ、
(a BankAccount) のように付けないものを値レシーバと呼びます。
ポインタレシーバは「本物の口座」を操作し、値レシーバは「コピーの口座」を操作します。 コピーを操作しても本物は変わりません。これを実際に確かめてみましょう。
次の BadWithdraw は、値レシーバで定義した「ダメな」出金メソッドです。
// BadWithdraw は値レシーバで定義したダメな例。
// a は呼び出し元のコピーなので、残高を減らしても本物は変わらない。
func (a BankAccount) BadWithdraw(amount int) error {
if amount <= 0 {
return fmt.Errorf("withdraw amount must be positive: %d", amount)
}
if amount > a.balance {
return fmt.Errorf("insufficient funds")
}
a.balance -= amount // これはコピーの balance を変えるだけ
return nil
}
%%
fmt.Println("Before:", acct.Balance())
err := acct.BadWithdraw(100)
fmt.Println("BadWithdraw の返り値:", err)
fmt.Println("After:", acct.Balance(), "← 100 減っていない(値レシーバの罠)")
Before: 1000 BadWithdraw の返り値: <nil> After: 1000 ← 100 減っていない(値レシーバの罠)
学んだこと: 状態を変更するメソッドはポインタレシーバ(*BankAccount)、
読み取るだけのメソッドは値レシーバでもポインタレシーバでも良い(どちらでも動く)。
状態を変える操作は *T、読むだけの操作は T でも *T でも、というのが Go の慣習です。
5. 直感・類推: 書類ケース¶
手続き型の書き方は、書類(データ)とその扱い方(操作)がバラバラの引き出しに置いてある状態です。 口座の残高を確認したければ「残高の引き出し」を開け、入金の手順は「入金の引き出し」を開ける……という具合で、 毎回どこに何があるか思い出さないといけません。
構造体とメソッドは、「口座」という名前の書類ケース 1 つに、中身(フィールド)と扱い方(メソッド)を 一緒に入れた状態です。ケースを開ければ、中身も、操作方法も、そして「してはいけないこと」 (残高が負になる操作はエラー)も全部そこにあります。
バックエンドでのつながり: これは後の「3層アーキテクチャ」「DAO」レッスンで、 「データの持ち方」と「その操作の契約」をまとめる設計思想につながっていきます。
練習問題 1.1: MonthlyFee(月額手数料)を実装しよう¶
BankAccount に新しいメソッド MonthlyFee を追加してください。
このメソッドは、毎月の口座手数料を残高から差し引きます。
仕様:
func (a *BankAccount) MonthlyFee(fee int) error
fee <= 0ならエラーを返す- 残高不足(
fee > a.balance)ならエラーを返す(残高は変えない) - 成功したら残高から
feeを差し引いてnilを返す
ヒント: Withdraw の構造がほぼそのまま使えます。まず上のコードを読んで、
「エラーを返す条件」「残高を引く場所」を同じ形にしてみましょう。
// YOUR CODE HERE
// func (a *BankAccount) MonthlyFee(fee int) error を実装してください。
// (未実装のままチェックセルを実行すると「未回答」と表示されます)
func (a *BankAccount) MonthlyFee(fee int) error {
return ErrUnanswered
}
チェックのためのヘルパー¶
答え合わせに使う小さなヘルパー mustEqual と、未回答を表す特別なエラー ErrUnanswered を定義します。
(GoNB はローカルパッケージを import できないため、各ノートブックにこの定義を置いています)
import "reflect"
import "fmt"
func mustEqual(got, want any, name string) {
if reflect.DeepEqual(got, want) {
fmt.Printf("✅ Passed: %s\n", name)
return
}
panic(fmt.Sprintf("❌ %s\n got = %v (%T)\n want = %v (%T)", name, got, got, want, want))
}
%%
acc := NewBankAccount("hanako", 1000)
err1 := acc.MonthlyFee(100)
if errors.Is(err1, ErrUnanswered) {
fmt.Println("⚠️ 未回答: 練習問題を解いてから、このセルを再度実行してください")
} else {
mustEqual(err1, nil, "MonthlyFee(100) はエラーなし")
mustEqual(acc.Balance(), 900, "残高が 1000 → 900")
err2 := acc.MonthlyFee(5000)
mustEqual(err2 != nil, true, "残高不足(5000)はエラー")
mustEqual(acc.Balance(), 900, "エラー時は残高が変わらない")
err3 := acc.MonthlyFee(-1)
mustEqual(err3 != nil, true, "負の手数料はエラー")
fmt.Println("🎉 すべてのチェックが通りました")
}
⚠️ 未回答: 練習問題を解いてから、このセルを再度実行してください
まとめ¶
- データと操作は構造体(
struct)+ メソッドで 1 つにまとめる - メソッドの中に「不変条件」(残高が負にならない等)を閉じ込める
- 状態を変えるメソッドはポインタレシーバ、読むだけならどちらでも良い
答え合わせは 01.1-structs-and-methods-solutions.ipynb で行ってください。
次は 01.2 で「カプセル化」を詳しく見ます。