第1幕 異変——印字が 42.8t、床の合計が 45.6t
船倉の天井は高い。二段三段に積み上がったコンテナの間を、粉塵ランプの黄色い光が斜めに落ちてくる。頭上のダクトから微かな空調の風音、遠くの隔壁の向こうでフォークリフトが低い駆動音を立てている。俺が立っているのは積み込みホールの入口横、貨物区画への通路の脇に据え付けられた作業台の前だ。俺はダグ。この恒星間貨物船〈オリオール号〉の機関士だ。船暮らしは二十年を超えていて、機関室の計器の並びなら目を閉じても言える。だが今日は俺は機関室にいない。ラップトップも今朝から機関室に置いてきた。船倉ではチョーク書きと紙の印字で仕事をする。それが昔からの流儀だ。
作業台の上には、朝プリントアウトした積載表の印字を一枚広げている。船長の指示——「出航前チェックとして、積載表と現物合計を照合し、機関系の入力値として承認せよ」。〈オリオール号〉の船倉総重量は機関系の燃焼効率計算の入力値になる。積載総重量に加速プロファイルを掛けたのが必要推進エネルギー、その逆算が減速シーケンスと着床マヌーバのマージン計算に使われる。機関士が船倉に降りてくるのは、こういう時だ。
作業台の脇には通路の計量器がある。俺はここまでの一時間で、区画Aと区画Bの中身を一つずつ計量器に載せて、鉛筆で紙の余白に実測値を書き並べてきた。区画A、普通コンテナ二つ、各 10.0t、合計 20.0t。区画B-1、サブコンテナ 11.4t が二個で 22.8t。区画B-2、小分けパレット二枚、各 1.4t で 2.8t。B-2 のパレット二枚は、載せ終わったところで通路の脇にそのまま置いてある。積み込みの職員が最終位置に運ぶのを待っている段階だ。
俺は印字の総重量の欄を見た。42.8t。俺は紙の余白の実測合計を、鉛筆の先で追った。20.0 プラス 22.8 プラス 2.8 は、45.6。
差 2.8t。
俺は印字の紙を鉛筆で軽く叩いた。42.8 と 45.6。差 2.8。船倉に降りてくるのは久しぶりだ。機関室のコンソールから離れて、コンテナの重さを一つ一つ計量器に載せる仕事は、指と腕にくる。しかし、この現物と印字の照合は、機関系の入力値になる。積載総重量に加速プロファイルを掛けたのが必要推進エネルギー——ここが合わないと、減速シーケンスの着床マージンが狂う。差 2.8t の推進エネルギー誤差は、着床マージンで数十センチのズレになる可能性がある。人的被害はまだ発生していない。船体損傷もまだ発生していない。だが、もし出航前にこの照合をしていなかったら、明日の出航後に航行中の再計算で気づくことになる。気づいた時には、燃料を追加消費して着床マヌーバを補正するしかない。半年前の 0.3 光秒到着ズレのときと同じ後追いの構図だ。
現物は床にある。通路の計量器の脇には、B-2 の小分けパレットが二枚、載せ終わったところで置いたままになっている。各 1.4t、合わせて 2.8t。俺は目を上げて船倉の三層構造を見上げた。区画A、区画B——B は B-1 と B-2 に分かれていて、B-2 の中にはさらに小分けパレットが二枚入っている。深さ三の入れ子だ。印字の総重量は、B-2 の小分けパレット二枚を——というより、B-2 の中身を丸ごと——数え落としている。
俺は印字の裏に鉛筆で走り書きした。「深さ3で落ちる。今日書ける機関士は誰だ」。書きながら、俺は自分の記憶を辿った。コード側の TotalWeight サービスは半年前に書かれた。当時の運用は「深さ2 まで」、区画とサブコンテナの二階層で済んでいた。今回の航路で B-2 に小分けパレットが二枚入ったのは、荷主の梱包単位が変わったからだ。従来の運用では B-2 は他の区画と同じ Array の形で来ていたが、今航海からは B-2 だけが Hash 化されて、内側に「pallets」というキーで小分けパレットの Array を持つ構造になった。データ構造で言えば、B-2 の中身の階層が一段深くなった。集計コードから見れば、その一段が想定外だった。
俺は印字を作業台に置き直して、頭上を見上げた。粉塵ランプの黄色い光が、船倉の空気に舞う細かな粉塵を照らし出している。フォークリフトの駆動音が、遠くの隔壁でこもって反響してくる。
船倉の隅の階段から、ミラが降りてきた。両手にコーヒーの入った金属マグと、脇には航海用の桐箱を一つ抱えている。外側からは一つの箱に見えるが、〈オリオール号〉の乗員なら知っている——三段入れ子の桐箱だ。外箱・中箱・小箱の三つが、外箱の中に順に納まっている。ミラは主任航法士。俺の同僚で、変わり者の天才航法士として船内では知られている。手癖には長年慣れている。何かを両手に抱えて現れる日は、それがその日の話題に関係する日だ。
「ダグ、照合?」
俺は視線を印字から一度離して、ミラの手荷物に一瞬止めて、また印字に戻した。またか、しかも今日は組み合わせだな、と口の中だけで呟く。理由は本人がいずれ言う。俺から先に聞くことはしない。
「積載表と現物の照合。……B-2 の小分けパレット二枚、印字に載ってない。差 2.8t。というより、B-2 全体が印字から落ちてる」
ミラは作業台の空いた場所にマグを置き、桐箱を脇に立て掛けた。まだ箱を開けない。まずコーヒーを一口だけ飲んで、俺の印字と実測書き並べを目で追う。
「深さ3で落ちてる、って?」
俺は頷いた。
「そうだ。TotalWeight サービスが、区画とサブコンテナの二階層までは足す。だが今航海の B-2 は Hash 化された。中に “pallets” ってキーで小分けパレットの Array が入ってる形。……その Hash の中身は、TotalWeight の分岐に入らない」
ミラは少し、間を置いた。桐箱の縁に指を一本だけ触れて、また離した。
「集計コードを1段深くするなら、Hash の入れ子を一層余分に展開する行を書き足せば済む。……そのやり方でいい?」
俺は少し思って、口を開けた。集計コードの再帰ループを一段深くすればいい。素直に書けば動く。深さ3までは通るようになる。今回の B-2 も足せるようになる。……そのやり方でいいんじゃないか。
俺は自分の答えを、頭の中で反芻した。ミラは頷きも否定もしない。粉塵ランプの黄色い光が、閉じられた桐箱の木目を薄く照らしている。
「動く」
ミラは短く答えた。
「深さ4 が入るまでは」
俺は初めて視線を上げた。深さ4。今航海で B-2 が Hash 化された。次の航海か、その次の航海で、B-2 の中の何かがさらに Hash 化されないという保証は無い。荷主の梱包単位が変わるたびに、階層は一段ずつ深くなる可能性がある。深さ4 が入ったら?
「また1行書き足す」
ミラはコーヒーのマグを両手で包むように持ったまま、続けた。
「深さ5 が入ったらまた1行。深さが増えるたびに集計コードを触る」
俺は少し止まった。……そうなるな。
ミラはまだ桐箱には触れずに、コーヒーをもう一口飲んだ。声のトーンを少しだけ落として続けた。
「分岐で書き分けた書き手は、“この階層は単品、この階層はコンテナ” と正しく毎回判断していた。集計は動いていたし、深さ2 までは数字も合っていた。当時の運用は深さ2 まで、判断は毎回正しかった。……抜けていたのは、“どっちも同じ問いに答えられる” という発想だけ」
俺は口の中で反芻した。判断は正しかった。集計は動いていた。だが、「深さを利用側で数える」以外の道があるらしい。ミラの言い方だと、その道は「同じ問いに答える」で始まる。今の俺には、まだ具体は分からない。だが、深さが変わるたびに集計コードを触る羽目になるという未来だけは、はっきり見えた。
第2幕 解析——箱を開けたら、また箱があった
ミラは、脇に立て掛けた桐箱を作業台の中央に置いた。外箱の蓋を開ける。木の蓋がわずかにきしんで、中には麻布で包まれた大きな麻袋が一つ入っている。ミラは麻袋を取り出して、作業台の空いた場所に置いた。外箱の底に、次の箱——中箱の蓋が見えている。ミラは中箱を取り出して、外箱の脇に置いた。中箱の蓋を開ける。中には中サイズの麻袋が二つ。ミラはその二つを大麻袋の隣に並べた。さらに、中箱の底に見えた小箱を取り出す。小箱の蓋を開けると、小さな麻袋が三つ。ミラはその三つも横に並べた。
外・中・小、三段の開封。ミラの手が三回止まって、三回開けた。俺の視線は初めて、桐箱と並んだ麻袋の列に固定された。
麻袋の個数は、正直、現物の個数とは対応していない。船倉のコンテナ配置は区画A に普通コンテナ二個、B-1 にサブコンテナ二個、B-2 に小分けパレット二個だが、麻袋は大一・中二・小三。数の対応はない。ミラが見せているのは、大中小の三段の入れ子構造そのものだ。数ではなく、外側から順に開けていくと、内側にまた同じ形の入れ物がある、という繰り返し。船倉の三層構造——区画、小区画、さらに内側——と型の上で同じ。
ミラは作業台の上に並んだ六つの麻袋を指で示した。
「見えるでしょう。外箱の中に、中箱がある。中箱の中に、小箱がある。……同じ形の入れ子が、深さ3 で並んでる」
俺は独白した。外箱を開けると中箱が出てくる、中箱を開けると小箱が出てくる。それぞれの箱の中には麻袋が入っている。船倉の三層構造そのものだ。区画は Array、B-1 も Array、B-2 は Hash——だが、話の型で言えば、「箱の中に箱があって、その中にまた箱がある」だけだ。数ではなく、形。
ミラは麻袋を一つずつ手に取って、また箱に戻していく。小麻袋三つを小箱に、小箱を中箱に、中麻袋二つを中箱に、中箱を外箱に、大麻袋を外箱に。最後に外箱の蓋を閉じる。
「箱を開けたら、また箱があった。……深さ3」
俺は印字の裏の走り書きに視線を戻した。TotalWeight.compute の実装は、俺の頭の中に半分入っている。走り書きの脇に、コードのおおまかな形を鉛筆で書き並べた。
| |
「これが、深さ3で数え落ちる TotalWeight」
ミラは印字の裏に書かれたコードを目で追いながら、コーヒーのマグをまた両手で包んだ。
「Ruby は動的型付け。is_a?(Array)——is_a? はオブジェクトが指定クラスのインスタンスかを真偽で返すメソッド——で型を判定して、Array の中身を足す。……section が Hash なら、何もしない分岐に落ちる。深さ2 までなら通る。今航海の B-2 みたいに Hash 化された入れ子が入った瞬間に、B-2 全体が集計から漏れる」
動的型付け。俺は頭の中でリマインドを呟いた。型を宣言しない代わりに、「どんなオブジェクトが来るか」は利用側が実行時に判定する、というやつだ。航路計算機の一件でミラが言っていた、「だから模擬航走が生命線」の理由。今回は、実行時ですら鳴らない場合がある——分岐が「何もしない」側に落ちて、数字が静かに合わなくなる。エラーも例外も出ない。ただ、印字が 42.8t で、現物が 45.6t になる。俺が計量器の脇で気づかなかったら、そのまま出航していた。
ミラは麻袋を戻し終えた桐箱の蓋の上に、指を一本だけ置いた。声のトーンを、さらに一段下げて続けた。
「分岐で書き分けた書き手は、“この階層は単品、この階層はコンテナ” と正しく毎回判断していた。集計は動いていたし、深さ2 までは数字も合っていた。当時の運用は深さ2 まで、判断は毎回正しかった。抜けていたのは、“どっちも同じ問いに答えられる” という発想だけ」
俺は口の中で反芻した。判断は正しい。集計は動いていた。だが、「判断が要る」状態そのものを「要らない」状態に置き換える方向があるらしい。それを、ミラは「問いの側を一本にする」と言っている。判断する側と、答える側。今の Before は、判断する側——TotalWeight の利用側——が階層を数えている。ミラが指し示しているのは、答える側——葉と複合——が同じ問いに応じる形。判断が消える、というより、判断の担当が入れ替わる。
ミラは印字の裏に、新しい書き出しを始めた。TotalWeight.compute(実データ) と鉛筆で書いて、その横に矢印を引く。
「もし今この場でコードを回すなら、こうなる」
矢印の先に、期待値と返り値を書き並べる。
- 入力: B-2 が Hash 化された実データ
- 期待値: 45.6t
- 返り値: 42.8t(B-2 が Hash として “section が Hash なら何もしない” 分岐に落ちる)
「船倉にはラップトップも据え置き端末もない。実際にコードは走らせない。……でも、書いてある通りに読めば、返る値は見える」
俺は印字の裏の疑似コードを目で追いながら、頭の中で TotalWeight.compute の各行を一つずつ走らせた。A の Array 分岐で 20.0、B-1 の Array 分岐で 22.8、B-2 は Hash として「section が Hash なら何もしない」分岐に落ちる、合計 42.8。頷いた。
もし今この場で assert_equal 42.8, TotalWeight.compute(実データ) を回すなら、これで GREEN になる——「壊れることを再現できた」を GREEN で示す形だ。テストは「壊れた値」を期待値にしないと通らない。壊れることを、GREEN で記録しておく。現物合計 45.6t は床にある。コード側の返り値は、朝の印字が既に 42.8t を示している。この紙上の走りは、「あの印字が 42.8t で出た理由」を、ペンとインクで再現しているだけだ。
ミラは桐箱を作業台の端に押しやって、椅子から立ち上がった。作業台を離れて、貨物区画の入口横の黒板の前まで歩いていく。黒板にはチョークで積載表が書き込まれている——区画Aが縦の一段目、区画B-1 が二段目、区画B-2 が三段目。三層の縦並び。ミラはチョークを手に取らない、指も文字に触れない、視線だけを上から下に一度動かした。粉塵ランプの黄色い光が、黒板の白い文字を薄く照らしている。ミラの背中越しに、その静止が数秒続いた。
俺はその様を見ている。ミラは黒板に何も書き足さない、消しもしない、ただ視線を動かして、また作業台に戻ってくる。黒板を見る、というのが、たぶん何かの合図なんだろう。ミラは、答えを言う前に、いつも一度、こういう間を置く。
第3幕 引き直し——同じ口で答える相手を用意する
ミラは黒板の前から作業台に戻って、椅子に腰を落とした。ジャケットの内ポケットに手を入れて、二枚の厚紙を取り出す。手のひらサイズの厚紙、両方とも同じ書体で同じレイアウト、片方に「Cargo → weight」、もう片方に「CargoHold → weight」と黒インクで印字されている。書体もレイアウトも「→ weight」の位置も、完全に同じだ。ミラは二枚を作業台の中央に、「→ weight」の位置を縦に揃えて並べた。
「見えるでしょう。同じ口を持つ二枚のカード。片方は葉、もう片方は複合。だけど、どちらも同じ問い——weight——に答える」
俺は独白した。木箱は「入れ子」を見せた。カードは「同じ口」を見せる。二つの直観が、別々の装置で並んだ。
「引き直しを、見せる」
ミラは印字の裏の、まだ空いている面を鉛筆で区切って、After のコードを走り書きし始めた。
Composite(コンポジット)——個々の要素と要素の集まりを、同じ問いに答える同じ形の相手として扱う設計。葉(単品)と複合(コンテナ)に同じメソッド名を持たせて、利用側は「葉かコンテナか」を判定せず、同じ問いを投げるだけで済む。それが Composite の一行の骨だ、とミラは口頭で置いた。
そして、Before の側にも名前を付けておく、と続けた。
「Before の TotalWeight みたいに、利用側で is_a? を使って型ごとに分岐して手書き再帰を書くやり方——俺たちは “型コード分岐” って呼んでる。深さが変わるたびに分岐と再帰を触ることになる」
俺は独白した。「型コード分岐」——正式なパターン名として聞いたことはないが、ミラたちの現場ではそう呼んでいるらしい。名前を付けておくと、次に見たときに気づきやすい。俺の頭の中では、is_a?(Array) と is_a?(Hash) が並ぶ Before のコードに、その呼び名がタグとして貼り付いた。
ミラは After コードの走り書きを続けた。
| |
「葉は Cargo。名前と重さを持つだけの単品」
そして、複合の側。
| |
「複合は CargoHold。中身の配列 @items を持って、weight は中身に同じ問いを投げて足すだけ」
俺は頭の中で、船倉の三層を紙の上に置き直した。船倉——A、B——B-1、B-2——B-2 の中のパレット二枚。同じ口が、深さの分だけ並んでいる。

俺は目で追いながら、頭の中で足場を並べていった。以前、異径継手の話でも触れた——ダックタイピングは、Ruby が動的型付けだから成り立つ書き方だ。「同じメソッド名を持てば良い」というだけで、明示的なインターフェース宣言は要らない。今回で言えば、Cargo と CargoHold が同じ weight メソッドを持てば、利用側は両者を区別せずに weight を呼べる。明示的な Module Component; def weight; end; end みたいなインターフェース定義は書かない——書いてもいいが、Ruby の慣用では最初から書かない。必要になった時点で切り出せば済む。
@items.sum(&:weight) の一行に、俺は少し目を止めた。&:weight は、:weight というシンボルが自動的に { |item| item.weight } というブロックに変換される仕組み——正式名を言えば Symbol#to_proc だ。sum { |item| item.weight } の短縮記法として書ける。Ruby でよく見る書き方。ブロック引数の中で「各要素の同じメソッドを呼ぶ」だけなら、この短縮で書ける。
そして、もう一段——CargoHold の中には Cargo が入るだけじゃなく、CargoHold も入る。CargoHold が CargoHold を含むと、weight の呼び出しはどうなるのか。俺は頭の中で追った。
sum 自身が再帰的なわけじゃない。sum(&:weight) は各要素の .weight を呼ぶだけだ。ただ、その要素が CargoHold だったら、その CargoHold#weight の中でまた sum(&:weight) を呼ぶ——同じ形の呼び出しが連鎖するから、結果として深さの分だけ再帰的に評価される。魔法じゃない、同じ形の連鎖だ。sum に何か特別な機構があるのではなく、CargoHold#weight の実装が「中身に同じ問いを投げる」形に統一されているから、CargoHold を入れ子にした瞬間に、その入れ子の分だけ問いが下に降りていく。
具体的に絵を置いておくと、今日の船倉の3層構造なら、total.weight を呼んだ瞬間にこう連鎖する。total.weight(total = CargoHold.new("船倉"))は [section_a, section_b].sum(&:weight) を評価する。section_a.weight は [container_a1.weight, container_a2.weight].sum で 20.0——ここは Cargo の weight(属性を返すだけ)で止まる。section_b.weight は [section_b_1.weight, section_b_2.weight].sum を呼び、それぞれの CargoHold#weight の中でまた sum(&:weight) が起動して、最終的に pallet_1.weight と pallet_2.weight の 1.4 と 1.4 まで降りる。降りきって葉に触れたら、値が戻り値として上に積み上がっていく。sum が魔法で階層を辿るのではなく、同じ weight メソッドが階層の各段で自分の役目を果たすから、結果として深さの分だけ足し算が積み重なる。
<< の側も、俺は目で追った。hold << item は hold.<<(item) の糖衣構文で、<< は Ruby ではメソッドとして自由に定義できる。ミラの実装では @items << item で追加した後、self を返している。これは Ruby 標準の Array#<< と同じ慣用だ。self を返すと、hold << a << b みたいな連鎖書きが可能になる。今日の記事では連鎖はしない、が、実装だけ連鎖可能にしておく——そのあたりも、Ruby の書き方に馴染むための足場だ。
俺は疑問を持ち上げた。素朴に聞いておくか、と口の中で呟いてから、口を開けた。
「これ、CargoHold なんて作らずに、Array に入れて array.sum(&:weight) でよくないか」
ミラは木箱の外箱を指で軽く叩きながら、短く答えた。
「平坦な配列ならできる。でも、Array は “重さを尋ねる相手” にならない——some_array.weight は書けない。束そのものに問いを投げられない」
俺は独白した。問いを受ける相手が違う。Array は「中身をまとめる」入れ物、CargoHold は「問いを受ける」相手。同じ問いを投げるには、束の側が答え手にならないといけない。Array はできる、が、Array そのものに weight を尋ねることはできない。CargoHold は、その相手になる。
もう一つ、俺は疑問を持ち上げた。junior 詰まりの本命だ。
「なぜ is_a? で分岐しちゃダメなんだ。単品なら x.weight、コンテナなら中を回す、って、素直に書くのと何が違う」
is_a? は、あるオブジェクトが特定のクラスのインスタンスかどうかを判定する Ruby のメソッドだ。x.is_a?(Array) と書けば、x が Array のインスタンスかどうかが真偽値で返る。素直に書けば、Before のようなコードになる。動く。深さ2 までなら、今回の Before と同じで、数字も合う。それの何が悪い?
ミラは「同じ口」印字カード二枚に、指を一本だけ乗せた。カードは「→ weight」の位置で揃ったままだ。
「書ける。でも、書いた瞬間に、“深さを利用側で数える” 責任が利用側に戻ってくる。今回の Before がそう。is_a?(Array) で分岐、is_a?(Hash) で分岐、Hash の中身を一階層展開、深さ4 が入ったら一階層追加、深さ5 が入ったらもう一階層。……利用側が階層を数え続ける」
ミラはカードから指を離した。
「Composite だと、total.weight の1呼び出しで終わる。CargoHold の中に CargoHold を入れた時点で、自然に再帰する。深さは実装側では意識しない」
俺は独白した。利用側から is_a? が消える。階層カウントも消える。素直に書ける、が、その素直さは深さが変わるたびに触る素直さだ。同じ問いを一回投げて、答え手が階層を歩く方が、利用側は静かだ。判断する側と答える側の交代——ミラの言い方が、俺の頭の中で像を結んだ。判断の担当が、利用側から答え手側に移る。利用側は同じ問いを投げるだけで済む。
ミラは、追加で二点、junior 詰まりの足場を置いた。落とし穴、というより、書いた後に「あれ」となるポイントだ。
「一つ目。空の CargoHold の weight は 0。@items が空でも sum(&:weight) は 0 を返す。Enumerable#sum の既定値。……空でも壊れない。例外は出ない」
俺は独白した。空でも壊れない、というのは、sum の既定値が 0 だからだ。CargoHold 側で @items.empty? を判定して 0 を返す、みたいな分岐は要らない。空の CargoHold に対して weight を呼ぶと、[].sum(&:weight) が評価されて、Enumerable#sum の既定値 0.0 がそのまま返ってくる。使う側は「空かどうか」を気にしないでいい。
「二つ目。<< は self を返す。Ruby 標準の Array#<< と同じ慣用。hold << a << b の書き方が可能だが、今日のテストでは連鎖せず一つずつ書く。連鎖はできる、実演はしない」
俺は独白した。<< は self を返す、というのは Ruby の慣用だ。ここは覚えておく。連鎖を実演しないのは、話の主題が「連鎖書き」じゃないからだ。今日の主題は「葉と複合が同じ問いに答える」の一点で、連鎖書きはその主題の周辺にある。
俺は「同じ口」印字カード二枚を借りて、卓上に「→ weight」の位置を自分の指で揃え直した。カードの端が作業台の木目と平行になるように、二枚を並べる。次に、閉じられた入れ子木箱三段を、自分の手で外・中・小と順に開けた。外箱の蓋を持ち上げる、大麻袋を取り出して脇に置く、中箱を取り出す、中箱の蓋を持ち上げる、中麻袋二つを取り出して並べる、小箱を取り出す、小箱の蓋を持ち上げる、小麻袋三つを並べる。全部並べ終えた後、俺は指を一本だけ立てて、口の中で「weight」と、一語だけ言った。
カードは揃っている。麻袋は並んでいる。俺の指は立っている。口は「weight」と一語だけ言った。ミラは口を挟まず、俺がカードを揃え終わり、木箱を開け終わり、指を立てるまで、卓の傍らで待っていた。
俺は独白した。同じ口を持つ相手に、同じ問いを一回投げる。答え手が階層を歩く。……「weight」と一語言えば済む。単品にも、複合にも、深さがいくつでも。
第4幕 模擬航走——同じ問いで、深さが変わっても答えが返る
ミラは「同じ口」印字カード二枚を「→ weight」の位置で揃えたまま、卓の中央に残した。指を一本だけ、カードの上に乗せる。
「保証を見る前に、1つだけ」
ミラはコーヒーのマグを、もう一口だけ飲んだ。
「Array は “中身をまとめる” 入れ物、CargoHold は “問いを受ける” 相手。同じ集計でも、“束が答え手になるか、ならないか” が違う」
俺は頷いた。Act3 で片付けた Array 対比の一線を、Act4 の保証議論の入口にもう一度置き直しておく——後で読み返したときに、この一線が問いと答えの構造そのものを分けていたことが分かる形で。
ミラは印字の裏面を鉛筆で縦に半分に区切って、左右に疑似コードとその返り値を書き並べ始めた。船倉にはラップトップは無い。実際に回すのは戻ってからでいい、今ここでは、書いてある通りに読めば返る値が見える、というのがミラの流儀だ。
左半分(Before・分岐+手書き再帰)。
TotalWeight.compute(cargo_data_all_flat) を呼ぶ場合。B-2 を Hash 化せず Array のまま渡す仮想データ——A、B-1、B-2 を全て Array として持たせて、合計は 45.6t。この場合、Before の実装は section が Array の分岐だけを通って、全部足す。返り値は 45.6t。「深さ2 の平坦データでは通る」ことが、この一本で示せる。テストとしては assert_in_delta 45.6, TotalWeight.compute(all_flat_data) で GREEN。
次に、TotalWeight.compute(cargo_data_with_depth_3) を呼ぶ場合。今航海の実データ——B-2 が Hash 化・深さ3——を渡す。この場合、Before の実装は A の Array 分岐で 20.0、B-1 の Array 分岐で 22.8、B-2 は Hash として「section が Hash なら何もしない」分岐に落ちる。返り値は 42.8t。期待値は本当は 45.6t だが、Before の実装が壊れているから 42.8t が返る。テストとしては assert_in_delta 42.8, TotalWeight.compute(depth_3_data) で GREEN——「壊れることを再現できた」を GREEN で示す形だ。
右半分(After・Composite・1本のテスト内で動的階層追加)。
ミラは右半分に、After のテストコードを鉛筆で書き並べた。
| |
assert_in_delta は Minitest の assertion で、assert_equal の代わりに「許容誤差の範囲内で等しい」ことを確認する。今日の値のように Float の合計を検証する場合、10.0 + 10.0 + 11.4 + 11.4 + 1.4 + 1.4 の合計は Ruby の Float 演算では厳密に 45.6 にはならない——45.599999999999994 みたいな値が返ってくる。丸め誤差のせいだ。assert_equal 45.6, ... だと通らない。assert_in_delta に置き換えると、既定の許容誤差の中で「実質的に等しい」を判定してくれる。Ruby でよくある小技だ。
俺はミラが書き並べたコードを、上から一行ずつ目で追った。total = CargoHold.new("船倉") で最上位の複合を作る。区画A を組み立てて total << section_a で追加、区画B を組み立てて total << section_b。区画B の中に B-1 と B-2 を入れて、B-2 の中に小分けパレット二枚を入れる。ここまでで深さ3 の入れ子——最上位「船倉」の中に「B」があって、「B」の中に「B-2」があって、「B-2」の中に「pallet_1」と「pallet_2」がある——が組み上がる。
assert_in_delta 45.6, total.weight の一行が、深さ3 で通ることを確認する。total.weight は @items.sum(&:weight) を呼ぶ。@items は [section_a, section_b]。それぞれに weight を尋ねると、section_a.weight は [container_a1.weight, container_a2.weight].sum で 20.0。section_b.weight は [section_b_1.weight, section_b_2.weight].sum を呼び、section_b_1.weight は 22.8、section_b_2.weight は 2.8、合計 25.6。total.weight は 20.0 + 25.6 = 45.6。同じ形の呼び出しが、深さの分だけ連鎖して降りていく。
次の一行——section_b_2 << Cargo.new("追加パレット", 1.4)。既存の B-2 相当の CargoHold に、<< で Cargo を一つ追加する。この一行で、B-2 の中身が二枚から三枚になった。……そして、その次の assert_in_delta 47.0, total.weight。
俺は独白した。集計コードは一行も触っていない。total.weight の呼び出しは、まったく同じ形で書かれている。だが、返り値は 45.6 から 47.0 に変わる。追加された Cargo の 1.4t が、section_b_2 の中で sum(&:weight) に拾われて、section_b_2.weight が 2.8 から 4.2 になる。section_b.weight が 25.6 から 27.0 になる。total.weight が 45.6 から 47.0 になる。同じ形の連鎖が、一段深いところで一つ増えた要素を、自然に拾い上げる。
俺は左半分と右半分の並びを、しばらく見た。左は「深さ2 の平坦データで通る/深さ3 で数え落ちる」を並べた。右は「深さ3 で通る/同じテストの続きで深さを組み替える/集計コードは触らない/それでも通る」を並べた。左は「壊れることを再現できた」の GREEN、右は「同じ問いで深さが変わっても答えが返る」の GREEN。同じ緑色でも、意味の重心が違う。左は「静かな数え落とし」、右は「同じ問いの連鎖」。
俺は左右の対比を、頭の中で一枚の紙に並べた。判断する側が階層を数える形と、答え手が階層を歩く形。同じ問題を、口の置き所で解いている。

俺は少し思って、口を開けた。junior 詰まりを、もう一つだけ代弁しておく。
「Ruby では Component みたいなインターフェース、明示的に作らなくていいのか。他の言語だと必ず interface Component を書く気がする」
ミラは頷いた。
「Ruby では最初から作らなくていい。ダックタイピングだから、weight を持てば良い。……必要になった時点で Module Component; end に切り出して include できる。後からでも困らない。他の言語(Java や TypeScript など)は先に interface を書かないと型が通らないが、Ruby はそれが要らない」
俺は独白した。明示 I/F は必須じゃない、必要になったら切り出す、というのが Ruby の慣用。今の Cargo と CargoHold はダックで十分。もし将来、Cargo と CargoHold に加えて第三の葉クラス(例えば Pallet みたいな、パレット単位の重量を持つ別の葉)が増えて、それらを「同じ問いに答える相手」として明示的に括りたくなったら、そのときに Module Component; def weight; raise NotImplementedError; end; end を作って、各クラスに include Component を足せばいい。事前に構造を用意しなくていい、というのは、動的型付けの Ruby ならではの身軽さだ。
「もう一つだけ、線を引いておく」
ミラはカードの上の指を、今度は「Cargo → weight」の側から「CargoHold → weight」の側に移した。
「Composite は “統一 I/F で任意深さを扱える”。でも、“重さを尋ねる問い(weight)と、中身を組み替える操作(<<)は別の口” ——<< は CargoHold にだけ定義した、Cargo には定義していない。全部の口を Component I/F に混ぜると、葉クラスに無意味な口が出る。……問いと操作は、別の口に分ける」
俺は独白した。Component I/F を作るとしても、そこに置くのは weight——「問い」の側だけ。<< は複合の側の話——「中身を組み替える操作」で、葉には要らない。もし全部を一緒くたに Component I/F に入れると、Cargo#<< みたいな「葉に中身を足す」意味不明のメソッドを実装する羽目になる。あるいは実装せずに例外を投げるか。どちらも、Composite の効能を薄める方向だ。問いと操作は、別のレイヤ。今日の実装では、weight はダックで共通、<< は CargoHold 専用。それが自然な切り分けだ。
俺はここで、実際にコードを回した結果を、ミラの紙上検証と突き合わせておきたくなった。ミラは、俺の視線を読んだかのように、コーヒーを飲み干して、マグを作業台の脇に置いた。
「戻ったら回してくれ。……テストの結果は、俺が予想した通りになると思うが」
俺は頷いた。もちろん、戻ったら回す。実際の Minitest の実行結果は、紙上の検証と一致すべきだ。もし一致しなかったら、それは俺かミラの読み違い。頭の中で書いたシミュレータと、実機の Ruby ランタイムの間にズレがあることになる。そういう時のために、模擬航走がある。
——出航後、機関室に戻ってからテストを走らせた結果は、こうだ。
| |
六本のテスト、七つの assertion、全て GREEN。Before の「深さ2 の平坦データで通る」も「深さ3 で数え落ちる(assert_in_delta 42.8 で GREEN)」も、After の「深さ3 で通る」も「同じテスト内で << して深さを組み替えても集計コード無改変で通る」も、空 CargoHold の weight が 0.0 も、Array を平坦で sum(&:weight) した対比も、全部通った。紙上の検証と、実機の実行結果は、一致した。
俺は、少し思って、口を開けた。
「これで、深さがもっと増えても、集計コードは触らなくていいのか」
ミラは頷いた。
「触らない。CargoHold の中に CargoHold を入れた時点で、自然に再帰する。……深さ変更で集計コードの改変回数は0」
俺は独白した。集計コードの改変回数、ゼロ。深さ2 から深さ3 に増えた瞬間に、Before は一行足す羽目になった。次に深さ4 が入ったら、また一行足す。深さ5 が入ったら、もう一行。After は、CargoHold を一つ追加するだけで、集計コードは無改変。「深さを利用側で数える」責任が、利用側から消えた。行数の diff じゃない。同じ問いで、深さが変わっても答えが返る。集計コードは、俺が書き足す場所が、無い。
ミラは「同じ口」印字カード二枚を「→ weight」の位置で揃えたまま、卓の中央に残した。次に、入れ子木箱を、外箱・中箱・小箱の順で閉じ直していく。小箱の蓋を閉じて、中箱の中に納める。中箱の蓋を閉じて、外箱の中に納める。外箱の蓋を閉じる。桐箱は、また外側からは一つに見える形に戻った。
ミラの声は静かだった。
「同じ顔で答えれば、深さは歩けるの」
俺は独白した。「歩く」ものは、「同じ顔で答える」相手に投げれば、深さの分だけ答え手が歩いてくれる。利用側は一回問いを投げるだけで済む。……そういう形が、葉と複合の両方に、同じ口として置いてある。
船倉の粉塵ランプが、閉じられた桐箱と、揃えて置かれた「同じ口」カード二枚と、書き込みだらけの積載表印字を、薄く照らしている。フォークリフトの駆動音は、遠くの隔壁でまだ低く反響している。俺は印字を作業台から取り上げて、裏面の走り書きに一度目を通した。「深さ3で落ちる。今日書ける機関士は誰だ」の下に、いつの間にか鉛筆でもう一行、書き足していた。「同じ口で答える相手を、用意する」。
積み込み最終確認に戻る。次の便の積荷リストは明日届く。
🚀 オリオール号 航海日誌(Navigator’s Log)
- 航法術(パターン名): Composite(コンポジット)——個々の要素と要素の集まりを、同じ問いに答える同じ形の相手として扱う設計。葉(単品)と複合(コンテナ)に同じメソッド名を持たせて、利用側は「葉かコンテナか」を判定せず、同じ問いを投げるだけで済む。Ruby ではダックタイピングと
Enumerable#sum(&:weight)の連鎖で最小コストで書ける - 発生事象(症状): 船倉での出航前現物照合中、印字の総重量 42.8t と現物合計 45.6t の差 2.8t を目視で発見。今航海で B-2 が Hash 化されて内側に「pallets」キーの Array を持つ深さ3 の入れ子構造になり、
TotalWeightサービスが B-2 全体を「section が Hash なら何もしない」分岐に落として丸ごと数え落としていた - 原因(旧航路の問題): 型コード分岐(
type-code-branching)——利用側でis_a?(Array)の型判定を使って手書きの再帰ループを書く構造。当時の設計者は「深さ2 まで」の運用前提で書いており、判断は毎回正しかったが、深さが一段増えた瞬間に集計コードが数え落とすようになった。深さ変更のたびに利用側の分岐と再帰を触る必要がある - 処置(引き直しの要点): 葉
Cargoと複合CargoHoldに同じweightメソッドを持たせるダックタイピングで統一。CargoHold#weightは@items.sum(&:weight)の一行で、CargoHoldの中にCargoHoldを入れた時点で自然に再帰する。利用側はtotal.weightの一呼び出しで任意深さを集計、is_a?分岐も階層カウントも消える。<<はselfを返して連鎖可能(Ruby 標準のArray#<<と同じ慣用) - 保証外事項: Composite は「統一 I/F で任意深さを扱える」が、「重さを尋ねる問い(
weight)と、中身を組み替える操作(<<)は別の口」として切り分ける必要がある——<<を Component I/F に混ぜると葉クラスに無意味な口が出る(LSP=リスコフの置換原則、派生型は基底型として振る舞えるべき、の違反懸念)。また、Arrayとは「問いを受ける相手」の有無が違い、Arrayは「中身をまとめる」入れ物、CargoHoldは「問いを受ける」相手、で使い分ける。明示的なModule Componentは Ruby では必須ではなく、最初はダックで開始、必要になった時点で切り出せる。循環参照(CargoHoldが自分自身を含む)の防御は本記事のスコープ外 - 模擬航走結果: Before の
TotalWeight.computeは平坦データで 45.6t を返し GREEN、深さ3 の実データで 42.8t を返しassert_in_delta 42.8として GREEN(「壊れることを再現できた」を GREEN で示す)。After のtotal.weightは深さ3 で 45.6t を返し GREEN、同じテスト内で B-2 相当のCargoHoldに<<でCargoを1点追加して深さを組み替えても集計コードは無改変でtotal.weightが 47.0t を返し GREEN——集計コードの改変回数は0 - 次の針路: 積み込み最終確認に戻る。次の便の積荷リストは明日届く
