午後2時を少し回ると、食堂の中はしんとする。
ランチのお客さんが帰って、片付けが終わったころの静けさだ。私はカウンターの食器をまとめながら、午後のメニューのことを考えていた。シェフは奥の冷蔵庫の前にいて、メモを取りながら在庫を確認している。換気扇の低い音だけが厨房から聞こえていた。
引き戸が開いた。
入ってきたのは30代前半に見える男性だった。ラフな服装だが、目が落ち着いていた。急いでいる様子はない。
「失礼します。知人からここを紹介してもらったんですが」
「コードの相談ですか?」と私が聞くと、「そうです」と彼はうなずいた。「ランチのあとに来てしまって、準備中でしたか」
「大丈夫です。シェフを呼んできますね」
そう言いかけると、「準備の途中でしたら、少し後でも」と彼は言った。気を遣える人だと思った。食器を片付けながら「いえ、少し待ってもらえれば」と私は答えた。
彼はカウンターにノートPCを置いた。スクリーンを開いて、横並びに2つのファイルを表示する。片方には process_form_order、もう片方には process_recurring_order という関数がある。
私は食器を持ったまま、その画面をなんとなく眺めた。コードの意味は細かくはわからない。でも、2つの関数の形が、なんとなく違う。
片方には die "デザートにはドリンクが必要\n" という行がある。もう片方には、ない。
「あの」と私は言った。食器を持ったまま、少し自信なさそうに。「こっちには書いてあって、あっちには書いてないですね」
彼がこちらを見た。「そこです。そこが問題で」
シェフが奥から出てきたのは、そのやりとりの直後だった。
この記事で学ぶこと
この記事は、「フォーム注文と定期注文でルーティングループが重複して書かれており、片方の検証を書き忘れてバグが出た」という問題を、Builderパターンで整理する話です。add_item というメソッドがアイテムの振り分けを引き取ることで、なぜ呼び出し側のコードが同一の形に収束するのかを、仕組みから解説します。
| 学ぶこと | ひとことで言うと |
|---|---|
| Builder パターン | 複雑なオブジェクトの組み立て手順を Builder クラスが担い、build() で検証済みの完成品を返す生成パターン |
| assembly-logic-in-caller | 呼び出し側のコードに、オブジェクトを組み立てるための振り分け・積み上げ・検証が重複して書かれている状態 |
| Builder と BUILD の違い | Moo の BUILD は「全引数を渡した後」に検証する。Builder の add_item は「渡すたびに」振り分けと検証を行う |
| 中間状態の所在 | BentoOrderBuilder のインスタンスが「詰め最中の弁当箱」を抱える。関数には持ち場がない |
対象読者は、次のような人を想定しています。
- PerlとMooの基本(
has、new、BUILD)がなんとなく分かる - 「同じような処理が2か所に書かれていて、片方を変えたときに片方を忘れた」経験がある
技術スタックはPerl / Mooです。コードはすべて手元で動かし、テストが通ることを確認しています。
ルーティングループが2か所にあった
シェフがカウンターのそばに来て、画面をのぞき込んだ。
彼が説明した。自分は社内のカフェテリア向け弁当注文システムを担当している。注文はPOSレジからもフォームからも届き、どちらも {type => '...', name => '...'} 形式のアイテムリストとして来る——メイン、副菜、ドリンク、デザートのどれかを示す type と、品名の name を持つハッシュの配列だ。
フォーム注文を処理する関数と、毎週固定の定期注文を処理する関数が、それぞれあった。
| |
BentoOrder クラス自体はシンプルなデータクラスだ。is => 'ro'(読み取り専用)で属性が固定されていて、一度作ったら変えられない。問題は BentoOrder の外にある——両方の処理関数が、同じルーティングループを別々に書いていた。
先週、弁当にデザートを追加できるオプションが増えた。彼は「デザートにはドリンクが必要」というルールを知って、フォーム側の関数に書いた。でも定期注文の関数のほうへ書くのを忘れた。本番で die が飛んだのは、デザートありドリンクなしの定期注文が届いたときだった。
「原因はすぐわかりました」と彼は言った。「でも、直し方がよくわからなくて。BentoOrder に BUILD を書いたら一か所にまとめられると思って、試したんですが」
シェフが「どこまで試したか」と聞いた。
「デザートとドリンクのチェックを BentoOrder の BUILD に移したら、そこは解消しました」と彼はつづけた。「BUILD は Moo でコンストラクタのあとに呼ばれる処理で——全引数を渡した後、作ったオブジェクトを検証できる」
| |
「デザートのバグは消えました。でも——」と彼は画面を指さした。「type を見て変数に振り分けるループが、まだ両方の関数に残っていて。side は @sides に押し込む、main は $main に入れる、という判断を2か所で書き続けているのが、なんかすっきりしなくて」
シェフがコードを見たまま、うなずいた。
仕込みのムラを見つける
シェフがいった。「2冊の手順書がある。弁当の詰め方が書いてある。片方は今週更新した。もう片方は古いまま残った——今回はデザートの話だが、次は別の何かが残る」
私は食器の片付けを止めて、少し考えた。2つの関数に同じ処理が書いてある、ということは分かった。でも、なぜそれがまずいのかを、うまく言葉にできなかった。2冊の手順書、というイメージのほうが、なんとなくわかりやすかった。
「BUILD で検証を一か所にするのはできた」とシェフが言った。「でもルーティングは動かない。誰がタイプを見て振り分けるかは、変わっていない」
これが、assembly-logic-in-caller と呼ばれる状態だとシェフは後で言った。オブジェクトを組み立てるための振り分け・積み上げ・検証が、呼び出し側のコードに重複して書かれている状態——BentoOrder を作るための「詰め手順」が、2か所のコードにそれぞれ存在している。手順が変わるたびに両方を直さないといけない。
「一か所にしたい」と彼は言った。「詰め方が変わっても、どちらかを忘れないようにしたい」
「弁当箱を持つ人間が必要だ」とシェフは言った。奥からホワイトボードを持ってきて、仕切りの入った弁当箱の絵を描いた。「メインを入れる。副菜を入れる。ドリンクを決める。デザートを追加する。そして蓋をする」
弁当箱は Builder の手の中に
シェフが BentoOrderBuilder を書いた。
| |
BentoOrder は is => 'ro' の不変なデータクラスのまま変えない。BentoOrderBuilder は is => 'rw' の属性を持つ——詰め最中の弁当箱だから、中身を変えられる必要がある。
add_item がアイテムを一つ受け取るたびに、type を見て振り分ける。side なら上限を確認してから _sides に追加する。main なら _main に入れる。呼び出し側はアイテムを渡すだけで、どこに入るかを知らなくていい。
build を呼ぶと、最後の検証を済ませて BentoOrder->new(...) を呼び、完成品を返す。蓋が閉まったら中は変えられない——is => 'ro' の完成品が出てくる。
「呼び出し側はこうなる」とシェフが書いた。
| |
彼は2つの関数を見比べた。
「同じになった」と彼は言った。「両方が同じ形になった」
「そうだ」とシェフは答えた。「ルーティングループは add_item の中に移った。どちらの関数も、アイテムを渡して build を呼ぶだけだ」
それから彼は少し考えて、こう言った。
「add_item の中で type を判定して振り分けているだけですよね。それを普通の関数として切り出せばよかったのでは——なぜ Builder クラスである必要があるんですか?」
シェフが書く手を止めた。一拍、間を置いた。
「中間状態を誰が持つか、だ」
シェフがホワイトボードに、add_item が呼ばれるたびに Builder の中身が育っていく図を描いた。「add_item を1回呼ぶたびに、_main に何が入ったか、_sides に何品積まれたか、が変わる。この途中の弁当箱——どこまで詰まったか——を、BentoOrderBuilder のインスタンスが持っている」
「関数には持ち場がない」とシェフは続けた。「関数は呼ばれて、終わる。途中の弁当箱を、次の関数呼び出しまで預かる場所がない。Builder クラスのインスタンスが、build が呼ばれるまで弁当箱を手の中に持ち続ける」
彼は「@sides の積み上げも、Builder の中に入っている」と言った。
「それが引き取るということだ。蓋を閉める(build)まで、弁当箱は Builder の手の中にある」
私は聞きながら、仕切り弁当を想像した。詰めている最中の弁当箱がある。それを誰かが持っていないといけない。関数は呼ばれて終わるから、弁当箱を最後まで持ち続けることができない。BentoOrderBuilder のインスタンスが、それを持つ——そういうことだと思った。完全には言葉にできなかったけれど、絵としては浮かんだ。
「将来、topping という新しいタイプが増えても」とシェフが言った。「add_item に1行追加するだけだ。process_form_order も process_recurring_order も変えなくていい」
これが Builder パターン——複雑なオブジェクトの組み立て手順を Builder クラスが担い、build() で検証済みの完成品を返す生成パターン。Moo では、Builder が rw 属性で可変な中間状態を保ち、Product が ro 属性で不変に仕上がる。
前日の Facade が「複数のサブシステムへの呼び出しを一本化する」パターンだとすれば、Builder は「一つのオブジェクトの部品をどう積み上げるか」を引き取るパターンだ。Facade は段取りの向き先を隠す。Builder は弁当の詰め方そのものを引き取る。
Mermaid 図: Before と After の構造
Before(ルーティングループが2か所に重複):
classDiagram
class BentoOrder {
+main: ro
+sides: ro
+drink: ro
+dessert: ro
}
class process_form_order {
ルーティングループ
デザート検証あり
}
class process_recurring_order {
ルーティングループ
デザート検証なし
}
process_form_order --> BentoOrder
process_recurring_order --> BentoOrder
After(ルーティングが add_item に集約):
classDiagram
class BentoOrderBuilder {
+_main: rw
+_sides: rw
+_drink: rw
+_dessert: rw
+add_item(item)
+build()
}
class BentoOrder {
+main: ro
+sides: ro
+drink: ro
+dessert: ro
}
class process_form_order {
add_item for items
build
}
class process_recurring_order {
add_item for items
build
}
process_form_order --> BentoOrderBuilder
process_recurring_order --> BentoOrderBuilder
BentoOrderBuilder --> BentoOrder
試食合格
テストを走らせた。
| |
全テスト通過、警告なし。
彼は After のコードをもう一度見た。process_form_order と process_recurring_order、両方が $b->add_item($_) for @items; $b->build; の同じ形になっている。
「これで、Builder が検証してくれる」と彼は言った。「どちらの関数もルールを知らなくていい」
「直すのは Builder だけだ」とシェフは言った。「次に新しいタイプが増えても、呼び出し側は変えなくていい」
「来てよかった」と彼は言った。芝居がかったところはなく、ただ率直だった。
シェフの仕込み工程表
| 問題(調理ミス) | 技法(パターン) | 効果(仕上がり) |
|---|---|---|
アイテムリストのルーティングループ(type を見て変数に振り分ける処理)と検証ルールが、複数の呼び出し関数にそれぞれ重複して書かれていた(assembly-logic-in-caller) | Builderパターン:add_item が振り分けと積み上げを担い、build() で検証済みの不変 Product を返す | ルーティングループが add_item の一か所に集まり、両方の呼び出し関数が同一の形になる。ルールを追加・変更しても Builder だけを直せばよい |
工程
BentoOrder->new(...)を呼ぶ前に、typeを見て変数に振り分けるループが呼び出し側に書かれていないか確認する- 同じルーティングループが複数の場所で繰り返されていないか確認する
- Builder クラス(
BentoOrderBuilder)を作り、rw属性で中間状態を持たせ、add_itemの中でルーティングを行う - 積み上げ中に確認できる制約(副菜の上限など)は
add_itemの中で、完成時の制約(デザート+ドリンクなど)はbuild()の中で検証する build()の中でのみBentoOrder->new(...)を呼ぶ。Builder の属性はrw、Product の属性はro- 呼び出し側を
$b->add_item($_) for @items; $b->build;の形に変更する - テストを実行し、両方の呼び出し関数が同じ検証を受けることを確認する
シェフより
「弁当の詰め方を2冊の手順書に書くな。詰め方が変わるたびに2冊を書き直すのは、手順書の作り方がおかしい。詰め場所を一つにして、そこだけが詰め方を知っていればいい。蓋を閉めたら完成品だ——中を変える必要はない」
依頼人が帰って引き戸が閉まると、シェフは厨房に戻った。私はカウンターを拭きながら、今日のことを整理しようとした。
2つの関数。片方には書いてあって、片方にはなかった。それを見たとき、私は「あれ、同じことをやっているはずなのに形が違う」と思った。なぜ違ってはいけないのかは、今もうまく言えない。でも「ここが違いますね」とは言えた。
ep10 の朝は「おかしい気がします」と言えた。今日は「ここが違いますね」と言えた。どこが違うのかを、少し指差せた。
ふと、自分の担当している在庫管理のコードを思い出した。入荷処理と返品処理、両方を書いたとき、同じバリデーションを2か所に書いた覚えがある。あれも、同じ話だろうか。まだ確認していないけど。
今は直さなくてもいい。でも次に触るとき、そこを見るものが変わった気がした。
