Featured image of post コードナビゲーター【State】接舷シーケンスの飛び越し事故〜順番は、書き並べるものじゃなくて、渡っていくものよ〜

コードナビゲーター【State】接舷シーケンスの飛び越し事故〜順番は、書き並べるものじゃなくて、渡っていくものよ〜

接舷シーケンスの状態を文字列フラグと巨大case文で回した旧設計が、書き漏らしから「許されない遷移」を通してしまう。状態そのものをクラスに引き上げ、書けない場所を作って構造的に防ぐStateパターンを、Rubyのシンボルとダックタイピングで解説します。

第1幕 異変——記録から抜けたロック

ブリッジに上がったのは、主機の巡航状態をあっちのモニタで確認したかっただけだ。

俺はダグという。この恒星間貨物船〈オリオール号〉の機関士だ。船暮らしはとうに二十年を過ぎたが、機関室から出て船橋まで足を運ぶのは、日に一度あるかないかで済ませている。機関士の持ち場は機関室で、姿勢制御のスラスター配分にせよ、主機の推力バランスにせよ、まず自分の席のモニタで見るのが筋だ。ただ今日は事情がある。次のランデブーが十二時間後で、相手は無人補給ドロン——オーガス-3。ブリッジ側の姿勢制御コンソールと機関室側のモニタとで、同じ数字が同じように出ているかを、俺は一度、目で揃えておきたかった。それだけの用事だ。

ブリッジは静かだった。舷窓の外は深宇宙で、星々は動かず、そのずっと先、まだ点にしか見えない小さな微光が一つ、船の航路とほぼ同じ方角に淡く光っている。あれがオーガス-3だ。無人だから交信の余計な音もない。姿勢制御コンソールの LED だけが規則正しく明滅していて、ブリッジ全体が水槽の底みたいに静まりかえっている。

先客がいた。

星図室の床に暮らしているような人が、その時間ブリッジのモニタ席にいる。うちの航法士のミラだ。モニタは三面広げてあった。真ん中の一面には、三日前の演習ログ——前回の接舷模擬航走の記録が、フェーズごとに区切って表示されている。左右の二面には、そのフェーズを制御している側のコード——ドッキング制御プログラムの一部が呼び出してあった。

ミラは片手をモニタに向けて、もう片方の手はコンソールの端に置いてあった。その手の下に、俺の目は一瞬止まった。

五個の金属の輪が繋がった、小さな鎖片が置いてある。銀色の、指先ほどの太さの輪だ。前の輪と次の輪だけがしっかり噛み合っていて、それ以外の輪同士は触れてもいない。ミラの指はその鎖の一番端の輪に触れていて、俺が見ている前で、一つ、次の輪に手繰っていった。

いつものことだ。俺は付き合いが長い。ミラが何かを考え込むときに、机の上に妙な小物を並べたり、指先で何かを辿ったりするのは、いちいち理由を聞くほどのことではない。俺は口を開けずに、姿勢制御コンソールの数字を確認しに、モニタ席の反対側へ回った。

用事はすぐ済んだ。数字は合っている。主機の巡航はいつもどおり、姿勢制御のスラスターも配分どおりに応答している。ブリッジの手すりに手をかけて、そのまま席を立ちかけたところで、俺はミラの背中の緊張のほうに気がついた。ミラは動かない指の代わりに、少し肩が固くなっている。付き合いが二十年を超えると、そういうのが読めるようになる。

俺は手すりから手を離して、モニタを覗いた。

演習ログのフェーズ表示は、上から順に 接近減速整列連結加圧 の五段。それぞれのフェーズに、開始・進行中・完了のタイムスタンプが並んでいる。俺はまず、整列のあたりに目を止めた。整列開始のタイムスタンプはある。連結開始のタイムスタンプもある。そのあいだに——姿勢一致率がロック確定信号として立った、という受信記録が、無い。

俺は少し首を捻った。

「センサーの一過性故障だろう」

俺は声に出して言った。演習中に姿勢センサーが一、二回、応答を漏らすことは無くはない。三系統ある姿勢センサーの一つが一瞬瞬断すると、多数決の側で処理を通してしまうことがある。ハードの側の書きそびれ、というやつだ。機関士としては、その手の一過性の不具合の後始末を何度もしてきた。よくある話だ。

ミラは、答える前に指をゆっくり動かした。鎖の輪から、次の輪へ。手のひらの筋肉が動くくらいの、ほとんど音のない動きだった。

「センサーは三系統で冗長化されてる」ミラの声は、ブリッジの静けさに合わせるみたいに低かった。「一系統が瞬断しても、残りの二系統は独立に記録に載る。三系統とも同時に瞬断する確率は、この演習の時間のなかでは、無視できるほど低い」

俺はモニタを見直した。右の面には、姿勢センサーの三系統の生の受信履歴が並んでいる。数字自体は継続して記録されていた。0.94。0.96。0.97。0.98。三系統ぶんの列が、揃って上がっている。ずっと。ロック確定信号の列だけが、ずっと空欄になっている。

俺は口の中で「ああ、確かに」と言った。数字が来ている以上、センサーの故障ではない。

一次仮説が、これで一つ落ちた。

「じゃあ、記録側の書き漏らしか」

俺はモニタから目を離して、姿勢制御コンソールのほうに寄りかかった。腕を組む。「制御プログラムのどこかで、担当者が case を一本、書き忘れたんだろう」

俺は前任者の仕事を悪く言うつもりはない。前任者はうちの航法系の制御プログラムを一手に見ていた人で、丁寧な仕事をする男だった。ただ、機能追加の連続する現場で、case の分岐が状態の数だけ並ぶようになったら、誰でもどこかで一本落とすものだ。俺自身、機関室の制御でそういう見落としを何度もしたことがある。書ける場所が多すぎるコードは、書き手を選ばず落とし物をさせる。

ミラは、俺の言葉に頷きもせず、否定もしなかった。ただ、モニタに顔を向けたまま、鎖から指を離して、こう言った。

「見てみようか。書き漏らしがあるとしたら、どこか」

俺は姿勢制御コンソールから体を起こして、モニタ席の隣の空いた席に腰を下ろした。前任者の残した制御プログラム——DockingController という名前のクラスが、ミラの三面のうち真ん中に呼び出されていた。俺はふと、整列の完了判断について考えていた。整列というのは、姿勢がロックされたと判断して、そこから連結に進む段のことだ。本来、整列の完了判断は機関士の腕の仕事だ。姿勢がロックされたと機関士が判断して、次のクランプ接続の許可を出す——それが運航の筋だ。制御プログラムがそれを代替してくれるのは効率のためで、俺はそれ自体に文句はない。代替した先の、その一段深いところで、書き漏らしがあった、というのが今回の話らしい。

そこがどうにも、静かに癪に障る。

第2幕 解析——十五マスと、書ける場所が多すぎたこと

ミラは真ん中のモニタを最初のほうまで巻き戻して、DockingController の初期化のあたりを俺に見せた。

1
2
3
4
5
6
7
8
class DockingController
  attr_reader :state, :log

  def initialize
    @state = :approaching
    @alignment_locked = false
    @log = []
  end

@state = :approaching。行頭にコロンが付いている。

俺はこの書き方に見覚えがあった。以前ミラに教わったことがあって、コロンで始まるやつはシンボルという。文字列と何が違うかというと、同じ名前のシンボルは船内でたった一つしか作られない。だから比較が速い。文字列で "approaching" == "approaching" と比べると Ruby は中身を一文字ずつ突き合わせて調べるが、シンボルで :approaching == :approaching と比べるのはただの同一物比較——「同じ一つのオブジェクトか?」の照合だけで済む。軽くて、速くて、名前を持たせるのに向いている。名札を貼るだけの用途に、実に素直な道具だ。俺はこの数年、この道具のことは肯定的に思っている。だから、モニタの一行目を見て、俺は特に警戒はしなかった。

@alignment_locked = false。これは真偽値のフラグだ。姿勢がロックされたかどうかを覚えておく、ただの旗だ。ここも、それ自体は普通の書き方に見える。

ミラは真ん中の一面に、advance メソッドの中身を呼び出した。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
def advance
  case @state
  when :approaching
    @state = :decelerating
    @log << "接近を確認、減速に入る"
  when :decelerating
    @state = :aligning
    @log << "減速完了、整列に入る"
  when :aligning
    @state = :latching
    @log << "整列完了、連結に入る"
  when :latching
    @state = :sealed
    @log << "連結完了、加圧に入る"
  when :sealed
    raise "already sealed"
  end
end

case の分岐が五本並んでいる。接近から減速へ、減速から整列へ、整列から連結へ、連結から加圧へ、加圧のあとは加圧のあと、というだけの、素直な分岐だ。

俺は目を細めて :aligning の三行を読み直した。@state = :latching@log << ...。三行。単純だ。

ミラは、それを俺に見せたまま、隣の二面に abortalign_confirm を呼び出した。中断のメソッドと、姿勢一致率を渡すメソッドだ。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
def abort
  case @state
  when :approaching, :decelerating, :aligning
    @state = :approaching
    @alignment_locked = false
    @log << "中断: 接近へ戻る"
  when :latching
    raise "cannot abort during latching"
  when :sealed
    raise "cannot abort after sealed"
  end
end

def align_confirm(match_rate)
  case @state
  when :aligning
    if match_rate >= 0.98
      @alignment_locked = true
      @log << "姿勢一致率 #{match_rate}: ロック"
    else
      @log << "姿勢一致率 #{match_rate}: 継続"
    end
  when :approaching, :decelerating
    raise "not aligning yet"
  when :latching, :sealed
    raise "already past aligning"
  end
end

三つのメソッドが、同じ形の case @state を持って横に並んでいる。

「マスが多いな」

俺は独り言のようにそう言った。三つのメソッドが、それぞれ五本の分岐を抱えている。三かける五で、十五。一つのクラスの中に、状態を確かめる分岐が十五本ある。俺は普段、機関の制御プログラムを扱うが、制御系ってのはたいがい状態の把握がややこしくなる。十五本くらい、決して珍しい数ではない。

ミラは、それを俺の口が言い終えるのを待って、四つ目の何もないモニタに手を伸ばした。指先で空欄の枠を描く。縦に三つ、横に五つ。表になった。上の縦軸に advance / abort / align_confirm、左の横軸に :approaching / :decelerating / :aligning / :latching / :sealed

「一マスずつ、埋めていく」

ミラは俺と一緒に、十五マスを一つずつ埋め始めた。左上、advance × :approaching は「減速へ」。次のマス、advance × :decelerating は「整列へ」。順に埋めていく。俺は指でマスを追いながら、少しずつ気づいてきた。

一マスあたりの中身は、コードでいうと三行。三行の判断が、十五マスある。合計四十五行。俺はそれを口には出さなかったが、頭のなかで数えていた。制御プログラムというのは、機能追加のたびに状態が一つ増え、操作が一つ増える。状態が四つで操作が二つのときは八マスだった。それが十五マス。仮に加圧の後に「切り離し」を足すことになれば、六状態で十八マス。操作が一つ増えれば十八マスがまた掛け算で膨らむ。

モニタに広がった表の全景を、俺は目でひとまず捉えた。

Before の DockingController を advance / abort / align_confirm の3列 × approaching / decelerating / aligning / latching / sealed の5行に整理した15マスの状態遷移マトリクス。advance × aligning のマスだけが橙色で強調され、@alignment_locked 未確認の警告が付いている

三行の判断が並んだマスと、その中の一段が浅く見えるマスとが、俺の目には別の光り方に見えた。

俺は advance × :aligning のマスまで来て、そこで指を止めた。

「……あれ、@alignment_locked を確認する行がない」

ミラの指が、そのマスをすっと叩いた。

「ここ」ミラは低い声で言った。「:aligning から次に進むとき、姿勢がロックされているか確認する条件が、入ってない。他の十四マスは、三行ぶんの判断が全部書かれてる。ここだけ、中に一段深い条件が要るのに、その一段が抜けてる」

俺は肩から力が抜けるような感覚を、ほんの少し覚えた。

「担当者が書き忘れたな」

俺の言葉はまだ、一次仮説のほうを守っていた。担当者が case の中の一段を落とした。それで納得できる話だった。

ミラは、そこで手を鎖に戻した。ゆっくりと、また一つ、輪を手繰り直す。

「書き忘れたわけじゃないの」

ミラの声は、変にはっきりしていた。「書ける場所を、全部書いた。十五マスを、埋め切った」

俺は言い返しかけて、ミラが左のモニタに切り替えたコミット履歴のほうを見た。前任者の残した過去のコミットが、時系列で並んでいる。細かい字までは俺には読めなかったが、機能追加、機能追加、と続いているのは分かった。

「機能追加のたびに、状態が一つ増え、操作が一つ増える」ミラは指を鎖の輪から離さないまま、続けた。「そのたびに、書ける場所が九、十二、十五、二十とマスの数だけ増えていく。一つ増えるたびに、掛け算で増える。埋め切ろうとした担当者は、丁寧だった。丁寧すぎて、書ける場所が多すぎた——それが構造欠陥」

俺は言葉を探して、少し詰まった。

「一マスを書くのは、三行の判断」ミラの声が続いた。「それが十五マス、四十五行。書ける場所が多いほど、書き漏らしの確率は上がる。マスの数を減らすのが、担当者の仕事じゃない。マスの数を増やす形が悪い」

俺はモニタから目を外した。前任者の顔を、今日のこの場面で、責める気にはならなかった。俺だって、同じ形の case を十五マス埋めろと言われたら、どこかで一マスの中の条件を一段浅く書く。それが機能追加の三度目か、四度目か、あるいは急ぎの案件のさなかかは分からないが、いずれ、どこかで、必ず落とす。書ける場所が多いということは、書き手を選ばずに落とし物をさせる、ということだ。

俺はそこまで考えて、ふと一つの案が頭に浮かんだ。

「マスの数を減らすには、状態ごとに部品を作って、丸ごと差し替えりゃいい」

俺は椅子の背に体を戻して、続けた。「前に星図室でやった、航路の切り替えみたいに。あれは、走行前にこの航路を使いますって選んで、選ばれた部品を差し込む形だった。同じことを状態でやれば、十五マスは五つの部品に散る」

ミラは、指を鎖から一瞬離して、俺のほうを向いた。

「あれは外から選ぶの」

ミラは短く、けれど正確に言った。「あなたが操作したの、憶えてる? 航路を選んだのは、あなただった。今回のは違うの。今回のは、内側で自動に切り替わる。あの航路のときは、外の人間が選ぶまで航路は動かない。今回のは、現在の状態が『次はどこ』を持っていて、あなたが advance を呼ぶたびに、そっちが勝手に切り替わる」

俺は少し考えて、「じゃあ、名前は?」と聞いた。

「あとで見せる」ミラは指を鎖に戻した。「今はまず、十五マスを、マスじゃない形に」

俺は納得はしかねたが、話を進めることにした。

ミラはそこで一度、モニタから体を起こして、姿勢制御コンソール脇の端末に手を伸ばした。前任者の残した DockingController を、その端末で走らせる。ミラの入力は静かで、俺の目には、指が短くコンソールを叩いているのだけが見えた。

1
2
3
4
5
controller = DockingController.new
controller.advance   # :approaching → :decelerating
controller.advance   # :decelerating → :aligning
controller.align_confirm(0.85)
controller.advance   # :aligning → :latching

姿勢一致率は 0.85。閾値の 0.98 に、まったく届いていない。@alignment_lockedfalse のままだ。それでも、controller.advance が呼ばれると——モニタに @state の表示が出た。

:latching

俺は口の中で「ああ」と言った。

ロックされていないのに、:latching に入っていた。コードは何もエラーを出さなかった。raise もしなかった。ただ、@state が黙って :latching に書き変わっただけだった。

俺は舷窓のほうを一瞬見た。深宇宙の暗さ。実機のドッキング演習で、この瞬間、船の実像はどう動くのか。姿勢のズレたまま、クランプが噛み合わせを試みる。金属が金属を掴む前に、ぎゅっと空回りする。姿勢センサーが物理のアラームを鳴らして、クランプは半噛みで止まる——最悪の場合、燃料補給ラインがねじれる。実機の側は、鳴る。鳴るが、コードの側は、無症状だ。@state は正常に、遷移した。

俺はしばらくして、独り言のようにこぼした。

「前に、コードの側でエラーにもならず、値だけが違う話をしていたな。今度のは、それとまた別か」

俺は少し前に、うちの積み荷区画の運賃計算で、順序の間違いで数字が違うだけの壊れ方を見せてもらったことがあった。エラーにならず、値だけが違う——それでも数字は表に出るから、突き合わせで見つかる。今度のは、そうではない。エラーは出ないし、値が違うわけでもない。順番が違うだけだ。しかも、その順番の違いが表に出るのは、コードのほうではなく、実機のほうだ。姿勢センサーが鳴り、クランプが空回りする。コードだけを見ている限り、この壊れ方は分からない。

俺の頭のなかで、静かな壊れ方の階段が、また一段深くなった。

ミラは、指を鎖に戻していた。そこで、一度、その指がゆっくり止まった。それから——ミラは鎖の輪を一つ、指で外した。輪と輪の噛み合いを解いて、小さな一つの輪を、掌の上に載せる。金属の重みを、指先で確かめるみたいに、掌のなかでそれを転がした。それが済むと、ミラは掌を軽く閉じて、開いた。

「一マスに、一個」

ミラは短くそう言った。「それを、十五個じゃなくて、五個にする」

第3幕 引き直し——輪を、五つ

ミラは新しいエディタ画面を開いて、右のモニタに移した。

「一つずつ、輪にしていくの」ミラは指で、掌の輪を軽く握った。「まず、接近から」

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
class Approaching
  def name = :approaching

  def advance(context)
    context.transition_to(Decelerating.new, "接近を確認、減速に入る")
  end

  def abort(context)
    context.transition_to(Approaching.new, "中断: 接近へ戻る")
  end
end

俺はコードを追った。Approaching という新しいクラス。最初の行に、見慣れない書き方があった。def name = :approachingdef の後にイコールが並んで、その後ろにシンボルが一つ。俺が首を捻る前に、ミラが一言添えた。

「Ruby 3.0 から使えるようになった、短い書き方。def name; :approaching; end と同じ意味。中身が短いメソッドを、一行で書ける」

俺は納得した。中身が return :approaching だけの、名前を返すだけのメソッドだ。end を書かないから、エンドレスメソッドというらしい。俺はうちの制御系ではまだ使ったことがなかったが、シンボルを返すだけの名札用のメソッドには、確かに向いている。

advance(context) は、context.transition_to(Decelerating.new, "接近を確認、減速に入る") の一行。abort(context) は、context.transition_to(Approaching.new, "中断: 接近へ戻る") の一行。中身が薄い。中身が薄いというより、「次にどこへ行くか」だけしか書かれていない、と言ったほうがいい。

引数の context については、俺は最初、少し戸惑った。Before では @stateDockingController の中に住んでいたのに、今度の状態クラスは外から context を渡してもらう形になっている。誰が context を渡すのか。ミラが端末の下のほうを指した——後で見る DockingController#advance の中身は、@state.advance(self) の一行だった。context の正体は、呼び出し元の DockingController インスタンス自身だ。状態クラスから元締めに「次はここへ」と押し返すために、self を渡してもらう。それだけの話だった。

俺は少し気になったので、素直に聞いた。

「クラスに親がないな。前にミラに教わったときは、こういうときには何か共通の抽象基底クラスってやつを継がせるって聞いた気がするんだが」

ミラは頷いた。「他の言語ではそう。Java や Kotlin や C# なんかは、State を書くときに State っていう抽象基底クラスか、インタフェースを置いて、そこから継承させる。Ruby は違うの。Ruby は同じ形のメソッドを持つ兄弟クラスで足りる。この Approaching と、あとで見せる Decelerating が、両方 advance を持ってる——それだけで、Ruby は『同じ形』として扱える。型で聞かず、呼べるかで判断する」

以前ミラに教わった、あの話だった。ダックタイピングというやつだ。呼べるなら通す。呼べないなら鳴る。Ruby はそういう言語だ。今回の兄弟クラスも、その流儀に載っている。俺は「なるほど」とだけ返した。

ミラは指で掌の輪を軽く立てた。

「これで、一個」

そう言って、掌にその輪を置く。それから隣に Decelerating を書き足した。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
class Decelerating
  def name = :decelerating

  def advance(context)
    context.transition_to(Aligning.new, "減速完了、整列に入る")
  end

  def abort(context)
    context.transition_to(Approaching.new, "中断: 接近へ戻る")
  end
end

Approaching と、同じ形だ。名前を返すメソッドが一本、次に進むメソッドが一本、中断のメソッドが一本。中身は、次にどこへ行くか、それだけ。俺はうすうす、この五つのクラスがだいたい同じ形をしていることを想像した。

「これで、二個」

ミラは、次の輪を掌に立てた。掌の上には金属の輪が二つ、静かに並んでいる。

「次が、整列」ミラの声が、少しだけ低くなった。「ここが、少し違うの」

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
class Aligning
  def name = :aligning

  def initialize
    @locked = false
  end

  def align_confirm(context, match_rate)
    if match_rate >= 0.98
      @locked = true
      context.record("姿勢一致率 #{match_rate}: ロック")
    else
      context.record("姿勢一致率 #{match_rate}: 継続")
    end
  end

  def advance(context)
    raise "姿勢が未ロックのまま連結には進めない" unless @locked

    context.transition_to(Latching.new, "整列完了、連結に入る")
  end

  def abort(context)
    context.transition_to(Approaching.new, "中断: 接近へ戻る")
  end
end

俺は、そのコードを二度読み直した。

initialize の中に @locked = false。姿勢がロックされたかどうかを覚えておく、フラグだ。俺はそこで、椅子の背に体を戻して、少し引っかかった。

「またフラグか、これ」

俺は率直に言った。前任者の DockingController にも、@alignment_locked という同じ意味のフラグがあった。それを片づけたかったんじゃなかったのか。フラグに戻ったなら、書き方は変わっても中身は変わらないんじゃないか。

ミラは、指を鎖の輪から離して、俺のほうを向いた。二秒くらい、俺の目を見て、それから短く言った。

「フラグを、捨てたわけじゃないの」

ミラの声が、少し正確になった。「フラグの持ち主を、変えた。前は @alignment_lockedDockingController の持ち物だった。だから、三つの case 文がそれぞれ独立に確認する必要があった。advance の中でも、abort の中でも、align_confirm の中でも、同じフラグを気にしていた。書ける場所が、三つあった。今度は違うの。@locked は Aligning という状態の持ち物。Aligning#advance の中の一回のチェックで、済む。フラグは同じでも、持ち主が違うと、書ける場所の数が違う」

俺は、掌の上の輪と、モニタの Aligning を、交互に見た。

「……ああ、なるほど」俺は言った。「前は、三マスで同じフラグを見ていた。今度は、一マスで見ればいい。マスが減った」

ミラは頷いた。「確認と、進行が、同じクラスの中にある。書き漏らしようがない」

そう言って、三つ目の輪を掌に立てた。

俺は advance のなかの raise を、あらためて読み直した。raise "姿勢が未ロックのまま連結には進めない" unless @locked。書き方だけ見ると、raise にクラス名がついていない。ただの raise "メッセージ" だ。

ミラは、俺の視線を追って一言添えた。「raise にクラス名を書かないと、Ruby は RuntimeError を投げる。ふつうの実行時エラー。既定の型」

俺は納得した。寄港手続きを引き直したときは、埋め忘れの警報として NotImplementedError を使っていた。あれは「この環境では未実装ですよ」と伝えるための、少し系統の違うエラーだった。今回のは、そうではない。今回の raise は、素直に「姿勢が未ロックのまま連結には進めない」と言うためだけの、素の実行時エラーだ。用途が違うから、型が違う。それだけの話だ。ちなみに、RuntimeErrorStandardError の下にいる系統で、後で書く模擬航走の中で assert_raises(RuntimeError) として素直に拾える。

そして、四つ目。ミラはエディタの新しい下のほうに、Latching を書いた。

1
2
3
4
5
6
7
class Latching
  def name = :latching

  def advance(context)
    context.transition_to(Sealed.new, "連結完了、加圧に入る")
  end
end

俺は、上から順に読んだ。名前を返すメソッドが一本、次に進むメソッドが一本。それで、終わり。俺は少し首を伸ばして、下にもコードが続いていないかを確かめた。続いていなかった。

「他の二つは?」

俺は聞いた。他の三つの状態には、それぞれ abortalign_confirm が付いていた。この Latching には、二つとも、無い。

「書かない」

ミラは、静かにそう言った。「意図的に、書かない」

俺は少し面食らって、モニタとミラを見比べた。「書き忘れじゃなくて?」

「書き忘れじゃない」ミラの声は、揺るがなかった。「書かないという、設計判断。連結中に中断できたら、物理的に危険。クランプが半噛みのまま残るような操作を、コード側が受け付けてはいけない。だから、Latching に abort メソッドを、そもそも存在させない。存在しない道は、通れない。align_confirm を書かないのも同じ。姿勢の照合は整列のうちに済ませる話——連結に入ってから確かめ直しても、もう遅い」

俺は、そこで少し止まった。

「呼んだら、どうなる」

俺は聞いた。ミラは、掌の輪をもう一つ立てた。四つ目の輪が、掌に並んだ。

「NoMethodError。呼べないメソッドを呼んだときに、Ruby が投げる例外。エラーメッセージは、たとえばこう出る」

ミラは端末に切り替えて、その場で試してくれた。

1
NoMethodError: undefined method 'abort' for an instance of Latching

undefined method 'abort' for an instance of Latching。どのクラスの、どのメソッドが存在しないかが、そのままの言葉で書かれている。俺は、そのメッセージを目で追った。

「ちなみに、寄港手続きのときに使った NotImplementedError とは、系統が違うの」ミラは付け加えた。「Ruby は rescue に何も引数を書かないと、StandardError の下だけを拾う——これが『普通の rescue の網』。NotImplementedErrorScriptError の系統だから、この網にはかからない。NoMethodErrorStandardError の系統だから、素直にかかる。系統が違うから、使い分けは大事」

俺は覚えておくことにした。呼べないメソッドは、素直に NoMethodError。普通の rescue で拾える。

そこでミラは、モニタの DockingController——前任者のもの——のほうに、視線を戻した。俺もそれを追った。三つのメソッドの中に、四十五行の case が並んでいた、あの元のコード。

「書けてしまう case の代わりに、書けない場所そのものを作った」

ミラは、そう言った。「Before は、abort × :latching のマスがあった。そのマスに raise を書いて『連結中は中断できません』と伝えていた。書けてしまうから、書いていた。今度は違う。Latching#abort は、そもそもメソッドとして存在しない。書けないなら、書き漏らせない」

俺は、あらためて Latching のコードを見た。abort が、無い。align_confirm も、無い。中身が薄い、を通り越して、「書かれていない」領域が、明示的に空いている。書かれていない場所に、書き漏らしはない。

「なるほど。書けないなら、書き漏らせない」

俺の口が、そう繰り返した。

そして最後、五つ目——ミラは Sealed を書いた。

1
2
3
class Sealed
  def name = :sealed
end

俺は苦笑してしまった。中身が、名前を返すメソッドだけだ。それ以外は、何もない。advance もなければ、abort もない、align_confirm もない。全部、無い。

「加圧完了のあとは、どこにも進まない、中断もしない、姿勢確認もしない」ミラは指で掌の輪を五つ目に立てた。「全部の操作が NoMethodError で鳴る。呼んでも、無い。『終わり』も、明示的に、一つの状態として存在する」

俺は、掌の上に並んだ五つの輪を見た。銀色の小さな輪が、五つ、綺麗に立っている。ミラはそれを両手で軽く持ち上げた。持ち上げた鎖の輪は、前の輪と次の輪の噛み合いだけで、一列に繋がっている。噛み合っていない輪同士は、どこにも触れていない。

「前の輪と、次の輪だけが繋がっている。それ以外の輪は、知らなくていい」

掌の五つの輪と、モニタの五つのクラスが、俺の視野のなかで重なった。

After の5つの状態クラスを左から右へ Approaching → Decelerating → Aligning → Latching → Sealed の連鎖として表した図。Aligning までは生きた実線枠、Latching と Sealed は書かないメソッドがある破線枠。矢印はすべて advance で、Aligning → Latching の遷移には @locked 確認が付く

噛み合わない輪同士の空欄には、そもそもコードが書かれていない。

ミラはそう言って、鎖をゆっくりコンソールの端に置いた。それから、モニタを一つ下にスクロールした。DockingController の、After のほう。俺はそこで、少し驚いた。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
class DockingController
  attr_reader :log, :state

  def initialize
    @state = Approaching.new
    @log = []
  end

  def advance
    @state.advance(self)
  end

  def abort
    @state.abort(self)
  end

  def align_confirm(match_rate)
    @state.align_confirm(self, match_rate)
  end

  def state_name
    @state.name
  end

  def transition_to(next_state, message)
    @state = next_state
    @log << message
  end

  def record(message)
    @log << message
  end
end

advance は、@state.advance(self) の一行。abort は、@state.abort(self) の一行。align_confirm は、@state.align_confirm(self, match_rate) の一行。

case 文が、どこにも、無い。

四十五行あった case の中身が、五つの状態クラスに散って、DockingController の側は「今の状態に頼む」だけの、三本の一行委譲になっている。

「巨大 case 文が、どっかに行った」

俺はそう言った。ミラは軽く笑って、「状態のクラスに、住所を変えた」と言った。「一箇所に集めて十五分岐で管理するんじゃなくて、五つの状態それぞれが自分の二、三操作分だけを知る形にした。しかも、書かない自由がある。Latching に abort を書かない。Sealed に何も書かない。書ける場所が減れば、書き漏らす場所も減る」

俺はもう一度、DockingController の下のほう——transition_torecord のあたりを読んだ。ここに少し引っかかりがあった。

「状態クラスが context.transition_to を呼んで、context が @state を書き換える」俺は指で行を辿った。「誰が誰を呼んでるんだ、これは」

ミラは、静かに答えた。「状態クラスは、context を客のように迎えて、context の『遷移させて』ボタンを一つ押す。context は状態が押したボタンに従って、次の状態をセットする。呼び返し合ってるように見えるけど、役割は分かれてる——状態は『次はここ』と指し示すだけ、context は『指されたところに、移す』だけ」

俺は口の中で「指す側と、移す側か」と繰り返した。それでモニタの流れは、俺のなかで一本の線になった。指す仕事と、移す仕事が、別の場所にある。混じっていない。

俺はそこまで話について行けたところで、Act2 の終わりに預けていた宿題を思い出した。「状態ごとに部品を作って、丸ごと差し替えりゃいい」——俺の思いつきに、ミラは「あれとは違う」とだけ言って、名前を後回しにしていた。そしていま俺の目の前には、また別の、既視感のあるコードが並んでいる。

「これは、寄港手続きのときの型と似てるな」

俺は口に出した。アルタイル・ステーションで許可保留になった、あのときの話だ。あのとき、俺たちは寄港手続きの手順を親クラスに一本化して、港ごとに違う一部分だけを子クラスに委ねる書き直しをした。手順の骨格を親が持って、隙間を子が埋める形。今の DockingController の姿と、少しだけ似ている。骨格が一本にまとまっていて、実処理が別クラスに散っている——そう見える。

ミラは、鎖を掌に握って机の端に置き、両手を胸の前で開いた。片方の手のひらを大きく広げ、もう片方は指を順に立てる仕草をした。

「形は似てる。動き方が、違うの」

ミラは、丁寧に言った。「あれは、dock を呼ぶと、骨格の並びを最初から最後まで、一気に流す。一回で、全部、通る。船が一つの港の手続きを、一回で走り抜ける。走り抜けたら、その船とその手続きの関係は、そこで終わる」

俺は聞いていた。

「今日のは、外から advance が呼ばれるたびに、一歩だけ、進む」ミラは片方の指を、机の上に一つ立てた。「一回で、一歩。同じ DockingController のインスタンスが、内側の @state を差し替えながら、時間をかけて五つの状態を渡り歩く。次の advance を呼ぶまで、船は『今どこにいるか』を、持ち続ける」

俺は、そこで少し考えて、口を挟んだ。

「……一回で全部通るのと、一回で一歩と」

ミラは頷いた。「もっと大事な違いがあるの。聞いて」

俺は聞き耳を立てた。

「Template Method には、『今どこ』がない」ミラは、片手の指をゆっくり閉じた。「一回の呼び出しが終わったら、次の呼び出しではまた最初から流れる。Template Method は、『終わり』を含む、一連の流れ。走り終わりまで含めて、一つの手順」

俺は聞きながら、あのときの dock を思い出した。確かに、あの dock は一回呼べば、寄港手続きの最初から最後までを全部やって、そこで返ってきた。呼び出す側は、途中を持たなかった。

ミラは、もう片方の手を軽く開いた。「State は、『今』を持ち続けて、次の呼び出しを待つ。State は、『終わり』ではなく、『今』を、握る。次の advance が呼ばれたときの答えは、『今どこ』で決まる」

俺は、そこで少し詰まって、素直に聞いた。

「じゃあ、State は終わらないのか」

ミラは頷いた。「加圧完了——Sealed まで進んだら、そこで『終わり』を持つ状態にたどり着く。ただ、その『終わり』も、一つの状態として、明示的にある。State パターンでは、『終わり』も状態の一つで、『今』のリストに含まれる。Sealed は『何もしない』を担当する状態——だから advance も abort も align_confirm も、そもそも存在しない」

俺は、“終わり” の状態、というのを口の中で繰り返した。あの Sealed は、名前を返すだけのクラスだった。中身が薄いのではなくて、中身が無い、というのが、あれの正しい姿だった。あれが State パターンにとっての「終わり」だった。

ミラは、机に置いた鎖をゆっくり持ち上げた。持ち上げた鎖の五つの輪に、指を一つずつ順に這わせながら、言った。

「並びを、親の一本に固定するのが Template Method。並びを、状態の連鎖に、分散させるのが、こっち。『終わり』を持つ型と、『今』を握り続ける型は、名前が、違うの」

そして、鎖を机に戻した。

「これを、State——状態パターンっていうの。状態そのものをクラスにして、それぞれが次の遷移だけを持つ。GoF の振る舞いのパターン。動く物の、動き方を型にしたもの」

俺は、名前を口の中で反復した。State。状態パターン。ミラの言葉は、そこで一拍だけ、間を置いた。

「順番は、書き並べるものじゃなくて、渡っていくものよ」

俺はその言葉を聞いて、机の上の鎖を、あらためて眺めた。前の輪と、次の輪だけが繋がっている、五つの輪。書き並べるのは、Before の十五マスの表みたいなやり方だ。並べて、埋めて、書き漏らす。渡っていくのは、鎖のほうだ。一つの輪から、次の輪へ。飛び越しは、輪の噛み合いが許さない。

俺は右手を、自分の膝の上でゆっくり動かしてみた。

まず、握る。それから、開く。次に、指を鳴らす。最後に、掌を、天井に向ける。

俺は掌を天井に向けたまま、しばらく止めた。それから、独り言のように言った。

「掌を上に向けたまま、いきなり指を鳴らそうとしたら、動きが繋がらないな」

俺は掌を下に戻して、続けた。「握るからは、開くに行ける。開くからは、指を鳴らすに行ける。指を鳴らしたら、掌を天井に向けられる。順番があって、その順番だけが、繋がっている」

俺は言葉を止めて、掌を眺めた。

「今どこ、か。それだけ握ってれば、次はここしかない」

ミラは、黙っていた。俺の手の動きを、確かめるみたいに見ていた。頷きもしなかったし、答えもしなかった。名前は、もうミラが付け終えたあとだった。俺の手のなかで、いま起こっているのは、聞いた名前を、俺の身体で確かめ直しているだけの動作だった。

俺は掌を膝に戻して、モニタのほうに視線を戻した。ミラの声が、静かに続いた。

「存在しない遷移が、コードの上に、存在しなくなる。書けるものと、書けないものが、分かれる。書けない場所は、書き漏らせない」

俺は繰り返した。

「書けないものは、書き漏らせない。……書ける場所の数を、減らすってことか。十五個を、五個に」

第4幕 模擬航走——エラーにならず、値も違わず

次のランデブーまで、まだ十時間ほど残っていた。

ミラは新しいエディタを開いて、模擬航走を書き始めた。前任者の Before::DockingController と、いま引き直した After::DockingController——両方が、同じ模擬航走で比べられるようにしておく。挙動を変えないことを、先に証明しておくためだ。

まず、正常系。姿勢一致率 0.99 でロックが立つ、まっとうな順の接舷を、Before と After で流す。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
def test_before_and_after_produce_identical_logs_on_normal_flow
  before = Before::DockingController.new
  after = After::DockingController.new

  [before, after].each do |controller|
    controller.advance                # approaching → decelerating
    controller.advance                # decelerating → aligning
    controller.align_confirm(0.99)    # ロック
    controller.advance                # aligning → latching
    controller.advance                # latching → sealed
  end

  assert_equal before.log, after.log
  assert_equal :sealed, before.state
  assert_equal :sealed, after.state_name
end

assert_equal before.log, after.log——挙動を変えていないことは、先頭に置く。寄港手続きのときも、積荷のオプションを重ねたときも、同じ規律だった。ここが緑にならないと、話は始まらない。

俺は結果のほうを覗き込んだ。全 3 runs / 7 assertions / 0 failures。緑だ。Before も After も、正常系では同じログを吐き、同じ加圧完了に着地する。

次が、本題。事故シナリオ。姿勢一致率 0.85 で、ロックが立たないまま advance を呼ぶ。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
def test_incident_scenario_shows_the_two_different_failure_modes
  # --- Before: 書き漏らしのため latching へ素通りしてしまう ---
  before = Before::DockingController.new
  before.advance                  # → decelerating
  before.advance                  # → aligning
  before.align_confirm(0.85)      # 一致率不足、@alignment_locked = false のまま
  before.advance                  # 書き漏らしにより latching に到達してしまう

  assert_equal :latching, before.state

  # --- After: Aligning#advance の @locked チェックで RuntimeError ---
  after = After::DockingController.new
  after.advance                   # → decelerating
  after.advance                   # → aligning
  after.align_confirm(0.85)       # 一致率不足、@locked = false のまま

  err = assert_raises(RuntimeError) { after.advance }
  assert_match(/姿勢が未ロック/, err.message)
end

俺は結果を見ながら、少し息を吐いた。Before は :latching に、素通りで到達している。実機ならその瞬間、姿勢のズレたクランプが空回りするやつだ。After は同じ操作の最後の advance で、raise が鳴る。エラーメッセージには「姿勢が未ロックのまま連結には進めない」と、そのままの言葉が入っている。書けてしまう遷移が、書けない遷移に、化けている。

俺は椅子の背に体を戻して、独り言をこぼした。

「エラーにならず、値も違わず、順番だけが違う——」

俺は自分で自分の言葉を繰り返した。これまでに見てきた壊れ方は、どれも「実行時にしか鳴らない」だった。それが積荷のオプションを重ねた一件で、「エラーにもならず、値だけ違う」壊れ方が加わった。今度のは、それのさらに一段深いところだ。エラーにならず、値も違わず、順番だけが違う。しかもコードの側は無症状で、実機のほうで、金属が鳴る。静かさに、階段がある。

ミラは、俺の独り言を聞きながら、次の模擬航走を書き始めた。今度は、NoMethodError を明示的に確かめる模擬航走だ。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
def test_sealed_has_no_transition_methods
  sealed = After::Sealed.new
  controller = After::DockingController.new

  assert_raises(NoMethodError) { sealed.advance(controller) }
  assert_raises(NoMethodError) { sealed.abort(controller) }
  assert_raises(NoMethodError) { sealed.align_confirm(controller, 0.99) }
end

def test_latching_has_no_abort_or_align_confirm
  latching = After::Latching.new
  controller = After::DockingController.new

  assert_raises(NoMethodError) { latching.abort(controller) }
  assert_raises(NoMethodError) { latching.align_confirm(controller, 0.99) }
end

俺はこれを見て、少し感心した。テストの側が、「これは、書かれていないはず」を宣言している。単なる書き忘れなら、テストの側は書きようがない。書かないという設計判断だからこそ、テストの側で「存在しないこと」を確かめられる。書かない、を守る側が、書いてある。裏返しの丁寧さだ。

続いて、state_name の遷移。シンボルは、After でも捨てられていない。名札としての役目は、そのまま残してある。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
def test_state_name_returns_the_current_symbol_at_each_step
  controller = After::DockingController.new
  assert_equal :approaching, controller.state_name

  controller.advance
  assert_equal :decelerating, controller.state_name

  controller.advance
  assert_equal :aligning, controller.state_name

  controller.align_confirm(0.99)
  assert_equal :aligning, controller.state_name  # 状態は変わらない、@locked が true になるだけ

  controller.advance
  assert_equal :latching, controller.state_name

  controller.advance
  assert_equal :sealed, controller.state_name
end

シンボルは、Before で「軽くて、速くて、名前を持たせるのに向いている」と俺が肯定した性質を、そのまま持ち続けている。悪かったのは、シンボルではなくて、シンボル一つを一つの変数で回して、確認の case を全部の操作に散らばらせたことだった。名札としてのシンボルは、そのまま state_name に残っている。

次が、中断と、その回復。整列でロックまで立てたあとに、中断してみる。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
def test_abort_from_aligning_resets_locked_state
  controller = After::DockingController.new
  controller.advance                # → decelerating
  controller.advance                # → aligning
  controller.align_confirm(0.99)    # ロック

  controller.abort                  # → approaching に戻る
  assert_equal :approaching, controller.state_name

  controller.advance                # → decelerating
  controller.advance                # → aligning (新しい Aligning インスタンス、@locked = false)

  # ロック信号を受け取り直さない限り、advance は raise で止まる
  assert_raises(RuntimeError) { controller.advance }
end

俺はこれを見て、なるほどと思った。中断で Approaching.new に戻すと、次にまた整列まで進んだときに、新しい Aligning.new が作られる。新しく作られた Aligning@locked は、initializefalse に戻っている。フラグを別の場所でリセットする必要はない。状態のオブジェクトが新しく生まれ変わるから、ローカルなフラグも自動でリセットされる——ミラが軽く一言添えた。「状態を持ってるのは、状態のほうだから。生まれ変わったら、記憶も生まれ変わる」

最後の模擬航走は、状態を増やす実演だった。加圧完了のあとに、切り離しのフェーズを一つ足す想定。ミラは別のファイル——after_extension.rb を開いた。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
require_relative "after"

module After
  class Detaching
    def name = :detaching

    def advance(context)
      context.record("切り離しを実行")
    end
  end

  class Sealed
    def advance(context)
      context.transition_to(Detaching.new, "加圧完了、切り離しに入る")
    end
  end
end

俺は目を細めた。追加した中身は、二つだけだった。Detaching という新しいクラス一つと、Sealedadvance を一メソッド書き足したことと。それだけだ。他のクラスには、何一つ手を入れていない。ApproachingDeceleratingAligningLatching——四つの元のクラスは、after.rb のままだった。ミラは実際に、after.rb のほうを開かずに、after_extension.rb のほうだけで書き足していた。Ruby のクラスは、後から同じクラス名でもう一度 class を開けば、既存のクラスに新しいメソッドを追加できる——オープンクラスというやつだ。元の after.rbSealed の中身はそのまま、後から after_extension.rb の側で advance を「足す」形。「書き足し」は、元のファイルに触らずに済む。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
def test_detaching_can_be_added_without_modifying_existing_classes
  controller = After::DockingController.new
  controller.advance                # → decelerating
  controller.advance                # → aligning
  controller.align_confirm(0.99)
  controller.advance                # → latching
  controller.advance                # → sealed

  controller.advance                # ← 拡張後: sealed → detaching
  assert_equal :detaching, controller.state_name
end

Sealed は、元のコードでは name を返すメソッドしか持たなかった。加圧完了で「終わり」だった。今度は、Sealedadvance を一つ足したことで、加圧完了のあとに切り離しへ進める形になった。「終わり」を持つ設計から、「終わり」を持たない設計への転換も、既存の五つのクラスを一切書き換えずに、一メソッド追加だけで済む。俺は、コードの並びを眺めながら、そう思った。

模擬航走の結果を、俺はまとめて確かめた。三つのテストファイル、合わせて 11 runs / 22 assertions。全部、緑だった。ruby -w を付けて走らせても、警告は一つも出なかった。

俺は椅子の背に体を戻して、舷窓のほうを一度、目を向けた。

補給ドロン——オーガス-3の微光は、まださっきと同じくらい遠い。もう少しずつは近づいているのだろうが、目の解像度では、まだ点にしか見えなかった。姿勢制御コンソールから、俺は一度、手を離した。ブリッジの空気は静かで、モニタの LED が薄く光っているだけの、水槽の底みたいな静けさが戻ってきていた。俺はしばらく、あの微光を目で追った。

俺の隣で、ミラが指を鎖に戻していた。まだ、五つの輪を、片手で握ったままだった。

俺はブリッジの空気に釣られて、そのままの姿勢で、少しだけ話を続けた。保証外事項の話だ。この引き直しでも、防げない類のことが、いくつかある。

一つ。Ruby は動的型付けで、間違いは実行時にしか鳴らない。これはシリーズを通して繰り返しているうちの支柱の話だ。今度の State パターンも、Ruby のこの性質からは逃げられない。もし俺たちが Aligningadvance を書き忘れたら、それは書いた瞬間には鳴らない。呼ばれるまで、鳴らない。だから模擬航走が、いまも生命線だ。今度の設計は書き漏らしを減らす、けれど無くすものではない。

二つ。Ruby には instance_variable_set という、外側から強引にインスタンス変数を書き換えるメソッドがある。もし誰かが Latching のインスタンスに向かって instance_variable_set(:@state, Approaching.new)——それは外側の話だから細かい書き方は控えるが——そういう形で強引に状態を戻したら、Ruby はそれを止めない。State パターンは書きやすくするだけで、言語の側で強制はしない。そこはレビューと、規約と、模擬航走で守る領分だ。

三つ。状態がごく少ない場合は、Before の case 分岐でも足りることがある。この航法術が最も効くのは、状態が五つから十くらいの規模、というのがミラの経験則だった。「マスが十五、二十を越え始めたあたりから、State に移すと明確に楽になる」とミラは言った。「マスが六つや八つで済むうちは、無理に引き直さなくていい」

四つ。State パターンは、遷移の仕組みを提供するだけで、「その仕様が正しいか」は、また別の話だ。姿勢一致率の閾値が 0.98 なのは、うちの船の運航規約で決まっている。0.97 でも 0.99 でもなく、0.98。この数字がどこから来ているかは、State の外の話で、これは航路管理局が定めた基準だ。State パターンは、その基準をコードのなかで書きやすくするだけで、基準そのものを決めない。「引き直しで安全になった」と言うときには、この線引きは、はっきり分けて言っておく必要がある。

ミラは頷いていた。俺は舷窓のほうに目を戻して、その姿勢のまま、しばらく黙っていた。

十時間ほどが経った。

俺は機関室に降りていた。オーガス-3との接舷本番は、俺の持ち場で仕事だ。姿勢制御と主機推力の細かい調整を、俺が手で握っている。ブリッジのほうにはミラが上がっていて、進行のログを見ている。二十年やっていて分かることの一つに、機関士と航法士は本番で場所を分けるのが安全、というのがある。互いの手元が近すぎない距離で、それぞれの仕事をする。今日もそうだ。

機関室のパイロットランプ盤には、五つの LED が縦に並んでいた。接近減速整列連結加圧。今度の本番の進行を、光の順に見せてくれる盤だ。俺はそれを、視野の隅で確かめながら、主機の逆噴射のタイミングを計っていた。

接近ランプが、緑になった。

俺は独り言で「一個目」と言った。オーガス-3との距離が縮まる。相手は無人だから、こちらの姿勢と推力に相手側が合わせてくることはない。姿勢の主導は、こちらが全部握る。

減速ランプが、緑になった。

「二個目」と俺はもう一度、独り言を言った。主機の逆噴射を最後の一段まで当てて、船の速度を接舷に必要な線まで落とす。姿勢制御のスラスターが、細かく応答している。俺の手のなかで、船は静かに減速していた。

整列フェーズに入った。

姿勢一致率が、モニタの数字でじりじり上がっていく。0.94。0.96。0.97。0.98——。

ここで、俺は少し息を止めた。

ここで、俺の腕で待つやつだ。二十年、俺はここで、待ってきた。姿勢がロックされたと機関士が判断して、次の連結許可を出す——その一秒か二秒か、あるいは十秒。数字の上がり方を見ながら、俺の指がスラスターの応答を、ぎりぎりまで詰める。ロックが立たなければ、俺が判断して、俺の口で言葉にする。それが、俺の仕事だった。

……今日は、コードが待ってくれる

今日の Aligning#advance は、@locked が立っていなければ、raise で止まる。俺が待つ代わりに、コードが、待つ。俺の手のなかの数字と、コードのなかの @locked が、同じ待ち方をしている。

数字が、0.98 を越えた。

整列ランプが、緑になった。

連結ランプが、緑になった。クランプが噛み合う音が、船体を伝って、機関室にまで軽く届いた。姿勢のズレは無い。噛み合わせは、一発で決まった。

加圧ランプが、緑になった。給油バルブが開き、燃料の流量計の針が、静かに動き始めた。

俺は主機のパネルから、いったん手を離した。

機関室の、その盤の前で、俺は独り言をこぼした。

「五個の輪が、順に光った」

俺はモニタの盤を、もう一度、目で追った。接近から加圧まで、五つの緑が縦に並んでいる。順に、鳴らずに、光っていった。

「書けない場所は、書き漏らせない——書かれてなかった場所は、そもそも道じゃなかった」

内線は、鳴らなかった。ブリッジからのミラの声も、今日は、無い。

俺は主機の状態を確かめて、給油の進行を目で追った。オーガス-3からの燃料が、規定の流量で流れ込んできていた。機関室の振動は、いつもの巡航のそれに戻り始めていた。俺は椅子の背に体を戻して、少しだけ、目を閉じた。

順番は、書き並べるものじゃなくて、渡っていくものだった。


🚀 オリオール号 航海日誌(Navigator’s Log)

  • 航法術(パターン名): State(状態そのものをクラスに引き上げ、各状態が「自分から進める次の遷移だけ」を持つ振る舞いパターン。ダックタイピングで実装した Ruby 版)
  • 発生事象(症状): 三日前の補給ドロン接舷演習ログで、整列フェーズの姿勢ロック確定信号の受信記録が抜けているのに、連結フェーズのタイムスタンプが記録されていた。実機側では姿勢のズレたまま連結クランプが空回りする。コード側は無症状(@state は正常に遷移した)
  • 原因(旧航路の問題): 状態を @state(シンボル)で持ち回り、三つの操作(advance / abort / align_confirm)の先頭で case @state の五分岐が並んでいた。五状態 × 三操作 = 十五マス。担当者は十五マス全部を丁寧に埋めたが、advance × :aligning のマスの中に必要な一段深い条件(@alignment_locked の確認)が抜けていた。書ける場所の総数が増えるほど、書き漏らしの確率は上がる
  • 処置(引き直しの要点): 状態を五つのクラス(Approaching/Decelerating/Aligning/Latching/Sealed)に引き上げ、各状態が自分の二、三操作分だけを持つ形に引き直した。Aligning は @locked を内部保持し、advance の一箇所でチェックする。Latching は advance のみ実装、abortalign_confirm は意図的に未定義(連結中の中断は物理的に危険)。Sealed は名前を返すメソッドのみ(「終わり」の状態、全操作 NoMethodError)。DockingController は現在状態に委譲する一行のみ。case 文が全部消えた
  • 保証外事項: Ruby は動的型付けで、実装忘れは実行時にしか鳴らない(模擬航走が生命線)/instance_variable_set などの反則技は言語では防げない(規約とレビューで守る)/状態数が少ない場合は Before の case 文で足りることがある(五〜十くらいがこの航法術の届く距離)/閾値の正しさ(0.98 か 0.99 か)は State の外の話、運航規約の領分
  • 模擬航走結果: 全 11 runs / 22 assertions、警告ゼロで全灯緑(正常系の Before=After 同値・事故シナリオの別々の鳴り方・NoMethodError の実演・state_name の遷移・abort と回復・状態追加の実演)
  • 次の針路: オーガス-3からの補給完了後、切り離しシーケンス(Detaching)を追加予定。Sealed に advance を一メソッド、Detaching クラスを一個。既存五クラスは無改変
comments powered by Disqus
システム開発・AIワークフローのご相談は Meetsource
Hugo で構築されています。
テーマ StackJimmy によって設計されています。