01.2 カプセル化 — 内部状態を外から守る¶
前のレッスンで、BankAccount の balance が小文字(非公開)で始まっていたのを覚えていますか?
このレッスルでは、カプセル化(encapsulation)を学びます。 「データ(フィールド)を外から直接いじらせず、メソッドという入り口だけを公開する」ことで、 オブジェクトの不変条件(「在庫は負にならない」「残高は不足分を出せない」等)を守る設計をします。
このレッスンのゴール:
- エクスポート(大文字)/非エクスポート(小文字)の意味を説明できる
- コンストラクタとメソッドだけで状態を操作する「公開 API」を作れる
- フィールドを直接触ると何が起きるか、実演で説明できる
1. カプセル化とは¶
現実の自動販売機を想像してください。
- あなたが操作できるのは「お金を入れる」「ボタンを押す」「釣銭を受け取る」だけ
- 中の在庫や売上は、ガラス越しに見えても直接いじれない
- もし中の部品を直接いじれたら、在庫を無限にしたり、お金を無料で引き出したりできる
ソフトウェアのオブジェクトも同じです。フィールド(中の状態)を直接変更させず、 メソッド(操作の入り口)だけを公開する。これがカプセル化です。
Go では、先頭を大文字にするとエクスポート(公開)、小文字だと非エクスポート(非公開)になります。
2. 自動販売機を作る¶
VendingMachine を構造体で定義します。inserted(投入金額)と stock(在庫)は小文字で、
外部(他のパッケージ)から直接触れないことを意味します。
import (
"errors"
"fmt"
"strings"
"github.com/janpfeifer/gonb/gonbui"
)
// ErrUnanswered は、練習問題が未回答のときにプレースホルダ関数が返す特別なエラー。
var ErrUnanswered = errors.New("未回答: この関数はまだ実装されていません")
// price は商品ごとの値段(このノートブック内だけの簡易表)
var priceOf = map[string]int{"cola": 120, "tea": 100, "water": 80}
type VendingMachine struct {
inserted int // 非公開: 投入金額の合計
stock map[string]int // 非公開: 商品ごとの在庫
}
// NewVendingMachine は「正しい初期状態」の自販機を作る(コンストラクタ)
func NewVendingMachine() *VendingMachine {
return &VendingMachine{
stock: map[string]int{"cola": 5, "tea": 3, "water": 0},
}
}
// InsertCoin はお金を投入する。0円以下はエラー。
func (v *VendingMachine) InsertCoin(amount int) error {
if amount <= 0 {
return errors.New("coin must be positive")
}
v.inserted += amount
return nil
}
// Inserted は投入金額を返す(読み取りだけ)
func (v *VendingMachine) Inserted() int {
return v.inserted
}
// Stock は指定商品の在庫を返す(読み取りだけ)
func (v *VendingMachine) Stock(item string) int {
return v.stock[item]
}
// Buy は商品を買う。在庫・金額のチェックはここで行う。
func (v *VendingMachine) Buy(item string) (string, error) {
price, ok := priceOf[item]
if !ok {
return "", fmt.Errorf("unknown item: %s", item)
}
if v.stock[item] <= 0 {
return "", fmt.Errorf("sold out: %s", item)
}
if v.inserted < price {
return "", fmt.Errorf("insufficient coins: inserted=%d, need=%d", v.inserted, price)
}
v.inserted -= price
v.stock[item]--
return item, nil
}
// Refund は投入金額をすべて返金する
func (v *VendingMachine) Refund() int {
amount := v.inserted
v.inserted = 0
return amount
}
ここがポイント¶
NewVendingMachineで「在庫が必ず入っている」状態からしか始められないBuyの中に「在庫がない→エラー」「金額不足→エラー」というルールが閉じ込められている- 外部からは
InsertCoin/Buy/Refundのメソッドを通してしか状態を変えられない
3. メソッド経由で正しく使う¶
投入→購入→返金の流れを、正しい(メソッド経由の)使い方で実行します。
var vending = NewVendingMachine()
func vmRecord(label string, result string) {
fmt.Printf("%-28s → %s\n", label, result)
}
%%
vmRecord("初期状態の在庫: コーラ", fmt.Sprintf("%d 本", vending.Stock("cola")))
vmRecord("100円投入", fmt.Sprint(vending.InsertCoin(100)))
vmRecord("コーラ(120円)購入", func() string {
r, err := vending.Buy("cola")
if err != nil {
return "エラー: " + err.Error()
}
return r + " が買えた"
}())
vmRecord("20円追加投入", fmt.Sprint(vending.InsertCoin(20)))
vmRecord("コーラ(120円)購入", func() string {
r, err := vending.Buy("cola")
if err != nil {
return "エラー: " + err.Error()
}
return r + " が買えた"
}())
vmRecord("水(80円)購入(在庫0)", func() string {
r, err := vending.Buy("water")
if err != nil {
return "エラー: " + err.Error()
}
return r
}())
vmRecord("返金", fmt.Sprintf("%d 円", vending.Refund()))
初期状態の在庫: コーラ → 5 本 100円投入 → <nil> コーラ(120円)購入 → エラー: insufficient coins: inserted=100, need=120 20円追加投入 → <nil> コーラ(120円)購入 → cola が買えた 水(80円)購入(在庫0) → エラー: sold out: water 返金 → 0 円
読んでください: 「水(在庫0)の購入」がエラーになり、Refund で残金が戻っています。
在庫ゼロの商品は Buy メソッドがエラーで止めてくれるので、在庫がマイナスになることは起きません。
4. つまずきポイント: フィールドを直接触るとどうなるか¶
カプセル化のありがたみは、「フィールドを直接いじれると何が起きるか」を見ると分かります。
注意: このノートブック内は 1 つのパッケージなので、技術的には
v.stockを直接変更できてしまいます。 実際の Go プロジェクトでは「外部パッケージ」からは小文字フィールドに触るとコンパイルエラーになります。 ここでは「もし直接いじれたら」の実演として確認します。
// 本来なら外部からはできない「在庫の直接書き換え」を実演する
func cheatStock(v *VendingMachine) {
v.stock["cola"] = 99999 // メソッドをすり抜けて在庫を増やす
}
%%
fmt.Println("Before: cola =", vending.Stock("cola"))
cheatStock(vending)
fmt.Println("After : cola =", vending.Stock("cola"), "← 検品なしで在庫が 99999 に")
fmt.Println("この『メソッドをすり抜ける変更』をさせないことがカプセル化の目的")
Before: cola = 5 After : cola = 99999 ← 検品なしで在庫が 99999 に この『メソッドをすり抜ける変更』をさせないことがカプセル化の目的
学んだこと: Buy のようなメソッド経由なら在庫は正しく減り、売り切れはエラーになります。
一方、フィールドを直接いじれば、在庫の整合性を破壊できます。
カプセル化は「変更の入り口をメソッドに 1 つに絞る」ことで、この破壊をコンパイル時(外部パッケージ)または
規約(内部)で防ぐ仕組みです。
5. 直感・類推: 銀行の窓口と金庫¶
自販機の中身(在庫・売上)は、金庫の中のお金だと思ってください。 金庫を直接開けられるのは中の人だけ。あなた(外部)は窓口(メソッド)を通してだけ操作できます。
- 窓口係は「残高確認」「入金」「出金」を手順どおりに処理し、ルール(残高不足なら出金できない等)を守る
- もし金庫を直接開けられたら、ルールは無意味になる
バックエンドでは、モデルの不変条件をメソッドに閉じ込め、直接アクセスを禁止することが、 データの整合性を守る基本の設計になります(DB の「制約」と似た考え方で、02 章で扱います)。
練習問題 1.2: Restock(在庫補充)を実装しよう¶
VendingMachine に Restock メソッドを追加してください。自動販売機の管理担当者が
在庫を補充するための操作です。
仕様:
func (v *VendingMachine) Restock(item string, n int) error
- 商品が存在しない(
priceOfに無い)ならエラー n <= 0ならエラー- それ以外は在庫に
nを加えてnilを返す
// YOUR CODE HERE
// func (v *VendingMachine) Restock(item string, n int) error を実装してください。
// (未実装のままチェックセルを実行すると「未回答」と表示されます)
func (v *VendingMachine) Restock(item string, n int) error {
return ErrUnanswered
}
チェックのためのヘルパー¶
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))
}
%%
m := NewVendingMachine()
err1 := m.Restock("water", 5)
if errors.Is(err1, ErrUnanswered) {
fmt.Println("⚠️ 未回答: 練習問題を解いてから、このセルを再度実行してください")
} else {
mustEqual(err1, nil, "Restock(water, 5) はエラーなし")
mustEqual(m.Stock("water"), 5, "water の在庫が 0 → 5")
err2 := m.Restock("pizza", 1)
mustEqual(err2 != nil, true, "存在しない商品はエラー")
err3 := m.Restock("cola", 0)
mustEqual(err3 != nil, true, "0 以下の補充はエラー")
err4 := m.Restock("cola", -3)
mustEqual(err4 != nil, true, "負の補充はエラー")
fmt.Println("🎉 すべてのチェックが通りました")
}
⚠️ 未回答: 練習問題を解いてから、このセルを再度実行してください
まとめ¶
- カプセル化 = フィールドを非公開(小文字)にして、操作の入り口をメソッドに絞る
- コンストラクタで正しい初期状態を保証し、メソッド内で不変条件を守る
- フィールド直接変更は整合性を壊す → 入り口を 1 つにすることが守りになる
答え合わせは 01.2-encapsulation-solutions.ipynb で行ってください。
次は 01.3 で「継承の代わり」になる埋め込み(composition)を学びます。