外はまだ薄暗かった。
開店前の厨房に入ると、シェフがすでに作業をしていた。包丁を研いでいるのか、メニューを確認しているのか——私はそのそばで、いつもより静かな朝の仕込みを始めた。
引き戸を軽くノックする音がした。「すみません、朝早くに——」と若い女性の声がした。シェフが「開いてる」と言った。
30代前半の女性が入ってきた。PCを抱えている。「○○さんに紹介していただいて——フリーランスでレシピ管理アプリを作っているんですが、Undoの実装でバグを出してしまって」
シェフが「見せろ」と言った。
女性がPCをカウンターに置いた。Recipe クラス。ingredients(食材)と seasoning(味付け)の2つのハッシュ属性。変更前に呼び出し側が手でコピーしている。
「いつ壊れた?」とシェフが聞いた。
「先週、serving_size——人数分——の属性を追加したとき。コピーのコードを1か所書き忘れて。Undoしたとき、食材と味付けは元に戻るのに、人数が戻らなかったんです」
私はコードを見た。呼び出し側に { %{$recipe->ingredients} } と { %{$recipe->seasoning} } が並んでいる。serving_size が追加されたが、2か所のコピーコードのうち1か所にしか $backup_serving_size = $recipe->serving_size がない。
「——Recipe の中身を、呼び出す側が全部知っている」と私は言った。
シェフが目を向けた。続きを促すように。
「属性が増えるたびに、コピーしているすべての場所を探して、全部に追加しなければならない。1か所でも漏れると——Undoが壊れる」
先週のMediatorの件が、まだどこかにあった。答えた。でも、シェフは頷かなかった。何が足りなかったのか——今日は違う。新しい問題だ。
この記事で学ぶこと
この記事は、「変更前の状態を呼び出し側が手でコピーし、Recipe に属性が増えるたびにすべてのコピー箇所を更新しなければならない」という問題を、Mementoパターンで整理する話です。Recipe(Originator)が RecipeMemento にスナップショットを切り出し、RecipeHistory(Caretaker)がスタックで管理する構造へ直します。
| 学ぶこと | ひとことで言うと |
|---|---|
| Memento パターン | Originatorが自身の状態のスナップショット(Memento)を生成し、Caretakerがスタックで管理する。Caretakerはいつ保存・復元するかを決めるが、Mementoの中身を知る必要はない |
| 破壊的な状態変更 | 変更前の状態を呼び出し側が手でコピーするアンチパターン。Originatorに属性が増えるたびに、すべてのコピー箇所を更新しなければならない |
| Moo での実装 | RecipeMemento は is => 'ro' 属性のみを持つ純粋なデータオブジェクト。Recipe は save_state でスナップショットを生成し、restore_state で復元する |
| 浅いコピー | { %{$self->ingredients} } で保存時と復元時の両方でコピーする。属性値がスカラーのフラットなハッシュであれば安全 |
対象読者は、次のような人を想定しています。
- PerlとMooの基本(
has、new)がなんとなく分かる - 「元に戻す」処理を実装しようとして、バックアップコードが散在した経験がある
技術スタックはPerl / Mooです。コードはすべて手元で動かし、テストが通ることを確認しています。
属性が増えるたびにコピーコードを探す
「もう少し見せてもらえますか」と私は言った。
女性が「ここです」と言って、コードをスクロールした。Recipe クラスはこうなっていた。
| |
呼び出し側のコピーコードはこうなっていた。serving_size を追加したとき、2か所のうち1か所はコピーを追加し忘れていた。
| |
「serving_size が 4 のまま残る。Undoが壊れている」と私は言った。
女性が「そうです。バックアップコードが5か所に分散していて——属性を1つ追加するたびに5か所全部を探して、全部に追加しないといけなくて。今回は1か所書き忘れた」
問題は構造にあった。呼び出し側が Recipe の内部——ingredients・seasoning・serving_size——を知っている。属性が増えるたびに、コピーしているすべての場所を追いかけなければならない。
レシピが自分のスナップショットを切り出す
シェフが「Recipe が自分で切り出せばいい」と言って、コードを書き始めた。
まず RecipeMemento——純粋なデータオブジェクト。is => 'ro' 属性だけを持つ。
| |
次に Recipe に save_state と restore_state を追加する。
| |
最後に RecipeHistory——スタックで履歴を管理する Caretaker。
| |
呼び出し側はこうなる。
| |
「Recipe の中身を知っているのは Recipe だけになる」と私は言った。「serving_size が増えても、save_state と restore_state の2か所だけを直せばいい——呼び出し側は変わらない」
女性が「でも——」と言った。
「save_state を呼ぶ代わりに、呼び出し側で my $backup_ingredients = { %{$recipe->ingredients} } すれば同じでは?中でやってることは一緒じゃないですか?」
私は答えた。
「呼び出し側では、Recipe の中に ingredients と seasoning と serving_size がある——ということを知っている必要があります。属性が増えるたびに、コピーしているすべての場所に追加しなければならない。今回のバグはそこから来ていた。save_state を使えば、Recipe が “自分の状態をどう保存するか” を知っている。属性が何であれ、呼び出し側は save_state を呼ぶだけです——中身を追いかけなくていい」
女性が「——ああ。それが今回のバグの原因ですね。5か所のうち1か所に、serving_size のコピーを追加し忘れた」と言った。腑に落ちた表情だった。
シェフが「Mementoパターン」と言った。
「Originatorが自身の状態のスナップショット(Memento)を生成し、Caretakerがスタックで管理する。Caretakerはいつ保存・復元するかを決めるが、Mementoの中身を知る必要はない。MooではMementoは ro 属性のみの純粋なデータオブジェクト。Originatorが save_state でスナップショットを作り、restore_state で戻す」
女性が「なるほど」と言って、コードを見た。しばらくして「$memento->ingredients って——RecipeHistory から呼べますか?Caretaker が Memento の中身を見られる?」
一拍あった。
「読めます」と私は言った。
「Moo の has は、ro にしても読み取りアクセサを必ず公開します。書き込みは防げますが、読み出しは防げない。Java や C++ のネストクラス、フレンドクラスのように、Caretaker からの読み取りを言語レベルで封じることが Moo では——できません。クロージャや inside-out オブジェクトにすれば真に隠せますが、それは has を捨てることになる。Moo を使う限り、慣習に委ねることになります」
「だから——Caretaker が save と restore だけをする、という約束を守れば機能します。でも——」
シェフが「約束で動くコードは——誰が破るか分からない」と言った。
一拍の間があった。
女性が「なるほど——じゃあ、Caretakerは中身を見ないで、save と restore だけをすればいいということで」と言った。実用上の答えとして受け取った。「分かりました、ありがとうございます」
試食合格
テストを走らせた。
Before(呼び出し側が手でコピー):
| |
テスト1〜2番——3属性すべてをコピーすれば動く。テスト3〜5番——serving_size のコピーが1か所漏れると、salt は戻るのに serving_size は戻らない。これが今回のバグの実態だ。
After(Mementoパターン):
| |
全テスト通過、警告なし。
テスト9番——serving_size を追加した後も、呼び出し側の $history->save($recipe->save_state) と $recipe->restore_state($history->restore) は変わらない。テスト5〜6番——保存時と復元時の両方で { %{...} } でコピーしているため、スナップショット取得後に Recipe を変更しても Memento は汚染されない。テスト10〜11番——ro 属性への書き込みは例外になる。読み取りはできる。
女性が「serving_size を追加しても、呼び出し側のコードは変わらないんですね」と言った。
「Recipe の中身を知っているのは Recipe だけになった」と私が言った。
女性が「これで安心して属性を追加できる」と言った。PCをバッグに入れながら、「ありがとうございました」と言って帰った。
シェフの仕込み工程表
| 問題(調理ミス) | 技法(パターン) | 効果(仕上がり) |
|---|---|---|
| 変更前の状態を呼び出し側が手でコピー。Originatorに属性が増えるたびに、すべてのコピー箇所を更新しなければならない。漏れると部分的にUndoが壊れる | Mementoパターン:Originatorが save_state でスナップショット(Memento)を生成し、Caretakerがスタックで管理。Originatorだけが状態の構造を知る | 呼び出し側はOriginatorの内部構造を知らなくてよい。属性が増えても save_state / restore_state の変更だけで済む |
工程
- 状態を保存したいクラス(Originator)を特定する。保存が必要な属性をリストアップする
- Memento クラスを作る。
is => 'ro'の属性だけを持つ純粋なデータオブジェクト save_stateメソッドを追加する。各属性の値をコピーして Memento オブジェクトを返す(ハッシュは{ %{...} }でコピー)restore_state($memento)メソッドを追加する。Memento の値を自身の属性に復元する(復元時もコピー)- Caretaker クラスを作る。スタック(配列)を持ち、
save(push)とrestore(pop)だけを行う - 呼び出し側のコピーコードを削除し、
$history->save($recipe->save_state)に置き換える
シェフより
「材料が何かを知っているのは、料理人だけでいい。手伝いが材料の中身を覚える必要はない——手伝いは、仕込み前に “今の状態を保存して” と頼んで、元に戻すときに “最後の状態を出して” と頼むだけでいい」
依頼人が帰った後、シェフがまた朝の作業に戻った。
私は——答えた。Layer 1 も Layer 2 も。「save_state を使えば、呼び出し側が内部構造を知らなくていい」——それは合っていた。「Moo では Caretaker からの読み取りを言語レベルで封じられない。慣習に依存する」——それも合っていた。
シェフが「約束で動くコードは——誰が破るか分からない」と言った。
それは——私の答えへの指摘だったのか。それとも、一般論だったのか。
「慣習に依存する」と言いかけたとき、私は「でも、守れば機能する」と続けようとしていた。シェフのひと言が入ったのは、そのタイミングだった。
「守れば機能する」は——正しかったのか。それとも、弱い答えだったのか。
ep16 のとき——「どこまで大きくなったら分割するか、まだ言えません」と言って、止まった。足りなかったものが何かは、分かっていた。
今回は——答えた。でも、答えが正しかったか、分からない。
何が足りないかは分かる。それが問題なのか、分からない。
