Featured image of post コードシェフの仕込み帳【Memento】仕込み直し〜変更前の状態を呼び出し側がコピーするコードを、Originatorが自分のスナップショットを切り出す構造へ直す〜

コードシェフの仕込み帳【Memento】仕込み直し〜変更前の状態を呼び出し側がコピーするコードを、Originatorが自分のスナップショットを切り出す構造へ直す〜

レシピ管理アプリで呼び出し側が変更前の状態を手でコピーし、serving_size 追加時に一箇所コピー漏れが出た問題。MementoパターンをPerlとMooで実装し、RecipeがRecipeMementoにスナップショットを保存し、RecipeHistoryがスタックで管理する構造へ直す。

外はまだ薄暗かった。

開店前の厨房に入ると、シェフがすでに作業をしていた。包丁を研いでいるのか、メニューを確認しているのか——私はそのそばで、いつもより静かな朝の仕込みを始めた。

引き戸を軽くノックする音がした。「すみません、朝早くに——」と若い女性の声がした。シェフが「開いてる」と言った。

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 での実装RecipeMementois => 'ro' 属性のみを持つ純粋なデータオブジェクト。Recipesave_state でスナップショットを生成し、restore_state で復元する
浅いコピー{ %{$self->ingredients} } で保存時と復元時の両方でコピーする。属性値がスカラーのフラットなハッシュであれば安全

対象読者は、次のような人を想定しています。

  • PerlとMooの基本(hasnew)がなんとなく分かる
  • 「元に戻す」処理を実装しようとして、バックアップコードが散在した経験がある

技術スタックはPerl / Mooです。コードはすべて手元で動かし、テストが通ることを確認しています。

属性が増えるたびにコピーコードを探す

「もう少し見せてもらえますか」と私は言った。

女性が「ここです」と言って、コードをスクロールした。Recipe クラスはこうなっていた。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
package Recipe;
use Moo;
use v5.36;
has name         => (is => 'rw');
has ingredients  => (is => 'rw', default => sub { {} });
has seasoning    => (is => 'rw', default => sub { {} });
has serving_size => (is => 'rw', default => 0);   # 先週追加した

sub add_ingredient {
    my ($self, $name, $amount) = @_;
    $self->ingredients->{$name} = $amount;
}

sub adjust_seasoning {
    my ($self, $type, $amount) = @_;
    $self->seasoning->{$type} = $amount;
}

呼び出し側のコピーコードはこうなっていた。serving_size を追加したとき、2か所のうち1か所はコピーを追加し忘れていた。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
# serving_size のコピーが抜けている(更新し忘れた箇所)
my $backup_ingredients = { %{$recipe->ingredients} };
my $backup_seasoning   = { %{$recipe->seasoning} };

$recipe->adjust_seasoning('salt', 3.0);
$recipe->add_ingredient('garlic', 2);
$recipe->serving_size(4);   # ← 変更したが、コピーがないので戻せない

# 気に入らないので元に戻す
$recipe->ingredients($backup_ingredients);
$recipe->seasoning($backup_seasoning);
# serving_size は復元されない

serving_size が 4 のまま残る。Undoが壊れている」と私は言った。

女性が「そうです。バックアップコードが5か所に分散していて——属性を1つ追加するたびに5か所全部を探して、全部に追加しないといけなくて。今回は1か所書き忘れた」

問題は構造にあった。呼び出し側が Recipe の内部——ingredientsseasoningserving_size——を知っている。属性が増えるたびに、コピーしているすべての場所を追いかけなければならない。

レシピが自分のスナップショットを切り出す

シェフが「Recipe が自分で切り出せばいい」と言って、コードを書き始めた。

まず RecipeMemento——純粋なデータオブジェクト。is => 'ro' 属性だけを持つ。

1
2
3
4
5
6
package RecipeMemento;   # Memento(純粋なデータオブジェクト)
use Moo;
use v5.36;
has ingredients  => (is => 'ro', required => 1);
has seasoning    => (is => 'ro', required => 1);
has serving_size => (is => 'ro', required => 1);

次に Recipesave_staterestore_state を追加する。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
package Recipe;   # Originator
use Moo;
use v5.36;
has name         => (is => 'rw');
has ingredients  => (is => 'rw', default => sub { {} });
has seasoning    => (is => 'rw', default => sub { {} });
has serving_size => (is => 'rw', default => 0);

sub save_state {
    my ($self) = @_;
    return RecipeMemento->new(
        ingredients  => { %{$self->ingredients} },   # 保存時にコピー
        seasoning    => { %{$self->seasoning} },
        serving_size => $self->serving_size,
    );
}

sub restore_state {
    my ($self, $memento) = @_;
    $self->ingredients({ %{$memento->ingredients} });   # 復元時もコピー
    $self->seasoning({ %{$memento->seasoning} });
    $self->serving_size($memento->serving_size);
}

sub add_ingredient   { my ($self, $name, $amount) = @_; $self->ingredients->{$name} = $amount; }
sub adjust_seasoning { my ($self, $type, $amount) = @_; $self->seasoning->{$type}   = $amount; }

最後に RecipeHistory——スタックで履歴を管理する Caretaker。

1
2
3
4
5
6
7
package RecipeHistory;   # Caretaker(履歴管理)
use Moo;
use v5.36;
has _history => (is => 'rw', default => sub { [] });

sub save    { my ($self, $memento) = @_; push @{$self->_history}, $memento; }
sub restore { my ($self) = @_;           return pop @{$self->_history}; }

呼び出し側はこうなる。

1
2
3
4
5
6
7
8
my $history = RecipeHistory->new;
$history->save($recipe->save_state);       # Caretaker がスナップショットを預かる

$recipe->adjust_seasoning('salt', 3.0);
$recipe->add_ingredient('garlic', 2);
$recipe->serving_size(4);

$recipe->restore_state($history->restore); # 呼び出し側は内部構造を知らない

Recipe の中身を知っているのは Recipe だけになる」と私は言った。「serving_size が増えても、save_staterestore_state の2か所だけを直せばいい——呼び出し側は変わらない」

女性が「でも——」と言った。

save_state を呼ぶ代わりに、呼び出し側で my $backup_ingredients = { %{$recipe->ingredients} } すれば同じでは?中でやってることは一緒じゃないですか?」

私は答えた。

「呼び出し側では、Recipe の中に ingredientsseasoningserving_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 が saverestore だけをする、という約束を守れば機能します。でも——」

シェフが「約束で動くコードは——誰が破るか分からない」と言った。

一拍の間があった。

女性が「なるほど——じゃあ、Caretakerは中身を見ないで、saverestore だけをすればいいということで」と言った。実用上の答えとして受け取った。「分かりました、ありがとうございます」

試食合格

テストを走らせた。

Before(呼び出し側が手でコピー):

1
2
3
4
5
6
ok 1 - 正しいコピー: salt が元に戻る
ok 2 - 正しいコピー: serving_size が元に戻る
ok 3 - コピー漏れ: salt は復元できる
ok 4 - ★コピー漏れ: serving_size は変更後の値のまま残る
ok 5 - ★コピー漏れ: Undo が壊れている
1..5

テスト1〜2番——3属性すべてをコピーすれば動く。テスト3〜5番——serving_size のコピーが1か所漏れると、salt は戻るのに serving_size は戻らない。これが今回のバグの実態だ。

After(Mementoパターン):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
ok 1 - restore_state: salt が元に戻る
ok 2 - restore_state: pasta が元に戻る
ok 3 - restore_state: serving_size が元に戻る
ok 4 - restore_state: 追加した garlic も消える
ok 5 - 浅いコピー: save 後の変更が Memento を汚染しない
ok 6 - 浅いコピー: serving_size も Memento から復元
ok 7 - 履歴スタック: 1回目の undo → snapshot 2 に戻る
ok 8 - 履歴スタック: 2回目の undo → snapshot 1 に戻る
ok 9 - 呼び出し側は変更不要: serving_size 追加後も同じ save/restore コール
ok 10 - Memento は ro: 書き込みは例外になる
ok 11 - Memento は ro: 書き込み試行後も値は変わらない
1..11

全テスト通過、警告なし。

テスト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 の変更だけで済む

工程

  1. 状態を保存したいクラス(Originator)を特定する。保存が必要な属性をリストアップする
  2. Memento クラスを作る。is => 'ro' の属性だけを持つ純粋なデータオブジェクト
  3. save_state メソッドを追加する。各属性の値をコピーして Memento オブジェクトを返す(ハッシュは { %{...} } でコピー)
  4. restore_state($memento) メソッドを追加する。Memento の値を自身の属性に復元する(復元時もコピー)
  5. Caretaker クラスを作る。スタック(配列)を持ち、savepush)と restorepop)だけを行う
  6. 呼び出し側のコピーコードを削除し、$history->save($recipe->save_state) に置き換える

シェフより

「材料が何かを知っているのは、料理人だけでいい。手伝いが材料の中身を覚える必要はない——手伝いは、仕込み前に “今の状態を保存して” と頼んで、元に戻すときに “最後の状態を出して” と頼むだけでいい」


依頼人が帰った後、シェフがまた朝の作業に戻った。

私は——答えた。Layer 1 も Layer 2 も。「save_state を使えば、呼び出し側が内部構造を知らなくていい」——それは合っていた。「Moo では Caretaker からの読み取りを言語レベルで封じられない。慣習に依存する」——それも合っていた。

シェフが「約束で動くコードは——誰が破るか分からない」と言った。

それは——私の答えへの指摘だったのか。それとも、一般論だったのか。

「慣習に依存する」と言いかけたとき、私は「でも、守れば機能する」と続けようとしていた。シェフのひと言が入ったのは、そのタイミングだった。

「守れば機能する」は——正しかったのか。それとも、弱い答えだったのか。

ep16 のとき——「どこまで大きくなったら分割するか、まだ言えません」と言って、止まった。足りなかったものが何かは、分かっていた。

今回は——答えた。でも、答えが正しかったか、分からない。

何が足りないかは分かる。それが問題なのか、分からない。

comments powered by Disqus
Hugo で構築されています。
テーマ StackJimmy によって設計されています。