Featured image of post コードナビゲーター【Value Object】航路計算室、混ざる数字たち〜数字は、単位を着てないと、混ざるの〜

コードナビゲーター【Value Object】航路計算室、混ざる数字たち〜数字は、単位を着てないと、混ざるの〜

座標が生の Float ペア、燃料量が裸の Float で持ち回られ、単位取り違えも座標系混同も「数値としては通ってしまう」静かな事故になっていた。半年前の 0.3 光秒到着ズレ near-miss の航海日誌を読み返し、応急処置が結果は直したが根の位置は値そのものだったと気づく。値そのものを不変の小さなクラスにくるむ Value Object パターンで引き直します。Ruby 3.2+ の `Data.define` で座標系識別子と単位を型に閉じ込め、生成時 raise と演算時 raise の 2 系統で混在を弾き、`Struct` との使い分けまでを Minitest で解説します。

第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) の実装。

1
2
3
def add_fuel(a, b)
  a + b
end

三行。それだけ。俺は指で画面を指した。

「これが、単位混在で通ってしまう 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_readerfreeze を組み合わせて、==hash を手書きで書けば、同じ形の不変値クラスは作れる。書く量が増えるだけ」

俺は頷いた。〈オリオール号〉は 3.3 だから問題ないが、別の船で読む人のために、旧 Ruby の代替は覚えておこう。

ミラは Coordinate の宣言行を画面に出した。

1
class Coordinate < Data.define(:x, :y, :frame)

「これで、xyframe——座標系識別子——の三属性を持つ不変値クラスができる。属性名は Data.define の引数にシンボルで並べる」

Data.define(:x, :y, :frame) は属性名をシンボルで受ける、と俺は頭の中で反芻した。作るときは、と、俺は画面の次の行を目で追った。

1
Coordinate.new(x: 3.5, y: 4.2, frame: :absolute_frame)

キーワード引数で書く。宣言と生成で書き方が違うのが少し引っかかる。宣言は位置引数のシンボル、生成はキーワード引数。二通り並ぶ。だが、「属性名を宣言するのは位置引数、値を渡すのはキーワード引数」と覚えれば済む。頭の中で線を引いた。

ここで俺は、疑問を一つ持ち上げた。junior だった頃の俺なら詰まっただろう場所を、今のうちに確認しておきたかった。

「これ、Struct.new(:x, :y) でもよくないか。前に何度か見た書き方だ。属性名をシンボルで並べる、っていう形だけ見ると、Struct と似てる」

ミラは収納ケースの「T」型の凹みに「T」分銅を指で軽く押し込みながら答えた。凹みに嵌まった分銅は、蓋を閉じても中で動かない、という物理を見せながら。

Struct はできる。でも、Struct は “後から書き換えられる”」

ミラは短い実演コードを画面に出した。

1
2
c = Struct.new(:x, :y).new(1.0, 2.0)
c.x = 999.0  # 通る。中身が書き換わる

Struct は、作ったあと c.x = 999.0 で中身を書き換えられる。Data.define は書き換えられない。作った瞬間に凍っている。……“値そのもの” は書き換えたら別のもの——それが Value Object の考え方」

値は書き換えない。書き換えたら別の値。俺は頭の中で線を引いた。もし「同じ座標」を複数の場所に渡していて、その一箇所で誰かが c.x = 999.0 と書き換えたら、他の場所は知らないうちに別の座標を持たされる。Struct はできる、が、「値」として使うには、書き換えられる余地が残っている。Data.define は、その余地を最初から消してある。

ミラは Coordinate の生成時検証を画面に出した。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
class Coordinate < Data.define(:x, :y, :frame)
  VALID_FRAMES = [:body_frame, :absolute_frame].freeze

  def self.new(x:, y:, frame:)
    unless VALID_FRAMES.include?(frame)
      raise ArgumentError, "Coordinate: 座標系識別子 #{frame.inspect} は無効。有効値は #{VALID_FRAMES.inspect}"
    end
    super
  end
end

Data.define==hashto_h を自動で用意してくれる。……でも、生成時検証は自分で書く。def self.new を override して、検証してから super を呼ぶ」

super——継承元のメソッドを呼ぶ、という Ruby の書き方。override した new の中で、まず自分で検証を書いて、通ったら super で元の new に処理を渡す。この「override して super を呼ぶ」は、Ruby でよく出てくる書き方だから、頭に入れておこう、と俺は自分にメモした。

ミラは実際に、typo を仕込んだ Coordinate.new を試しに走らせた。

1
Coordinate.new(x: 3.5, y: 4.2, frame: :absolut)  # :absolute の typo

コンソールに赤い行が出た。

1
ArgumentError: Coordinate: 座標系識別子 :absolut は無効。有効値は [:body_frame, :absolute_frame]

「未知の座標系識別子は、オブジェクトを作れない」

ミラはコンソールを閉じて、次に Coordinate#- の実装を出した。

1
2
3
4
5
6
7
8
def -(other)
  unless frame == other.frame
    raise IncompatibleFrames, "座標系 #{frame}#{other.frame} は混ぜられない"
  end
  Math.sqrt((x - other.x)**2 + (y - other.y)**2)
end

class IncompatibleFrames < StandardError; end

「差分は、座標系が一致するときのみ計算。……混在した座標系の差分は、計算の瞬間に raise IncompatibleFrames。数値として静かに通らなくなる」

俺は目で追った。二段階だ、と気づいた。生成時に「未知の座標系識別子」を弾く。演算時に「異なる座標系どうしの計算」を弾く。単位のズレは、値を作った瞬間と、値どうしを混ぜようとした瞬間の、二つの関門で捕まる。

生成時と演算時、二段の関門で「混ざれない」が守られる。

Coordinate.new の生成時関門で VALID_FRAMES 検査を通り無効なら raise ArgumentError 有効なら super でオブジェクト生成、Coordinate 同士の減算の演算時関門で frame 一致を検査し不一致なら raise IncompatibleFrames 一致なら Math.sqrt 差分計算、という二段の関門の流れ図

続いて、Fuel を画面に出した。同じ形。属性は amountunit。生成時検証で単位の妥当性と非負をチェック、+ で単位一致を確認し不一致で raise IncompatibleUnits

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Fuel < Data.define(:amount, :unit)
  VALID_UNITS = [:ton, :liter].freeze

  def self.new(amount:, unit:)
    unless VALID_UNITS.include?(unit)
      raise ArgumentError, "Fuel: 単位 #{unit.inspect} は無効。有効値は #{VALID_UNITS.inspect}"
    end
    if amount < 0
      raise ArgumentError, "Fuel: 量は非負。#{amount} は無効"
    end
    super
  end

  def +(other)
    unless unit == other.unit
      raise IncompatibleUnits, "単位 #{unit}#{other.unit} は混ぜられない"
    end
    Fuel.new(amount: amount + other.amount, unit: unit)
  end

  class IncompatibleUnits < StandardError; end
end

CoordinateFuel は、構造として同じ形をしている。属性を並べて、生成時に列挙値をチェックし、演算で型(座標系や単位)が一致するときだけ計算する。違うのは「何を測っているか」だけだ。同じ形の枠を、意味の違うものに着せる。

俺はコードから視線を離して、机の上の収納ケースを、指で借りた。「T」分銅を「T」型の凹みに、「L」分銅を「L」型の凹みに、自分の手で嵌めていく。両方、ぴたりと嵌まる。試しに、「T」分銅を「L」型の凹みに嵌めようとした——入らない。木の凹みが「T」の形に切ってあるから、「L」型の分銅は物理的に入らない。押し込もうとすると、分銅の縁が凹みの縁に当たって止まる。

俺は蓋を閉じずに、収納ケースをそのまま机の端に置いた。ミラは口を挟まずに、卓の傍らで、俺が凹みに嵌める動作を見ていた。

口の形が違うと、物理的に嵌まらない。コードで言えば、座標系識別子と単位が値そのものに付いているから、渡した先で「違う口」に嵌めようとすると弾かれる。同じ数字でも、「何を測っているか」が違えば、別のもの。

そこまで頭の中で置いてから、俺はもう一つ、junior だった頃の俺なら引っかかっただろう場所を、口に出した。

「これ、Ruby 3.2 以上じゃないと使えないのか。……古い船のコードだと Data.define は無いのか」

ミラは頷いた。

「Ruby 3.2 以上。もし旧 Ruby なら、attr_readerfreezedef ==def hash を手書きで書く。書く量が増えるだけで、同じ形の不変値クラスは作れる」

書く量が増えるだけ、と俺は反芻した。Ruby 3.2 以上——今の〈オリオール号〉は 3.3 だから問題ないが、別の船で読む人のために、旧 Ruby の代替は覚えておこう。もう一度、頭にメモした。

第4幕 模擬航走——数字が通る画面と、型で弾かれる画面

ミラはコンソールから顔を上げた。

「保証を見る前に、一つだけ。Struct は “属性を持つ小さなクラス”、Data.define は “値そのもの”。書き換えられるか、書き換えられないか、が違う」

俺は頷いた。収納ケースは、まだ机の端に置いてある。蓋は開けたまま、凹みに嵌まった「T」分銅と「L」分銅が、内側に静かに収まっている。

ミラはラップトップの画面を左右に分けた。二つの模擬航走を並列で流す準備だ。

左半分——Before の生 Float

1
2
calc = AutopilotCalculator.new
calc.add_fuel(10.0, 20.0)

30.0 が返る。GREEN。テストは「数値が返ること」を確認する形で書かれている。単位の混在は、テストにも書かれていないから、通る。半年前の座標系混同の再現テスト——compute_delta(0.0, 0.0, 3.5, 4.2) を「船体基準系のつもりで絶対系として計算」——も、数値として 5.47 を返す。GREEN。事故が再現されたことが、GREEN で示される。

右半分——After の Value Object。

1
Fuel.new(amount: 10.0, unit: :ton) + Fuel.new(amount: 20.0, unit: :liter)

コンソールに赤い行が出る。

1
Fuel::IncompatibleUnits: 単位 :ton と :liter は混ぜられない

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 で揃っている。

同じ数字でも、名前を着せた瞬間、混ざれない形になる。

Before 生 Float 沈黙側で add_fuel が 30.0 を数値として通してしまう緑色プレート、After Value Object 拒否側で Fuel.new(:ton)+Fuel.new(:liter) が加算の瞬間に raise IncompatibleUnits で弾かれる赤枠プレート、両者を「同じ緑色でも意味が真逆/気づけなかった事故 1 から 0」の金色矢印で結ぶ Before/After 対比図

左は「数値として通ってしまった」ことを GREEN で示している。右は「型で拒否できた」ことを GREEN で示している。同じ緑色の GREEN でも、意味が真逆だ。左は静かな事故——数字は返ってきて、警報は鳴らない、テストは通る。右は騒々しく弾かれた事故——型が違うから、加算の瞬間に例外を投げて、テストが assert_raises でそれを捕まえて、「弾けたね」と GREEN を返す。半年前の事故報告書の一行——「軌道誤差、原因:座標系混同」——が、この右半分の画面では、例外の一行——Coordinate::IncompatibleFrames: 座標系 :body_frame と :absolute_frame は混ぜられない——として、走らせた瞬間に浮かび上がっている。事故の後で書かれた報告書と、事故の前で書かれた例外。時系列が反対だ。

ここで俺は、junior だった頃の俺が引っかかりそうな場所を、もう一つ、口に出した。

「型が違うから加算できない、って、raise するってことか。……走らせて初めて分かる、ってことは、結局 “型を書き忘れる” 余地は残らないのか」

ミラは少し、考える間を置いた。

「Ruby は動的型付け。……コンパイル時に “型が違うから加算できない” を検出する仕組みは、Ruby には無い。だから、“型を書き忘れる”——Fuel.new を通さずに、生の FloatAutopilotCalculator#add_fuel に渡す——という書き方は、まだできる」

俺は少し、静かになった。じゃあ、根の位置は、まだ残るのか。

ミラは続けた。

「ただし。……AutopilotCalculator#add_fuel の中身は、a + b を呼ぶだけ。aFuel オブジェクトなら、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 の導入は「一度に全部」ではなく「値を作る場所から少しずつ」で進む、という話。今日の記事では CoordinateFuel の型定義と、AutopilotCalculator の受け取り側を書き換えた。だが、実際のコードベースでは、Coordinate.newFuel.new を呼び出す「値を作る場所」——センサー読み取り、外部通信、設定ファイルロード——を全部書き換えて初めて、「生の Float が漂う場所」が消える。全部を一度に書き換えるのは現実的ではない場合が多い。だから、まず型を定義して、次に「一番事故が起きやすい入口」から順に、値を作る場所を Value Object に置き換えていく。置き換わった場所は、以降は型で守られる。残った場所は、次の改修候補として日誌に書き足す。今日の日誌の下の行が、そういう行になる。

もう一つは、Data.defineself.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 に名前と型を着せて、混ざれなくする」という一点だ。近縁は名前だけ挙げて、別の航路で読み直す価値のあるものとしておく。

俺はプロット室のドアを開けて、通路に出た。書架の《座標変換ハンドブック》の背表紙が、閉まるドアの隙間で最後に一瞬見えて、消える。今日の改修候補リストに、CoordinateFuel の 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 IncompatibleFramesIncompatibleUnits==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 IncompatibleUnitsCoordinate 混在も raise IncompatibleFrames、いずれも assert_raises で拒否確認の GREEN
  • 次の針路: 次期改修計画の候補リストへ、今日の一行を書き足す。半年前に気づけなかった事故一件は、今後は生成時か演算時に弾かれる零件へ
comments powered by Disqus
システム開発・AIワークフローのご相談は Meetsource
Hugo で構築されています。
テーマ StackJimmy によって設計されています。