夜の仕込みの時間は、昼とは匂いが違う。
営業前の厨房は、出汁の湯気が一日でいちばん濃くなる。私はその端で、シェフが宴会の予約用に盛り合わせを組むのを見ていた。小鉢をいくつか大皿に並べ、その大皿を、さらに大きな漆の盆に乗せていく。器の中に器、その盆をまた別の盆に重ねていく。両手で持つと、見た目より重い。
器の中に、器がある。
それを眺めていて、ふと前のことを思い出した。先日、ここで「変換役を一か所に」と教わった日の夜、私は自分の案件のコード——在庫管理ツール——を少しだけ覗いた。外部APIのレスポンスを取り出している箇所が、三か所に散らばっていた。直し方はまだわからない。あの日は、見つけただけで終わった。
そんなことを考えていると、引き戸が開いた。
本日の持ち込み
入ってきたのは、二十代後半くらいの男性だった。少し急いだ足取りで、入り口で一度立ち止まってから「すみません、ネットで見て——設計の相談に乗ってもらえると書いてあって」と早口で言った。声に、疲れと焦りのようなものが混じっている。
シェフは盆を静かに置いて、手を拭いた。「座れ。何を持ち込んだ」
彼はカウンターにノートPCを開きながら、堰を切ったように話し始めた。「飲食店の注文システムを作ってるんです。単品とコースの料金計算で——コースの中にコースが入ると、合計が合わなくなって。先週、お客さんに安く請求しちゃってたのが見つかって」
私は自分のPCを脇に避けて、彼のために場所を作った。画面に、こんなコードが映っている。
| |
「注文の一品ずつを、こういうハッシュで表してて」と彼は続けて、データの例を見せた。
| |
「単品は値段をそのまま返す。コースは中の品を全部足す。最初はこれで十分だったんです。total($lunch) で、ちゃんと 1600 円になる」
私にはコードの細かいところは読めない。でも「単品」と「コース」という言葉は、さっきまでシェフが組んでいた盛り合わせと重なって見えた。小鉢が単品で、大皿がコース。そういうことだろうか。
「問題は、これなんです」と彼はもう一つのデータを開いた。
| |
「宴会プランの中に、ドリンクセットっていう小さなコースを入れたくて。total($banquet) を呼ぶと——」
彼は実際にそれを動かして、画面に数字を出した。1200。
「本当は、主菜 1200 とビール 600 とワイン 800 で、2600 円のはずなんです。なのに 1200 円。ドリンクセットの 1400 円分が、まるごと消えてる」彼は頭を抱えた。「これで一週間、お客さんに安く請求してました」
子は単品とは限らない
シェフはしばらく画面を見てから、total のコースの部分を指でなぞった。
「ここだ。コースの合計、子を一つずつ足してる。だがこの足し算、『子は単品だ』って決めてかかってる」
| |
「単品なら {price} に値段が入ってる。だが、子がコースだったら? コースのハッシュに {price} なんてキーはない。undef だ。undef を足しても、何も増えない」
彼が「あ……」と小さく声を出した。「だから、ドリンクセットの分が 0 円扱いに」
私には、なぜ 0 円になるのかまではわからなかった。でもシェフが盆を一つ持ち上げたとき、急にわかった気がした。
「この盆、いくらだ? って聞かれたら、お前さんどうする」とシェフが彼に聞いた。
「乗ってるものを、全部足します」
「乗ってるのが小鉢なら、値段が付いてる。だが、大皿が乗ってたら? その大皿だって、中身を足さなきゃ値段は出ないだろう」シェフは盆の上の大皿を指した。「お前さんのコードは、大皿が乗ってる場合を、見なかったことにしてる。大皿には値札が貼ってないから、0 円だと思ってる」
彼が黙ってうなずいた。
これが今回の問題の名前だ。
nested-conditionals(入れ子の場合分け): 個(単品)と集合(コース)を type で if 分岐し、集合の中に集合が入るたびに分岐をネストさせる必要が出る設計。種類や階層が増えるほど分岐が増殖し、ある深さで必ず破綻する。
「それで、僕、こう直したんです」と彼は別のコードを見せた。コースのループの中に、もう一段 if を足したものだった。
| |
「これで宴会プランは 2600 円になりました。直った、と思ったんです」彼の声がまた沈む。「でも今度は、宴会プランをさらに別のプランに入れたとき——コースの中のコースの中のコース——でまた合わなくなって。1700 円とか、変な数字が出るんです」
「そうだろうな」とシェフは言った。怒ってはいない。「一段深くなるたびに、if を一段足す。盆の中に盆、その中にまた盆——書ききれるか? 終わりがないぞ」
彼が「もう、何段ネストすればいいのか分からなくなって」と、自嘲するように笑った。
私は片付けの手を止めていた。「単品とコースを if で分けてる」——その言葉が、自分のコードの、別の場所を呼び起こしたからだ。
前に見つけた「散らばり」とは違う。あれは、同じ言葉が三か所にあった話。でも今、彼が言っているのは、「単品か、セットか」で処理を分けている話だ。私の在庫ツールにも、確か——セット商品の在庫金額を出すところで、単品商品かセット商品かを if で分けていた。そういえば、セットの中にセットを入れたとき、数が合わなかったことがあった。
形が違う。これは「場合分け」の問題だ。それが見分けられた自分に、少し驚いた。でも今は、彼の話に集中しよう。
同じ問いに答えられればいい
「単品もコースも、同じ問いに答えられるようにすればいい」とシェフが言って、コードを書き始めた。
「同じ問い、ですか」と彼が聞き返す。
「『お前さん、いくらだ』だ。単品だろうがコースだろうが、それに答えられること——それだけを約束させる」
シェフはまず、こう書いた。
| |
「これが『同じ顔』だ」とシェフは言った。
Composite(コンポジット): 個(単品)と集合(コース)を同じインターフェースで扱い、集合の合計を子へ再帰的に委譲する技法。GoF の構造パターンのひとつで、部分と全体をひとまとめに扱う。その共通の顔を定義するのが Component(コンポーネント)——ここでは price を持つことだけを要求する MenuComponent という Role(役割)だ。
私はその requires 'price' という一行を見て、シェフに聞いてみた。「それは……『いくらか答えられないやつは、仲間に入れない』ということですか?」
「そんなとこだ」とシェフは短く返した。「Perl は、メソッドがあるかどうかを後から確かめる言葉だ。この requires は、MenuComponent を名乗るクラスを読み込んだその瞬間に、『お前 price 持ってるか?』と確かめる。持ってなきゃ、その場で止める」
次に、単品。
| |
「単品は、price っていう値をそのまま持ってる。それがそのまま答えになる」とシェフは言った。
これが Leaf(リーフ・葉)——子を持たない末端だ。
「一つ、気をつけることがある」とシェフが続けた。「単品のほうは、price を書く行を with より先に置け。with ってのは『price を持ってるか』をその場で確かめる処理だ。先に確かめると、まだ price が用意できてなくて、無いと言って怒られる。コースのほうは price を sub で書くから先に用意される。あっちは気にしなくていい」
そして、コース。
| |
これが Composite(枝)——子を持ち、計算を子へ委ねる側だ。シェフは price の中の一行を指した。
| |
「ここを見ろ。コースは、自分の中身に『お前いくらだ』と聞いて、足してるだけだ。中身が単品なら、値段を答える。中身がコースなら——そいつがまた、自分の中身に同じことを聞いて、足して、答える」
シェフは盆を持ち上げて、乗っている小鉢と大皿に順番に指を当てた。「この小鉢、いくらだ。この大皿、いくらだ——大皿は中の小鉢に聞いて、足して答える。同じ問いを、どの段にも投げる。盆は、乗ってるのが小鉢か大皿かを、気にしない。ただ『いくらだ』と聞くだけだ」
構造を絵にすると、こうなる。
classDiagram
class MenuComponent {
<<Role>>
+price()
}
class SingleItem {
+name
+price
}
class Course {
+name
+children
+add(component)
+price()
}
MenuComponent <|.. SingleItem : with
MenuComponent <|.. Course : with
Course o-- MenuComponent : children
Course が抱える children は MenuComponent——つまり単品でも、別のコースでもいい。だから入れ子は、構造そのものに織り込まれている。
組み立て方は、こうなる。
| |
呼び出し側は、ただ $banquet->price と書くだけ。単品でも、コースでも、入れ子の宴会プランでも、書き方は同じ。if は一つもない。
「2600、出ました」と彼が画面を見て言った。「if を、一つも書いてないのに」
ただ「いくらだ」と聞くだけ
彼はしばらく画面を見つめていた。それから、少し言いにくそうに口を開いた。
「……でも」と彼は言った。「これ、結局 Course の中で子を for で回して足してますよね。さっきの if を for に書き換えただけ、というか……正直に言うと、ハッシュのまま、こういう再帰関数を書いても同じことができる気がするんです」
彼は、こう打ち込んだ。
| |
「これでも、入れ子は何段でも合うはずで。今までも、書き換えるたびに『直った』と思って、また壊れたんです。これ、本当に違うんですか?」
不信が、声に滲んでいた。私も、その気持ちは少しわかる気がした。見た目は、確かに似ている。
「いい疑いだ」とシェフは言った。手を止めて、彼のほうを向く。「入れ子の合計を出すだけなら、お前さんの言う通りだ。その再帰でも合う。そこは認める」
「じゃあ……」
「だが、新しいメニューを足してみろ」とシェフは遮った。「『割引セット』だ。中身を合計して、そこから一割引く。さあ——その再帰関数なら、どこを直す?」
彼は少し考えて、答えた。「total の中の if に、もう一本……『割引セットなら、合計して 0.9 を掛ける』っていう枝を足します」
「そうだ。料金だけならな」とシェフは言った。「だが、お品書きを画面に出す処理を作ったら、どうする? 品数を数える処理は? ツリーをたどる処理を作るたびに、その全部に同じ枝を足すことになる。種類が三つ、処理が三つあれば、if の枝は九本だ。種類が増えるたびに、全部の処理を開いて回ることになる」
彼の手が止まった。
「Composite なら——」シェフは新しいコードを一つだけ書いた。
| |
「クラスを一枚足すだけだ。Course から、子を持つ仕組み(children と add)はそのまま受け継ぐ。price だけ『合計してから一割引く』に差し替える。around っていうのは、元の price を一回呼んで、その結果に手を加える書き方だ」
シェフは盆の上に、赤い札のついた新しい大皿を乗せた。「これは『合計してから一割引く皿』だ。だが——Course の price も、呼び出し側の ->price も、一文字も変えてない。盆は、これが割引セットかどうかなんて聞かない。ただ『いくらだ』と聞くだけだからな」
私は、その赤い札の皿が盆に乗ったのを見て、思わず口に出していた。
「つまり……盆は、乗ってるのが単品か、盛り合わせか、割引セットか——気にしてないんですね。ただ『あんた、いくら?』って聞くだけ。だから新しい料理が増えても、盆は聞き方を変えなくていい?」
シェフがちらりとこちらを見た。「そういうことだ」。短い返事だったけれど、この前のときより、少し長く目が合った気がした。
私の中で、何かがつながった。「同じ顔」というのは、見た目が同じということじゃない。同じ問いに答えられる、ということだ。盆に乗れる資格は、ただ一つ——「『いくら?』に答えられること」。それさえあれば、単品でも、コースでも、割引セットでも、盆は同じように扱える。
新しい種類のメニューを足したいなら、MenuComponent を名乗って price を実装したクラスを、一枚足せばいい。盆の側(Course と呼び出し側)は、何も知らないまま、今まで通り動く。これが OCP(開放閉鎖原則)——機能を足すときに、既存のコードを変えずに、新しいクラスを足すだけで済むようにする、という設計の指針だ。
彼は画面を見つめたまま、ゆっくりと言葉を選んでいた。「……今までは、total が全部の種類を知ってなきゃいけなかった。種類が増えるたびに、total を開いて、if を足してた。これは逆ですね。種類のほうが、『自分の値段の出し方』を自分で持ってる。total は、もう知らなくていい」
「飲み込みが早いな」とシェフが言った。彼の硬かった表情が、少しほどけたように見えた。
そういえば、と私は思った。この前ここに来た人は、料理を一枚ずつ包んで、味を足していた(あれは Decorator というらしい)。あれは、一つの皿に一枚ずつ重ねる話だった。今回は、一つの器が、何品も抱える話だ。トッピングは縦に積む。コースは横に並べる。包む向きが違う。
試食合格
コードを書き直して、テストを走らせた。
| |
コースの中のコースの中のコースでも、if を一本も足さずに、正しい合計が出た。
「これなら」と彼が言った。今度は自分から先を続けた。「次に『朝食セット付きプラン』とか言われても、total を触らずに済みますね。クラスを一枚足すだけで」
シェフは小さくうなずいてから、付け加えた。
「献立は、いくらでも深くしていい。器が中身に『いくらだ』と聞ければ、何段重ねても崩れやしない」
「同じ問いに答えられればいい、ですか」と彼は繰り返した。「……僕はずっと、total を賢くしようとしてました。全部の場合を、一か所で見分けようとして。逆だったんですね。料理のほうに、答えさせればよかった」
そう言って、彼は立ち上がった。来たときより、足取りが軽い。もう頭の中で書き直しているのだろう、礼の言葉は短かった。
引き戸が閉まる音がした。
片付けをしながら、シェフの言葉を反芻した。器が中身に「いくらだ」と聞く。中身がまた、自分の中身に聞く。同じ問いが、どこまでも下りていく。
それで——自分のコードのことが、戻ってきた。この前見つけた「散らばり」とは別の、あのセット商品の if。
閉店後の静かな食堂で、私はノートPCを開いた。在庫管理ツールのコード。セット商品の在庫金額を出している箇所を探すと、あった。
| |
単品商品とセット商品で、金額の出し方を if で分けている。セットの中にセットを入れたとき数が合わなかったのは、これだ。彼のコードと、同じ形をしている。
でも今日は、見つけただけでは終わらなかった。
単品もセットも、同じ顔にすればいい。「いくら?」に答えられるようにすれば、if で分けなくていい——直し方の方針が、自分の言葉で出てきた。
書けるかどうかは、まだ自信がない。でも、何を直せばいいかは見えた。この前の夜、ここで止まっていた場所より、一歩だけ前に来た。
シェフの仕込み工程表
| 問題(調理ミス) | 技法(パターン) | 効果(仕上がり) |
|---|---|---|
単品とコースを type で if 分岐し、コースの中にコースで合計が壊れる | Composite(同じ顔の MenuComponent Role) | 入れ子が何段でも、if を足さずに再帰的に合計できる |
新しい種類を足すたびに、ツリーをたどる処理全部に if の枝が増える | 各種類が自分の price を自分のクラスに持つ | 新種は「Role を with したクラス」を一枚足すだけ。既存コードは無変更(OCP) |
| 合計処理が全種類を見分ける責任を背負っている | 子に ->price を聞いて委譲する | 呼び出し側は型を知らない。「いくらだ」と聞くだけ |
工程
Step 1: 共通の顔(Component)を Role で定義する
単品も集合も共通で持つべき操作を Moo::Role で宣言する。requires で「この Role を with するクラスは、必ずこのメソッドを持つこと」を強制する。
| |
Step 2: 末端(Leaf)を作る
子を持たない単品。price を属性で持ち、その値がそのまま答えになる。has price を with より先に置くこと(アクセサが with の時点で必要なため)。
| |
Step 3: 集合(Composite)を作る
子のリストを持ち、price は子の ->price を足すだけにする。ここで子の型を見ない——これが入れ子に強い理由だ。add のような子を管理するメソッドは、集合の側だけに置く(単品に add を持たせない)。
| |
Step 4: 呼び出し側は ->price を呼ぶだけにする
単品か集合かを if で見分けない。同じ ->price で、単品でも入れ子でも合計が出る。
Step 5: 新しい種類はクラスを一枚足すだけで拡張する
割引セットのような新種は、MenuComponent を with(または Course を extends)した新しいクラスを足すだけ。Course も呼び出し側も変更しない。
シェフより
賢い total を一つ作って、全部の場合をそこで見分けようとすると、種類や入れ子が増えるたびに、その一か所がどんどん太っていく。どこかで必ず追いつかなくなる。
逆だ。料理のそれぞれに、「自分はいくらか」を自分で答えさせろ。器は、中身が何かを知らなくていい。ただ「いくらだ」と聞いて、返ってきた数を足す。中身がまた器なら、そいつが同じことをするだけだ。これなら、何段深くなっても、新しい料理が増えても、聞き方は変わらない。
一つ正直に言っておく。if がこの世から消えるわけじゃない。「今日はどの料理を作るか」を決めて器に盛り付けるとき——どのクラスを new するか——の判断は、組み立てる場所に残る。消えたのは、でき上がった料理をたどって合計する処理の中の、種類による場合分けだ。それが各料理の側に分かれて、たどる側は型を意識しなくなった。場合分けの居場所が変わった——それが、今日の仕込みだ。
