第1幕 異変——届いてしまった後の話
貨物区画は、機関室ほどではないが俺の持ち場のうちだ。温度管理槽の巡回点検、緩衝材の在庫確認、荷締めベルトの張力チェック——航海中の手空きは、たいていこの区画のどこかで潰している。区画は棚ごとに温度が違う。保冷棚の前を通ると空気がひやりと変わり、常温棚に戻るとまた元の生ぬるさに戻る。その境目を跨ぎながら歩くのが、この持ち場の癖みたいなものだ。
今日もそんな日だった。次の寄港地まではまだ日がある巡航中で、俺は積み込んだばかりの空コンテナを一つずつ点検していた。船が静かに巡航しているときの貨物区画は、機関室よりよっぽど静かだ。聞こえるのは換気扇の低い唸りと、たまに棚がわずかに軋む音くらい。
星図室にいるはずの航法士が、貨物区画にいた。
ミラだ。うちの船の航路を一手に引いている変わり者で、いる場所はだいたい決まっている——星図室の床。それが今日は積み荷台帳の投影を睨みながら、次の寄港地の積み荷配分を確認しに来ているのだという。用件はそれだけ聞けば普通のものだ。
気になったのは、その傍らでミラがやっていることのほうだった。
手近にあった工具箱を、荷造り用の布で几帳面に包んでいる。梱包の必要なんてどこにもない、ただの工具箱だ。布の端を折り込み、角を合わせ、余った分を裏へ回して留める——手つきだけ見れば、丁寧な仕事にしか見えない。しかもこの人、よく見ればこういうことをしょっちゅうやっている。私物の端末、乾パンの缶、椅子の背もたれ——手が空くと何かに布を巻きたがる癖が、うちの航法士にはある。今更のことでもない。俺は「いつものことだ」と流して、コンテナの点検に戻った。
そこへ、通信端末が鳴った。
前回寄港したリゲル外郭ステーション経由、コロナ光学からの通信だった。コロナ光学は精密観測機器のメーカーで、うちとは今航海から取引が始まったばかりの上得意先だ。荷主が増えるのは喜ばしい話のはずが、通信の中身は喜ばしくなかった。
発注した光学センサーユニット一式が、目的地で破損して届いたという。荷を開けたら、レンズ系が振動でやられていた。発注時の指定は「保冷・防振・保険」の3点セット。にもかかわらず届いた荷は、防振の扱いを受けていない状態だったらしい。
始まったばかりの取引先だ。最初の一件でこれをやらかすと、次の発注が来るかどうかも怪しくなる。通信を読み終えて、そのことがまず胃の底に落ちた。
俺とミラは、その場で一緒に通信を聞いた。
「発送のとき、誰かが名前を見間違えたんだろう」
俺は端末を睨みながら、そう言った。荷札に書く発送クラスの名前を、担当者が打ち間違えた——それ以外に考えつく理由がなかった。忙しい港ならいくらでもある話だ。
ミラは肯定も否定もせず、「もう少し見せて」とだけ言って、船内の発送台帳を呼び出した。台帳というのは、その荷物の発送時にどのクラスが使われたかを記録している、コードの実行ログのようなものだ。
該当の記録を開く。使われていたクラスの名前は CooledInsuredCargo。
保冷(Cooled)と保険(Insured)——防振の字がどこにもない。
もう一つ、気になる数字があった。請求額が、見積もりと158クレジット合わなかったのだ。ミラが「これも見ておこうか」と請求ログを並べる。金額の差は、使われたクラスが違えば運賃の計算式も違う、というだけの話だろう。物理的な破損の原因はそれだけでは分からないが、少なくとも「どのクラスが実際に使われたか」の裏付けにはなる。
「誰が発送を担当したかまでは、記録に残ってない」俺は続けた。「まあ、忙しい港だったんだろう」
一次仮説は、まだ生きているつもりだった。ミラは何も言わず、台帳の全体——このシステムに登録されているクラスの一覧を呼び出した。
第2幕 解析——似すぎた7つの名前と、掛け算の正体
投影に並んだのは、こういう一覧だった。
| |
7行。並べて見ると、CooledInsuredCargo と CooledShockproofInsuredCargo は、名前の途中に “Shockproof” が丸ごと埋もれているだけの違いしかない。忙しい港で、長い名前のリストからこの2つを見分けろというほうが無理な話に思えてきた。
「これ、見間違えても仕方ないよ」ミラが言った。「人間が悪いんじゃなくて、見分けさせようとしてない名前が7つ並んでるのが悪いの」
一瞬、面食らった。誰かの不注意だろうという俺の見立てが、その誰かを守る方向に訂正されたからだ。ミラの口調に責める色はなかった。担当者の名前は記録にも残っていない。姿の見えない誰かを、これ以上追及する気はミラにもないらしかった。
投影に上がっているコードを見せてもらう。7クラスとも、積荷の基本形を継いでいた。
| |
attr_reader は持ち物の読み出し口を開ける書き方、raise ArgumentError はおかしな値をその場で弾く門番——このへんは前の引き直しで馴染んだ型のままだ。label は品目名、base_cost は基本運賃。cost は運賃を、manifest は積み荷目録の文字列を返す、それだけの素の積荷だ。
このクラスを継いで、オプションつきの積荷が作られている。実際に使われた CooledInsuredCargo と、本来使うべきだった CooledShockproofInsuredCargo を並べてもらった。
| |
round は四捨五入して整数に丸めるメソッドだ。保険料は運賃に5%を掛けるので、端数が出る。それをそのまま残さず、ここで整数に丸めている。
二つを見比べると、base_cost + 300 の部分が一字一句同じで、下のクラスにだけ + 150 が挟まっているだけだと分かる。保冷の +300 のロジックも、単独のクラス(CooledCargo)で一度書いたはずのものが、組み合わせのクラスにもう一度そのまま書き写されている。
「なんでコピーしてあるんだ。継げばいいだろう、CooledCargo と InsuredCargo の両方を」
「Rubyは、親を1人しか選べないの」
継承——クラスが親の実装を受け継ぐ仕組みは、前にも何度か出てきた。だが親が2人になれるとは、考えたこともなかった。CooledInsuredCargo は CooledCargo の性質も InsuredCargo の性質も欲しいが、class CooledInsuredCargo < CooledCargo と書いた瞬間、InsuredCargo の側は継げなくなる。書き写す以外に、手がなかったわけだ。
「じゃあ、最初からこんな作り方をしたのが悪かったのか」
「一つ一つは、悪くない」ミラは3つのクラスの生まれを順に辿った。保冷は数年前、生鮮貨物寄りの受注が増えたときに1クラスとして追加された。防振はその後、精密機器輸送の引き合いが増えて追加。保険は保険会社との契約更新のときに追加された。どれも、追加されたその時点では単体で足りていた。「1つずつは、そのとき正しい判断だった。誰も、3つ同時に欲しいと言われる日を計算に入れていなかっただけ」
Beforeを嘲笑う気は、俺にもミラにもない。旧航路図には、当時の重力場なりの理由がある。今日もそうだった。
だが、その「一つずつは正しい」判断が積み重なった結果を、ミラは丸を三つ描いて見せた。保冷、防振、保険。それぞれ単体で選ぶかどうかを決められる、独立した3つの丸。
「1個だけ選ぶ組み合わせが3通り。2個の組み合わせも3通り。3個全部で1通り。足すと7」
7という数字は、さっき見た一覧の行数と一致する。ミラはこの、独立して選べる丸のことを、軸とも呼んだ。
「オプションが1個増えるたびに、クラスは1個増えるんじゃないの。今ある組み合わせぜんぶに、新しい丸を足すか足さないかの2択が掛かるから、今までの倍近く増えるの」
試しに、危険物割増という4個目のオプションが来たらどうなるか、その場で数えさせられた。答えは15。8増えるどころか、今ある7を軽く上回る数のクラスが要る計算になる。
ミラが指で示した先を、目で追い直した。丸が3つ、線の先に7つの名前がぶら下がっている。線をたどると、CooledInsuredCargo と CooledShockproofInsuredCargo は隣り合う枝の先で、名前の途中がまるごと重なっていた。数える前から、もう紛らわしさが目に見えていた。

「プローブの新型追加と同じだろう」俺は口を挟んだ。「1個増えりゃ1個作ればいい。前にそうやって片付けたはずだ」
新型の探査プローブが来るたび案件が増えていた、あの管制システムの引き直しが頭にあった。あれも「増えるたびに手が要る」話だったはずだ。
「あっちは1本の軸」ミラは首を振った。「プローブの種類が増えるだけだった。1種類増えたら1クラス増える、それで済んだ。こっちは軸が3本あって、それぞれが独立に選べる。軸が増えるのは、1個増えるんじゃなくて、今ある組み合わせが全部倍になるってこと」
1本の軸が増えるのと、軸そのものが増えるのとでは、増え方の質が違う——そう言われて、ようやく腑に落ちた。プローブの一件は縦に足し算だった。これは横に掛け算だ。
「もう一つ、見ておいてほしいことがある」
ミラが投影を切り替えた。もし発送担当が、名前を間違えたのではなく順序を間違えていたら——という仮の計算だった。細かい話はあとで見せる、と前置きされて、この時点では「エラーにもならず、金額だけ違う場合がある」という一言だけ聞かされた。詳しい中身は、まだ見せてもらえない。
ミラの手が止まった。巻きかけていた布を持ったまま、動きが雑さから急に精密なものに変わる。長い付き合いで、この切り替わりが何を意味するかは知っている。
「じゃあ、直そう。今度は、7個じゃなくて、3個でいいようにする」
第3幕 引き直し——一枚ずつ包んで、重ねる
投影に上がったのは、小さなクラスの骨格だった。
| |
「CooledCargo は Cargo の子分だったろう」俺は訊いた。「これは何を継いでるんだ」
見た限り、どこも継いでいない。class CargoOption で終わっていて、< の記号がどこにもなかった。
「継いでない。持ってるの」
initialize で受け取った cargo を、@cargo という持ち物として抱えているだけだ。cost は自分では何も足さず、抱えている @cargo に cost を聞いて、そのまま返す。manifest も同じ。
「である、の関係は、親を1人しか選べない」ミラが続けた。「持つ、の関係は、何個でも抱えられる」
継承——「である」の関係は is-a と呼ばれる。今日出てきた「持つ」の関係は has-a。単一継承の壁は、is-a の側にしかない。has-a なら、抱える相手を何個でも重ねられる。
CargoOption を親に、3つのオプションが並んだ。
| |
型はどれも同じだ。抱えている相手の cost に、自分の分だけ足す。保冷なら300、防振なら150。保険だけ少し違って、抱えている相手の cost——つまりそれまでの累計——に5%を掛けて、round で丸める。
「待て、これ」俺はコードを二度見した。「港の通訳のときと同じ形だろう。initialize で相手を抱えて、委譲する」
港ごとに違う交信規格を翻訳していた、あのアダプタの一件を思い出していた。相手を1個抱えて、呼ばれたら相手に頼み、結果を返す——「抱えて頼む」形は、まったく同じに見える。
ミラが手を止めて、こっちを向いた。
「形は同じ。仕事が違う」
「……同じに見えるんだが」
「通訳は、hail を request_docking に変えてた。呼び名を変えるのが仕事だったの」ミラは投影を指した。「今日のは cost は cost のまま、manifest は manifest のまま。呼び名を保ったまま、中身だけ足す」
言われて見返すと、確かにそうだった。CooledCargo も ColdStorage も、外から見れば Cargo と同じ顔をしている。呼び方は変わらない。変わるのは中身の答えだけだ。
「もう一つ違いがある。通訳は1回訳したら終わり。今日のは——」
ミラが一行書いた。
| |
「何層でも重ねられる。通訳を通訳で包む、なんてことはしないでしょ。今日のは、包んだものをまた包める」
インタフェースを変えるのがAdapter、保ったまま重ねるのがDecorator——同じ「抱えて頼む」の形をした2つの航法術が、そこで初めてはっきり線を引かれた。
「航路計算のときの部品差し替えと、何が違う」俺はもう一つ、ずっと引っかかっていたことを訊いた。運航方針を1つ選んで差し替える、あの一件とも形が似て見えたからだ。
「あっちは1つ選ぶ。こっちは何個でも重ねる」ミラは即答した。「保冷だけ、保冷と保険、全部——好きなだけ着せられる。選ぶ服と、重ね着、くらい違う」
Adapterとの線引き、Strategyとの線引き。両方済んだところで、ミラが自分から言った。
「同じ形のまま包んで、重ねる。Decorator——航法術としては、そのまま。装飾、って意味ね」
Decorator。オブジェクトを同じインタフェースのまま包み、機能を動的に積み重ねる構造パターン——これが素の定義だ、とミラは付け足した。
「一枚一枚は、自分の内側しか知らなくていいの」
金言だった。Insurance は、自分が Shockproofing を包んでいることも ColdStorage を包んでいることも知らない。ただ @cargo.cost と聞くだけで、内側が何層あっても答えが返ってくる。1枚は、1枚より内側の1つしか見ていない。
名前を聞いて、ようやく訊けた。
「なんでそんなに包むのが好きなんだ」
ミラは答えなかった。代わりに、手近にあった小さな積み荷箱をこちらへ寄越した。
受け取って、しばらく箱を眺めた。それから、さっきミラがやっていたのを見よう見まねで、布で包んでみた。端を折り込み、角を合わせる。不器用な手つきで、ミラの仕上がりほど綺麗にはいかなかったが、一枚は巻けた。その上から、もう一枚。重ねるたびに、下の布の柄が見えなくなっていく。箱の形自体は、最初から一度も変わっていない。
「一枚は一枚のままだ」つぶやいていた。「重ねても、下の一枚は変わってない」
新しい発見をしたつもりはなかった。ただ、たった今聞いた名前を、自分の手で確かめ直しただけだ。ミラは何も言わず、黙ってそれを見ていた。正解も不正解も、口にしなかった。もう答えは出ている後だったからだろう。二重に巻いた布の感触を確かめながら、俺はようやく合点がいった。この人が手が空くたびに何かを包んでいたのは、たぶん暇つぶしですらなかった。
もう一つ、確認しておきたいことがあった。
「装飾したやつは、元の Cargo として扱えるのか」
「is_a? で聞いたら false が返る」ミラが答えた。is_a? はオブジェクトが指定したクラスの一種かどうかを尋ねるメソッドだ。「ColdStorage はどのクラスも継いでない。でも cost は呼べる。型で聞かず、呼べるかどうかで判断する——港の通訳のときと同じ流儀」
ダックタイピング、同じ名前の呼びかけに応えられれば型は問わない、という前に教わった流儀。型で弾こうとするコードを書くと、装飾済みの積荷はことごとく撥ねられる。呼べるかどうかだけを見ればいい。
第4幕 模擬航走——静かに違う数字と、軸1本ぶんの追加
模擬航走の先頭は、いつもどおり約束の確認だった。Beforeの各クラスと、Afterの対応する組み合わせを並べて、cost と manifest が完全に一致することを照合する。
| |
保冷のみ、保険のみ、保冷+保険、保冷+防振+保険——4組とも緑。構造は変わった、挙動は変わっていない。それをまず数字で示してから、次に進む。
次が、今日の主役だ。Act2で予告されていた「順序を間違えたら」の中身を、ここでようやく見せてもらった。
| |
保険は「それまでの累計コスト」に5%を掛ける。だから、どの順で包むかで答えが変わる。保険を最外層——一番最後に包む正しい順なら13073。保険を最内層——一番最初に包んでしまうと、素の運賃12000に5%を掛けただけの額に、あとから保冷と防振の定額を足すことになり、13050。
結果盤の数字だけを目で追っても、どこで額が分かれたのかはすぐには掴めない。同じ3枚を、重ねる順だけ変えて並べた図をミラが見せてくれた。内側から積み上がる数字をたどると、保険がどの段に座るかで、最後の一段がまるごと変わっていた。

「……前に、実行時にしか鳴らないって言ってたな」俺は結果盤を見ながら言った。「今度のは、鳴りもしないのか」
| |
assert_kind_of は指定した型かどうかを確認する照合、refute_equal は「等しくないはずだ」という照合——assert_equal の逆側だ。両方とも「例外は起きない」ことと「答えは違う」ことを、セットで確かめている。
「鳴らない」ミラは静かに答えた。「ただ、数字が違うだけ。エラーより静かな壊れ方」
Rubyの間違いは航行中にしか鳴らない——動的型付けの言語では、書き間違いは実行するまで黙っている。それはこれまでも繰り返し出てきた話だ。だが順序の間違いは、実行してすら鳴らない。13073と13050、どちらも例外の一つも投げずに、正常に答えを返す。違うのは数字そのものだけだ。だからこそ、順序そのものを模擬航走で固定して確認しておく必要がある、という規律に繋がる。
is_a? の罠も、1本の模擬航走で確認した。
| |
respond_to? は、指定したメソッドを呼び出せるかどうかを尋ねるメソッドだ。型では弾かれるが、呼び出しは通る。この2つをセットで確認しておくのが、ダックタイピング前提のコードの作法だ。
任意の部分組み合わせも、数本の模擬航走で確かめた。保冷だけ、防振と保険だけ(保冷なしの組み合わせ——Beforeなら ShockproofInsuredCargo という別クラスの定義が要った組み合わせだ)。どれも新規クラスを1つも足さずに、.new の入れ子を組み替えるだけで作れる。
「増えても崩れない、が半分」ミラが結果盤を眺めながら言った。「もう半分は——オプションが増える速さが、掛け算から足し算に変わったの。7が15になる未来はもう来ない。15が31になる未来も」
保証できないことも、いつもどおり確かめておいた。
「三つある」ミラは指を折った。「一つ。順序に意味を持たせた場合、順序を間違えても構造は止めてくれない。今日みたいに、模擬航走で順序ごと固定して確認しておくのが運用の仕事。二つ。デコレータを積みすぎると、今何が乗っているか読みにくくなる。今回みたいに3つか4つなら実害は出ないけど、無制限に増やしていい保証じゃない。三つ。オプションが増えない、自由に組み合わさらない場面には過剰。1軸しかないなら、Beforeの素直な分岐で足りることもある」
守っていないものを守っていると言わない——このへんの正直さは、いつもの流儀だ。
締めに近いところで、新しい依頼が実際に届いた。危険物割増オプションを追加してほしいという通信だ。ミラが1クラスだけ書いた。
| |
既存の CargoOption・ColdStorage・Shockproofing・Insurance はどれも触っていない。模擬航走を流す。通る。既存の3オプションの組み合わせも、そのまま緑のままだった。
| |
「前なら、これで台帳が15行に増えるところだったな」
「そう。今日からは、1行」
検分は全部で27本、47項目のアサーション。赤は一つも出なかった。ミラは全部の緑を確かめてから、発送システムを引き直した版に載せ替えた。
コロナ光学への詫びと代替発送の手配は、事務的に短く済ませた。最後にもう一度、正しい組み合わせを実行してもらう。保冷、防振、保険。今度こそ正しい順で。
| |
13073。今度こそ、これが発送される数字だ。
ミラは包みかけの工具箱を仕上げながら、一言だけ言った。
「一枚ずつなら、迷わない」
俺は手元に残っていた布の余りを畳みながら、次に来る軸が何であれ、増えるのはクラス表の1行だけだろうと考えていた。貨物区画の換気扇は、さっきと変わらない低い唸りを立てている。保冷棚の冷気の境目を跨いで、点検の続きに戻る。積み荷は、包む数が増えても中身は増えない。台帳も、同じだ。
🚀 オリオール号 航海日誌(Navigator’s Log)
- 航法術(パターン名): Decorator(オブジェクトを同じインタフェースのまま包み、機能を動的に積み重ねる構造パターン。継承でなく、相手を持ち物として抱える合成ベースの実装)
- 発生事象(症状): 前回寄港地の客先(コロナ光学)から貨物破損クレーム。保冷+防振+保険の3点発注が、防振抜けの
CooledInsuredCargoで発送されていたと台帳から判明。7つの近似したクラス名が並ぶ台帳が、選択ミスを誘発していた - 原因(旧航路の問題): 独立した3つのオプション軸を組み合わせごとのサブクラスで表現し、Rubyの単一継承では2軸以上を同時に継げないため、組み合わせクラスは既存ロジックを書き写すしかなかった。軸が増えるとクラス数は足し算でなく掛け算で膨らむ(3軸で7クラス、4軸なら15クラス)
- 処置(引き直しの要点): オプションごとに独立したDecoratorクラス(ColdStorage/Shockproofing/Insurance)を用意し、
CargoOptionを土台に委譲で積み重ねる設計に変更。継承(is-a)でなく合成(has-a)を使うことで、何個でも自由に重ねられるようにした。新オプションの追加はDecorator1クラスの追加で完結し、既存クラスは無改変 - 保証外事項: 順序に意味を持たせた場合、順序を間違えても構造は止めてくれない(エラーにすらならず数字だけ違う。模擬航走で順序を固定する運用が必要)/デコレータを積みすぎると全体像が読みにくくなる(濫用のリスクは残る)/オプションが増えない・自由に組み合わさらない場面には過剰
- 模擬航走結果: 全27テスト・47アサーション、全灯緑(Before=After同値・順序依存の実演・is_a?の罠・任意の部分組み合わせ・新オプションの無改変追加・事故の正しい再現)
- 次の針路: 危険物オプションの依頼が実際に届き、Decorator1クラスの追加だけで解決。次に増える軸も、増えるのは台帳の1行だけ
