第1幕 異変——半年前の日誌に、今のコードが映っている
プロット室の壁一面が、書架だ。背表紙は古い星図と参考書で埋まっていて、俺の目線の高さに《座標変換ハンドブック 第七版》の背が並んでいる。中央の机の上には、計算コンソールが据え置かれたラップトップと、俺が今日抜き取ってきた紙の航海日誌のバインダが開いている。俺はダグ。この恒星間貨物船〈オリオール号〉の機関士だ。船暮らしはもう二十年を超えていて、機関室の壁ラックの並びは目を閉じても言える。今日は俺は機関室にいない。プロット室の机の前に座っている。
プロット室は普段、航法士補佐が航路計算のドラフトを書く場所だ。天測室のような観測窓は無くて、室内照明の均一な白い光だけが机上と、開いたバインダのページを照らしている。空調の音以外に音源はなく、書架の古い星図の紙の匂いが微かに漂っている。俺がここにいるのは、船長の指示——「過去 12 ヶ月の日誌から、燃料・座標関係で発生した near-miss を全部拾って、次期改修計画の候補リストに載せろ」。機関士がプロット室にいるのは自然な流れだ。航路計算モジュールは燃料消費計算のために機関系と結合が深い。航路距離と燃焼効率の掛け算が、俺の担当する機関系の燃料タンク集計と同じデータ源を使う。だから、こういう時は俺の仕事になる。
バインダを開いて、2026-02-14 のページに栞を挟んだところを読む。手書きの記録が並んでいる。
「絶対座標系と船体基準系の混同による到着地点 0.3 光秒ズレ、応急処置として convert_frame_to_absolute 関数を1つ追加、修正コミット済み」
半年前の事案だ。俺は当時、機関室でこの事故報告書のコピーを読んだ。船長も航法士も「変換関数を追加したから、次はない」と言っていた記憶がある。0.3 光秒のズレというのは、船が目的地の給油ステーションに到着した瞬間の話で、実距離にすればおよそ九万キロメートル。船から見上げてもステーションの照明が視認できないくらいの距離だ。ステーション側のドッキング管制官から「航路計画とのズレを検知、再ランデブーを要請」の通信が入ったのは、減速シーケンスが完了してから三分後だった。俺は当時、機関室で減速シーケンスの燃焼データを最終確認していて、通信は艦橋との共通ラインで聞こえた。管制官の声は淡々としていたが、こちら側の航法士の返答が一拍遅れた——「再ランデブー、了解、追加接近マヌーバに入る」。その一拍で、船内の空気が少し変わったのを覚えている。追加接近マヌーバに四時間、その分の燃料を追加消費した。人的被害はなく、船体損傷もなかった。だが、四時間と追加燃料は帳簿に残った。事故だ。事故報告書の書き出しには「軌道誤差、原因:座標系混同」と一行、太く印刷されていた。
俺は日誌のページから応急処置のコミットハッシュを引いて、ラップトップで当時の diff を開いた。画面に、convert_frame_to_absolute メソッドが 1 つ追加されている。三行のシンプルな変換だ。船体基準系の (x, y) に、自船位置 (ship_x, ship_y) を足すだけ。当時のテストも通っている。俺は変換関数を追加した箇所と、それを呼び出す全ての箇所を、コミット履歴で追った。呼び出し箇所は七箇所。半年前の応急コミットは、convert_frame_to_absolute を七箇所に差し込んだ。
七箇所に、と、俺は少し、間を置いた。
次に座標を渡す八箇所目のコードを書く人は、変換関数を呼ぶことを覚えていないといけない。半年前の事故で得た教訓は、コード上には「変換関数の存在」として残っているが、「呼び忘れの余地」も同時に残っている。しかも、変換関数を呼ぶかどうかは、渡す座標が船体基準系なのか絶対座標系なのかで決まる——だが、渡ってくる座標は生の Float のペアだ。座標系情報はどこにも書かれていない。呼び出し側が「これは船体基準系」と覚えていないと、変換すべきかどうかも判断できない。
俺は日誌の余白に、鉛筆で走り書きした。
「同じ穴。今日書ける機関士は誰だ」
書きながら、俺は自分の記憶を辿った。半年前、事故報告書を機関室で読んだとき、俺は「変換関数を追加したなら、これで大丈夫」と受け取った。深く考えなかった。今、日誌を読み返して、「変換関数を追加した」が「呼び忘れの余地を消した」ではないことに、初めて気づく。応急処置は結果を直した。だが、根の位置は、値そのものだったんじゃないか。
プロット室のドアが薄く開いた。ミラが入ってきた。手にはラップトップと、机の脇に置いた小さなかばん。かばんの口が半分開いていて、そこから真鍮の小さな秤の縁と、木製の小箱の角が覗いている。
「ダグ、資料調査?」
俺は視線を diff から一度離して、ミラの手荷物に一瞬止めて、また diff に戻す。またか、しかも今日は多いな、と口の中だけで呟く。ミラの手癖には長年慣れている。理由は本人がいずれ言う。俺から先に聞くことはしない。
「半年前の座標系混同。応急処置の diff を追ってる。……変換関数を1つ追加してある。呼び忘れの余地は、今も残ってる」
ミラは椅子を引いて、かばんを机の脇に置いた。まだ秤も小箱も出さない。まずラップトップだけを机の反対側に開いて、俺の diff と同じコミットを画面に呼び出した。俺の呼び出し七箇所を目で追う。
「呼び忘れる余地、って、次にコードを書く人が、変換関数を呼ぶことを覚えていないといけない、ってこと?」
俺は頷いた。
「そうだ。座標を渡す全ての場所で、“これは船体基準系だから変換が要る”、あるいは “これは絶対系だから変換不要”、を人が覚えていないと同じ事故が起きる。渡ってくるのは、生の Float のペアだ。座標系情報はどこにも書かれてない」
ミラは少し、間を置いた。書架の《座標変換ハンドブック》の背が、俺の目の端に映っている。ミラは頷きも否定もせずに、diff の一行を指差した——compute_delta(current_x, current_y, target_x, target_y) の引数。全部 Float、全部同じ型、区別なし。
「応急処置の書き手は、結果を正しく直した」
ミラの声は静かだった。
「抜けていたのは、“値そのものに名前を付ける発想” だけ」
俺は初めて視線を上げた。
「値そのものに名前を付ける?」
ミラは頷くだけで、まだ具体は言わない。指は diff の Float の並びから離れて、机の脇のかばんに一度触れた。触れただけで、まだ何も取り出さない。
「応急は変換関数を追加した。それで、当時の結果は戻った。……戻したのは結果だけ。根の位置は、別」
根の位置。俺は口の中で反芻した。応急処置は正しかった。結果を直した。だが、根の位置は、値そのものだったのか。
第2幕 解析——数字は通る、単位は無視される
ミラは、かばんから小型の真鍮秤を取り出して、机の空いた場所に置いた。トランプ大の卓上バランスだ。左右に小さな金属の皿があって、中央に細い針が立っている。続いて、かばんから単位刻印の分銅を二個出した。同じ大きさ、同じ形の真鍮製。片面に「T」、もう片方に「L」の刻印が、一文字ずつ打ってある。ミラは二個を左手の掌に並べた。俺の視線は初めて、秤と分銅に固定された。またか、が、これは今日の話に関係する、に変わった瞬間だった。
ミラは秤の左皿に「T」の分銅を一つ、右皿に「L」の分銅を一つ、そっと載せた。針が中央から左右に揺れて、しばらく定まらない。皿と皿の重さは同じはずなのに——同じ大きさ、同じ形の分銅なのに——針は落ち着かずに、微かに震え続けた。
「見えるでしょう。同じ形、同じ大きさの分銅。だけど、片方は “T”(トン)、もう片方は “L”(リットル)」
ミラは針を目で追いながら、続けた。
「単位が違う分銅を秤に載せると、針が定まらない」
俺は秤の針を見ていた。二個の分銅は、大きさも形も同じに見える。刻印だけが違う。刻印を隠したら、俺の目では区別がつかない。でも、「何を測っているか」が違うから、秤の上で正しく比較できない、と俺は頭の中で言い直した。分銅そのものの物理的な重さも違うのかもしれない——トンの分銅とリットルの分銅は、実際の質量が違うのかもしれない——だが、そこは今の話じゃないな、と俺は自分に断った。ミラが見せているのは「同じ形の入れ物に、意味の違うものを入れると、比較の道具が正しく機能しない」、その一点だ。
ミラは分銅を秤から降ろして、両方「T」の分銅——掌の一個と、かばんからもう一個を追加——に差し替えて、また秤に載せた。今度は針が定まって、左右で釣り合った。
「同じ単位の分銅どうしなら、比較できる」
俺は diff の画面に視線を戻した。add_fuel(a, b) の実装。
| |
三行。それだけ。俺は指で画面を指した。
「これが、単位混在で通ってしまう add_fuel」
ミラは頷いた。
「Ruby は動的型付け。a がトンで b がリットルでも、数値としては a + b を計算して、30.0 を返す。エラーは出ない。……単位が違うのに、数字は通る」
動的型付け。俺は頭の中で、以前ミラが言った言葉を反芻した。型を宣言しない代わりに、型の不一致は実行時にしか鳴らない、というやつだ。航路計算機の一件でミラが言ってた、「だから模擬航走が生命線」の理由。今回はさらに厄介で、実行時ですら鳴らない場合がある。数値としての + は成立してしまうから、テストが「単位が違うと事故る」という観点で書かれていなければ、通過する。GREEN のまま通る。
ミラは秤の上の「T」分銅を一つだけ掌に戻して、少し声のトーンを落として続けた。
「半年前の応急処置は、当時の到着ズレを見て、正しく計算経路を直した。変換関数を一つ追加した。それは、その瞬間の事故に対しては正しい修正だった」
ミラの指は、掌の「T」分銅を軽く弄んでいる。分銅の刻印が指先で微かに擦れる。
「抜けていたのは、“値そのものに名前を付ける発想” だけ。応急は結果を直した——値の表現には手を付けなかった。“値に名前を付ける” という選択肢が、当時の設計語彙には、なかった」
俺は少しの間、それを頭の中で置いた。直し方の方向は正しい。根の位置は別だった。前担当は「計算経路を直せば結果が戻る」と考えた。実際、戻った。だが、「値そのものが座標系情報を持っていない」という根は、今も残っている。前担当を責める話じゃない。当時の設計語彙の話だ。俺自身、半年前に報告書を読んだとき、「変換関数を追加したなら大丈夫」で通した。同じ語彙の中にいた。
ミラはラップトップを開いて、Minitest の実行画面に切り替えた。半年前の座標系混同を、模擬航走で一本、走らせてみせる。
呼び出し側は「船体基準系の目的地」のつもりで (3.5, 4.2) を渡した。だが、compute_delta はそれを絶対系の目的地として計算する。結果は数値としては返る——sqrt(3.5² + 4.2²) ≈ 5.47。テストは「この結果が半年前の事故時の値と一致するか」で GREEN。事故が再現された、ことを、GREEN が証明した。
俺は少し、間を置いた。
「深宇宙で聞こえるはずの警報が、天測室でも、機関室でもなくて、今、プロット室のラップトップで再現された。数字は返ってきた。エラーは出てない。……これが、“数値としては通ってしまう” の意味か」
「そう」
ミラは頷いた。
「エラーが出るなら、まだ気づける。……気づけないのが、一番怖い」
ミラは秤と分銅を机に置いたまま、椅子を立って壁の書架に近づいた。書架の並びから、古い星図一冊を抜き取って、机の空いた場所に開いた。
そこから、手を離した。
ミラは机の傍らに立って、開かれた星図を、指で触れずに、数秒眺めた。プロット室の照明が均一に星図を照らして、影ができない。星図の紙は少し古びていて、綴じ目に薄く色褪せが見える。ミラの手は、星図のどこにも触れていない。俺は、その様を見ていた。
手を離す、というのが、たぶん何かの合図なんだろう。ミラは、答えを言う前に、いつも一度、こういう間を置く。
第3幕 引き直し——値そのものに、名前を着せる
ミラは書架から離れて、椅子に戻った。かばんから、もう一つ、木製の小箱を取り出した。蓋を開ける。内側に、二つの凹みが刻んである——「T」型の凹みと「L」型の凹み。ミラは「T」分銅を「T」型の凹みに嵌めた。ぴたりと嵌まる。次に、「T」分銅を「L」型の凹みに嵌めようとした。入らない。凹みの形が違うから、物理的に嵌まらない。
「見えるでしょう。口の形が違うと、挿さらない」
俺の視界に、秤と、収納ケースが並んだ。秤は「重さの比較」で単位を見せた。この収納ケースは「口の形」で型を見せている。二つの直観が、別々の装置で、並んだ。
ミラはラップトップに After コードを出した。
「引き直しを、見せる」
俺は視線を画面に戻した。
「Value Object(ヴァリュー・オブジェクト)——値そのものを、意味を持った専用の小さな不変クラスにくるむ設計。裸の Float や生の文字列に、名前と型を与えて、混ざれなくする。Ruby 3.2 からは Data.define で最小コストで書ける」
素の定義。俺の頭の中で言い直した。値そのものに名前と型を与えて、混ざれなくする。裸のままだと、口の形が誰にでも合ってしまうから、名前を着せて、口の形を分ける。
ミラはキーボードから手を離して続けた。
「Data.define は Ruby 3.2、2022 年の 12 月以降の機能。それより古い Ruby なら、attr_reader と freeze を組み合わせて、== と hash を手書きで書けば、同じ形の不変値クラスは作れる。書く量が増えるだけ」
俺は頷いた。〈オリオール号〉は 3.3 だから問題ないが、別の船で読む人のために、旧 Ruby の代替は覚えておこう。
ミラは Coordinate の宣言行を画面に出した。
| |
「これで、x と y と frame——座標系識別子——の三属性を持つ不変値クラスができる。属性名は Data.define の引数にシンボルで並べる」
Data.define(:x, :y, :frame) は属性名をシンボルで受ける、と俺は頭の中で反芻した。作るときは、と、俺は画面の次の行を目で追った。
| |
キーワード引数で書く。宣言と生成で書き方が違うのが少し引っかかる。宣言は位置引数のシンボル、生成はキーワード引数。二通り並ぶ。だが、「属性名を宣言するのは位置引数、値を渡すのはキーワード引数」と覚えれば済む。頭の中で線を引いた。
ここで俺は、疑問を一つ持ち上げた。junior だった頃の俺なら詰まっただろう場所を、今のうちに確認しておきたかった。
「これ、Struct.new(:x, :y) でもよくないか。前に何度か見た書き方だ。属性名をシンボルで並べる、っていう形だけ見ると、Struct と似てる」
ミラは収納ケースの「T」型の凹みに「T」分銅を指で軽く押し込みながら答えた。凹みに嵌まった分銅は、蓋を閉じても中で動かない、という物理を見せながら。
「Struct はできる。でも、Struct は “後から書き換えられる”」
ミラは短い実演コードを画面に出した。
| |
「Struct は、作ったあと c.x = 999.0 で中身を書き換えられる。Data.define は書き換えられない。作った瞬間に凍っている。……“値そのもの” は書き換えたら別のもの——それが Value Object の考え方」
値は書き換えない。書き換えたら別の値。俺は頭の中で線を引いた。もし「同じ座標」を複数の場所に渡していて、その一箇所で誰かが c.x = 999.0 と書き換えたら、他の場所は知らないうちに別の座標を持たされる。Struct はできる、が、「値」として使うには、書き換えられる余地が残っている。Data.define は、その余地を最初から消してある。
ミラは Coordinate の生成時検証を画面に出した。
| |
「Data.define は == と hash と to_h を自動で用意してくれる。……でも、生成時検証は自分で書く。def self.new を override して、検証してから super を呼ぶ」
super——継承元のメソッドを呼ぶ、という Ruby の書き方。override した new の中で、まず自分で検証を書いて、通ったら super で元の new に処理を渡す。この「override して super を呼ぶ」は、Ruby でよく出てくる書き方だから、頭に入れておこう、と俺は自分にメモした。
ミラは実際に、typo を仕込んだ Coordinate.new を試しに走らせた。
| |
コンソールに赤い行が出た。
| |
「未知の座標系識別子は、オブジェクトを作れない」
ミラはコンソールを閉じて、次に Coordinate#- の実装を出した。
| |
「差分は、座標系が一致するときのみ計算。……混在した座標系の差分は、計算の瞬間に raise IncompatibleFrames。数値として静かに通らなくなる」
俺は目で追った。二段階だ、と気づいた。生成時に「未知の座標系識別子」を弾く。演算時に「異なる座標系どうしの計算」を弾く。単位のズレは、値を作った瞬間と、値どうしを混ぜようとした瞬間の、二つの関門で捕まる。
生成時と演算時、二段の関門で「混ざれない」が守られる。

続いて、Fuel を画面に出した。同じ形。属性は amount と unit。生成時検証で単位の妥当性と非負をチェック、+ で単位一致を確認し不一致で raise IncompatibleUnits。
| |
Coordinate と Fuel は、構造として同じ形をしている。属性を並べて、生成時に列挙値をチェックし、演算で型(座標系や単位)が一致するときだけ計算する。違うのは「何を測っているか」だけだ。同じ形の枠を、意味の違うものに着せる。
俺はコードから視線を離して、机の上の収納ケースを、指で借りた。「T」分銅を「T」型の凹みに、「L」分銅を「L」型の凹みに、自分の手で嵌めていく。両方、ぴたりと嵌まる。試しに、「T」分銅を「L」型の凹みに嵌めようとした——入らない。木の凹みが「T」の形に切ってあるから、「L」型の分銅は物理的に入らない。押し込もうとすると、分銅の縁が凹みの縁に当たって止まる。
俺は蓋を閉じずに、収納ケースをそのまま机の端に置いた。ミラは口を挟まずに、卓の傍らで、俺が凹みに嵌める動作を見ていた。
口の形が違うと、物理的に嵌まらない。コードで言えば、座標系識別子と単位が値そのものに付いているから、渡した先で「違う口」に嵌めようとすると弾かれる。同じ数字でも、「何を測っているか」が違えば、別のもの。
そこまで頭の中で置いてから、俺はもう一つ、junior だった頃の俺なら引っかかっただろう場所を、口に出した。
「これ、Ruby 3.2 以上じゃないと使えないのか。……古い船のコードだと Data.define は無いのか」
ミラは頷いた。
「Ruby 3.2 以上。もし旧 Ruby なら、attr_reader と freeze と def == と def hash を手書きで書く。書く量が増えるだけで、同じ形の不変値クラスは作れる」
書く量が増えるだけ、と俺は反芻した。Ruby 3.2 以上——今の〈オリオール号〉は 3.3 だから問題ないが、別の船で読む人のために、旧 Ruby の代替は覚えておこう。もう一度、頭にメモした。
第4幕 模擬航走——数字が通る画面と、型で弾かれる画面
ミラはコンソールから顔を上げた。
「保証を見る前に、一つだけ。Struct は “属性を持つ小さなクラス”、Data.define は “値そのもの”。書き換えられるか、書き換えられないか、が違う」
俺は頷いた。収納ケースは、まだ机の端に置いてある。蓋は開けたまま、凹みに嵌まった「T」分銅と「L」分銅が、内側に静かに収まっている。
ミラはラップトップの画面を左右に分けた。二つの模擬航走を並列で流す準備だ。
左半分——Before の生 Float。
| |
30.0 が返る。GREEN。テストは「数値が返ること」を確認する形で書かれている。単位の混在は、テストにも書かれていないから、通る。半年前の座標系混同の再現テスト——compute_delta(0.0, 0.0, 3.5, 4.2) を「船体基準系のつもりで絶対系として計算」——も、数値として 5.47 を返す。GREEN。事故が再現されたことが、GREEN で示される。
右半分——After の Value Object。
| |
コンソールに赤い行が出る。
| |
Minitest の側では assert_raises(Fuel::IncompatibleUnits) で捕まえて、「拒否できたこと」を GREEN として通す。
半年前の再現データを Coordinate.new で渡して、座標系不一致で compute_delta を呼ぶ——raise Coordinate::IncompatibleFrames。こちらも assert_raises で GREEN。
俺は左右の GREEN と RED の並びを、少しの間、見た。左半分の Minitest 出力は 6 runs, 8 assertions, 0 failures, 0 errors, 0 skips。右半分の Minitest 出力は 2 runs, 2 assertions, 0 failures, 0 errors, 0 skips——assert_raises のブロック内で発火した例外が拒否として捕まった行数だ。二つの画面のカウントは違うが、どちらも下段は 0 failures で揃っている。
同じ数字でも、名前を着せた瞬間、混ざれない形になる。

左は「数値として通ってしまった」ことを GREEN で示している。右は「型で拒否できた」ことを GREEN で示している。同じ緑色の GREEN でも、意味が真逆だ。左は静かな事故——数字は返ってきて、警報は鳴らない、テストは通る。右は騒々しく弾かれた事故——型が違うから、加算の瞬間に例外を投げて、テストが assert_raises でそれを捕まえて、「弾けたね」と GREEN を返す。半年前の事故報告書の一行——「軌道誤差、原因:座標系混同」——が、この右半分の画面では、例外の一行——Coordinate::IncompatibleFrames: 座標系 :body_frame と :absolute_frame は混ぜられない——として、走らせた瞬間に浮かび上がっている。事故の後で書かれた報告書と、事故の前で書かれた例外。時系列が反対だ。
ここで俺は、junior だった頃の俺が引っかかりそうな場所を、もう一つ、口に出した。
「型が違うから加算できない、って、raise するってことか。……走らせて初めて分かる、ってことは、結局 “型を書き忘れる” 余地は残らないのか」
ミラは少し、考える間を置いた。
「Ruby は動的型付け。……コンパイル時に “型が違うから加算できない” を検出する仕組みは、Ruby には無い。だから、“型を書き忘れる”——Fuel.new を通さずに、生の Float を AutopilotCalculator#add_fuel に渡す——という書き方は、まだできる」
俺は少し、静かになった。じゃあ、根の位置は、まだ残るのか。
ミラは続けた。
「ただし。……AutopilotCalculator#add_fuel の中身は、a + b を呼ぶだけ。a が Fuel オブジェクトなら、Fuel#+ が呼ばれて、単位一致がチェックされる。a が生の Float なら、Float#+ が呼ばれて、そのまま数値として通る。……つまり、“生の Float を渡してしまう” 事故は、AutopilotCalculator#add_fuel の入口ではなく、“生の Float を作った側” の書き方に残る」
俺は頭の中で二つの言葉を並べた。値を作る側と、値を受け取る側。Value Object を導入すると、値を受け取る側では「混ざれない」が保証される。だが、値を作る側で「そもそも Fuel.new を通さずに生の Float を作る」書き方は、まだできる。
「じゃあ、値を作る側で Fuel.new を通させる仕掛けは、別に要る、ってことか」
ミラは頷いた。
「別に要る。……たとえば、船体タンクの燃料量を返す FuelTank#read_amount を、Fuel を返すように書き換える。呼び出し側は read_amount の戻り値をそのまま AutopilotCalculator#add_fuel に渡す。……この書き換えを、Value Object の導入と同時に、値を作る全ての場所でやる。そうすると、“生の Float が漂う” 場所が、少しずつ消える」
書き換えの範囲は、値を作る全ての場所——タンク読み取り、外部通信、設定ファイル読み込み。全部を一度に書き換えなくていい。少しずつ、Value Object を返す形に置き換えていく。置き換わった場所は、以降は「Value Object として受け取れる」から、その先の混在は型で弾ける。残った「生の Float」の場所は、次の改修候補として日誌に載せる。
俺は納得して、次の疑問に移った。
「じゃあ、“何もしない” が悪い場合はないのか、みたいな話——生成時に無効値を弾く、ってのは分かった。……でも、“設計者が想定しなかった有効値” は、どうする? 例えば、:body_frame として渡されたけど、実際にはセンサー誤差でズレていた座標データ、みたいなの」
ミラは頷いた。
「弾けない。……Value Object は “生成時に無効値を弾く” だけ。“設計者が想定しなかった有効値” は、Value Object の守備範囲外。それは、ドメイン層のバリデーション——センサー由来の誤差を許容範囲でチェックする、みたいな仕組み——で別途担う」
俺は反芻した。生成時検証は、「識別子が有効値のいずれか」「量が非負」みたいな、静的な妥当性しか見ない。動的な妥当性——実際のデータが物理的にありえる範囲か——は、別のレイヤの仕事だ。Value Object で「全ての事故が防げる」わけじゃない。
そこまで置いてから、俺は少し思って、口を開けた。
「半年前の 0.3 光秒ズレは、もし当時この形で書かれてたら、そもそも計算に入る前に弾かれてたのか」
ミラは頷いた。
「弾かれる。……Coordinate に座標系識別子が付いているから、“船体基準系の座標を絶対系として渡す” は、生成時か、演算時に raise する。半年前に気づけなかった事故一件は、After のコードなら、そもそも起きない」
気づけなかった事故一件。過去に既に起きた一件。今後は零件。これは、行数の diff でも、見張り数の diff でもない。「起きたのに気づけなかった事故」と「起こそうとしたら弾かれる事故」の差だ。
ミラは秤の上に「T」分銅を一つだけ残して、「L」分銅と、もう一つの「T」分銅を、収納ケースの正しい凹みに戻した。ケースの蓋を閉じる。秤の上に残った一個の「T」分銅は、片側の皿に載ったまま——比較する相手がいないから、針は片側に完全に振れきっている。もう混ぜる相手がいない状態で、静かに一つだけ、秤の上にある。
ミラの声は静かだった。
「数字は、単位を着てないと、混ざるの」
俺は秤の上に残った一個の分銅と、閉じられた収納ケースを、同じ視界に収めた。混ざるものは、混ざれない形に置いてから使えばいい。単位を着せて、口の形を分けて、片側の皿だけに載せて、混ざる相手を最初から用意しない。……そういう形が、値そのものにある。
プロット室の照明が均一に、机上の秤と、閉じられた収納ケースと、開いたままの diff 画面を照らしている。俺は日誌のバインダを閉じた。閉じる直前に、余白の走り書きが目に入る。「同じ穴。今日書ける機関士は誰だ」。俺はもう一行、その下に書き足した。「値そのものに、名前を着せる」。
プロット室を出よう。改修候補リストに、今日の一行を書き足せる。
補記——書き換えの現実的な順序
junior 読者の後追い用に、Before/After の切り替えで実務的に押さえておくべき点を、二つだけ整理しておく。ミラの言葉のうち、実装のディテールが要る部分だ。
一つは、Value Object の導入は「一度に全部」ではなく「値を作る場所から少しずつ」で進む、という話。今日の記事では Coordinate と Fuel の型定義と、AutopilotCalculator の受け取り側を書き換えた。だが、実際のコードベースでは、Coordinate.new や Fuel.new を呼び出す「値を作る場所」——センサー読み取り、外部通信、設定ファイルロード——を全部書き換えて初めて、「生の Float が漂う場所」が消える。全部を一度に書き換えるのは現実的ではない場合が多い。だから、まず型を定義して、次に「一番事故が起きやすい入口」から順に、値を作る場所を Value Object に置き換えていく。置き換わった場所は、以降は型で守られる。残った場所は、次の改修候補として日誌に書き足す。今日の日誌の下の行が、そういう行になる。
もう一つは、Data.define の self.new を override するときに、super に渡す引数の形の話。今日のコードでは def self.new(x:, y:, frame:) のようにキーワード引数で受けて、そのまま super を呼んだ。Data.define(:x, :y, :frame) で定義した Data クラスは、.new をキーワード引数でも位置引数でも受けられるが、override した self.new がキーワード引数で受けているなら、super はキーワード引数のまま親に渡す。もし override 側で位置引数で受けたい場合は、def self.new(x, y, frame) として、super(x: x, y: y, frame: frame) と明示的に渡すか、super を引数なしで呼んで暗黙的に位置引数を渡す(この暗黙渡しは、キーワード引数と位置引数が混在するときに混乱の元になるので、キーワード引数で統一するのが素直だ)。今日のコードは全てキーワード引数で統一している。
Money Pattern(Fowler の PoEAA 収録の値オブジェクト実装例)、Entity と Value Object の区別(Evans の Domain-Driven Design)——Value Object と近縁の設計語彙は他にも複数あるが、今日の話の主戦場は「生の Float に名前と型を着せて、混ざれなくする」という一点だ。近縁は名前だけ挙げて、別の航路で読み直す価値のあるものとしておく。
俺はプロット室のドアを開けて、通路に出た。書架の《座標変換ハンドブック》の背表紙が、閉まるドアの隙間で最後に一瞬見えて、消える。今日の改修候補リストに、Coordinate と Fuel の Value Object 化、と一行書き足そう。次のシフトが終わったら、船長に見せに行く。
🚀 オリオール号 航海日誌(Navigator’s Log)
- 航法術(パターン名): Value Object(ヴァリュー・オブジェクト)——値そのものを、意味を持った専用の小さな不変クラスにくるむ設計。裸の Float や生の文字列に、名前と型を与えて、混ざれなくする。Ruby 3.2+ の
Data.defineで最小コストで書ける - 発生事象(症状): プロット室で半年前の 0.3 光秒到着ズレ near-miss の航海日誌を読み返し中、応急処置のコミット diff を掘り下げると、変換関数を一つ追加した対処が「変換を呼び忘れる余地」を残していることに気づく。座標が生 Float ペア、燃料量が裸 Float で持ち回られ、単位・座標系混在の演算は「数値として通ってしまう」静かな事故
- 原因(旧航路の問題): プリミティブ執着——ドメイン上の意味(座標系識別子・単位)が値そのものに付いておらず、呼び出し側の記憶頼み。Ruby は動的型付ゆえコンパイル時では弾けず、実行時ですら数値としては成立するため沈黙する
- 処置(引き直しの要点):
Coordinate = Data.define(:x, :y, :frame)とFuel = Data.define(:amount, :unit)で不変値クラスを定義、self.newを override して生成時にraiseで検証、Coordinate#-とFuel#+は型(座標系/単位)一致時のみ計算し不一致でraise IncompatibleFrames/IncompatibleUnits。==/hashは Data 自身が値等価で自動提供 - 保証外事項: Value Object は「生成時に無効値を弾く」だけで「設計者が想定しなかった有効値」(例えば
:body_frameとして渡されたが実際にはセンサー誤差でズレていた座標データそのもの)は弾けない——それはドメイン層のバリデーションで別途担う。また、Structとは書き換え可否が違い、Structは「属性を持つ小さなクラス」、Data.defineは「値そのもの」、で使い分ける。Ruby 3.2 以上が前提、旧 Ruby ではattr_reader + freeze + def ==の手書きで同型実装可 - 模擬航走結果: Before の生 Float で単位混在
add_fuel(10.0, 20.0)→30.0が沈黙して通り、半年前の座標系混同再現テストも数値として通ってしまう GREEN。After はFuel.new(:ton) + Fuel.new(:liter)がraise IncompatibleUnits、Coordinate混在もraise IncompatibleFrames、いずれもassert_raisesで拒否確認の GREEN - 次の針路: 次期改修計画の候補リストへ、今日の一行を書き足す。半年前に気づけなかった事故一件は、今後は生成時か演算時に弾かれる零件へ
