Featured image of post コードナビゲーター【Command】操舵訓練で消えた一手〜戻し方を知ってるのは、動かした本人だけ〜

コードナビゲーター【Command】操舵訓練で消えた一手〜戻し方を知ってるのは、動かした本人だけ〜

操舵訓練の一手をキーストロークで打ち間違えたとき、既に実行した yaw と thrust を戻す手段がコードに無く、日誌には "yaw変更" とだけ残っていた。動作をオブジェクトに引き上げ、実行と戻し方の両方をそのオブジェクトが持つ Command パターンを、Rubyの兄弟クラス群と履歴スタックで解説します。

第1幕 異変——訓練で消えた一手

シミュレータ室のドアを閉めると、通路のかすかな低音が急に途切れて、耳の底に自分の血の巡る音が戻ってきた。

俺はダグ。この恒星間貨物船〈オリオール号〉の機関士だ。船暮らしはもう二十年を超えていて、船の中の場所という場所にはたいてい用事で足を踏み入れてきた。それでも、シミュレータ室には年に一度しか下りない。ここは船の深部にあって、窓がひとつも無い。天井の照明は少し落としてあって、実運用のブリッジと同じ暗さを再現している。中央にはダミー操舵卓が一台、その周りにモニタが数面。船が実際に動いていない部屋の中で、船を動かす手つきだけを身につける場所だ。

〈オリオール号〉には、全乗員が年次で基本操舵の実技講習を受ける規定がある。事故で操舵手が動けなくなった場合の冗長性を、全乗員で担保するための安全規約だ。俺自身は今年は受験する年ではない。教官役の割り当てで来ている。教官は操舵席の斜め後ろに座って、受験者の実技を横で見る。危ないキーストロークの癖を指摘し、「なぜここでこの操作をした」を訊ねる係。手を出す係ではない、口を出す係だ。

今日の受験者は三ヶ月前に赴任してきた副操舵手見習い——カデットだ。基本操舵はほぼ問題ない、と別の教官から引き継ぎを受けている。ただし、緊急時のキーストロークが早すぎて、桁を一つ間違えることがある、とも書いてあった。俺はコンソールの脇の椅子に腰を下ろして、卓の上のモニタに目をやる。カデットは操舵席で、実技のメニュー画面を静かに開いているところだ。

卓の斜め横、別のコンソールにミラがいた。

〈オリオール号〉の航法士。俺の長年の相棒だ。ミラはシミュレータ室のもう一台の卓を引き寄せて、モニタの一面に訓練プログラムのコードを開いている。今日は俺の教官業の付き添いというわけではないらしい。ミラの左手はキーボードに置かれていない。卓の端に、トランプ大の小さな紙片が伏せて重ねて置いてある。五、六枚。縁は少しくたびれていて、幾度も指で摘まれたあとの手ざわりが遠目にも見える。何度も使い込まれた道具の顔をしている。

俺はそのカードの束を見て、口の中で「またか」と一言だけ吐いて、視線をカデットの操舵卓に戻す。ミラの手癖には慣れている。理由はいずれ本人が言うだろうから、俺から先に聞くことはしない。

「今日は、姿勢とバラストの数値だけ回すの」

ミラが、モニタから顔を上げて言った。俺にというより、卓の端のカードに向かって話しているような具合だ。「燃料は、次の寄港まで動かさない。今日の題材は、“戻せる操作"に絞っての話」。

俺はうなずいた。シミュレータだから、姿勢もバラストも、数値がモニタの中で動くだけだ。本物のスラスタなら、噴射した瞬間に燃料タンクの残量が実際に減る。減った燃料は、コードのどこを叩いても戻ってこない。今日はそういう話をする日ではない、ということだ。俺は椅子に浅く座り直して、カデットに軽く頷いてみせる。始めてくれ、の合図だ。

カデットは緊張気味に、一度深く息を吐いてから、実技メニューの最初の一手を叩いた。

helm.execute_yaw(+5.0)

モニタの yaw の表示が、0.0 から +5.0 に切り替わる。日誌欄に「yaw変更」の一行が追加される。カデットは軽く肩の力を抜いて、次のキーに指を伸ばした。バラスト配分を左三十パーセントに寄せる操作だ。指がキーを押した。数字を打ち込む——。

helm.execute_ballast(300)

音は無い。ただ、モニタの一部が赤くなった。コンソールの下段に、警告が浮かぶ:「ballast は 0..100 の範囲」。カデットの指が、途中で止まった。桁を一つ、間違えていた。三十と打つはずのところが、三百になった。

日誌欄には「yaw変更」の一行が、まだ一行だけある。二手目の記録は無い。バリデーションが弾いたから、@log には何も書かれなかった。

カデットは俺のほうに振り向いた。

「あの、教官、」と、少しかすれた声で言った。「今の、消してください、消せますか? 桁を一つ間違えました、三十と打つはずが、三百と打ってしまって——」

俺は口を開きかけて、途中で止まった。

消す手段は、コードに無い。

一手目の execute_yaw(+5.0) は、既に @yaw の中に足し込まれている。日誌に残っているのは「yaw変更」の四文字だけ。何度動いたか、どっちに動いたかは、書いてない。反対に打ち直すには「今、何度動いているか」を知る必要があって、それには「動かす前は何度だったか」も要る。カデットが動かした量は、モニタ上の実測値と、カデットの記憶の中にしか残っていない。

俺はカデットの肩に軽く手を置いた。声が上ずらないように、意識して低く言う。

「一旦、待て。yaw を反対に打ち返すのはあとでいい」

カデットは自分の日誌欄を、上から下まで一度なぞる。「これだけ、しか、書いてない」と小さくつぶやく。俺はうなずいた。書いてないから、復元できない。カデットが叩いた 5.0 という数字は、@yaw の中に足された値と、コードのどこにも数字として残っていない。俺の頭の中にすら、正確な値は残っていない——俺は目で +5.0 を見ただけで、暗記していない。

これは訓練だ。数値が狂うだけで済む。ただ、この HelmConsole は実運用と同じコードベースだ。俺は事故解析側の当事者を長年やってきた——機関士は事故のあと、ログを掘って原因を追う立場にある。実運用でこれが発動したら、日誌を掘っても「yaw変更」の一行しか出てこない。何度動いたかが復元できない事故解析は、俺の仕事にとって、最悪の状態のひとつだ。

俺は視線をミラに向けた。

「消せないのはいい——いや、良くはないが、後回しでもいい」と、俺はまずそう切り出した。「まず、記録だ。何度動いたか、どっちに動かしたか。それが日誌にちゃんと残ってれば、後から反対に打つくらいはできる。日誌に、打ったコマンド文字列でも残しとけばいい」

ミラは、モニタから顔を上げた。俺のほうを見て、それから卓の端の紙片の束のほうへ、体をひとつ動かした。一番上のカードを、伏せから表に返す。

黒インクで、はっきりと書かれていた。「yaw +5.0」。

ミラは静かに言った。「文字列で残しても、あとで “反対に打つ” を、誰が知ってる? ダグが知ってる? カデットが? マニュアルを引く? どこかの誰かに翻訳を頼む?」

俺は口を開きかけて、答えを出す前に閉じた。誰が知っているか、と問われると、答えが出ない。ダグが知っているとは限らない。カデットは緊張していて、後から自分の記憶を疑うかもしれない。マニュアルには「yaw を +5.0 動かした場合の反対は yaw を -5.0 動かす」なんて表は無い——当たり前だ。それは動作の話であって、静的なマニュアルに書ける話じゃない。

ミラは、続けた。

「“反対に打つ” を知ってるのは、動かした本人だけよ」

そう言って、カードをそのまま裏返す。裏には「yaw -5.0」と、同じ筆跡で書いてあった。表と裏が、ペアで書き付けてある。動かした本人が、自分の戻し方を、あらかじめ、一緒に書いておく——そういう紙片。

「動かした本人が、自分の戻し方も、一緒に書いておく」

ミラは、カードを一枚だけ、卓に表を上にして置いた。

カデットは、自分の実技セッションが中断状態にあることに気づいたらしい。「あの、少し、外の空気を——」と小さく言い残して、シミュレータ室のドアを開けて出て行った。ドアが閉まる小さな音が、深部のシミュレータ室の静けさに一度だけ響いて、消えた。カデットはたぶん、通路の給水機のところで、水を一杯だけ飲んでから戻ってくる。それまでの間は、俺とミラの二人だ。

ミラは、卓のもう一台のほうにコンソールを引き寄せた。

「見てみよう。動かした本人が、戻し方を持ってない——それが、今日の綻び」

第2幕 解析——呼び出しに溶けた三行

ミラは、俺の目の前のモニタに、Before の HelmConsole を呼び出した。

 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
class HelmConsole
  attr_reader :yaw, :ballast, :thrust_log, :log

  def initialize
    @yaw = 0.0
    @ballast = 50 # 左配分の百分率(右は 100 - @ballast)
    @thrust_log = 0.0
    @log = []
  end

  def execute_yaw(delta)
    @yaw += delta
    @log << "yaw変更"
  end

  def execute_ballast(left_pct)
    raise ArgumentError, "ballast は 0..100 の範囲" unless (0..100).cover?(left_pct)

    @ballast = left_pct
    @log << "バラスト調整"
  end

  def execute_thrust(delta)
    @thrust_log += delta
    @log << "スラスタ噴射"
  end
end

俺はモニタに顔を近づけて、上から下まで一度読み下した。

execute_yaw@yaw += delta@log << "yaw変更" の二行。execute_ballast は範囲チェックしてから @ballast = left_pct@log << "バラスト調整"execute_thrust@thrust_log += delta@log << "スラスタ噴射"。……全部、二行だな。実行して、日誌に一行残す」

シンプルだ、悪意も無い。ただ、と俺は独り言のように続けた。「“実行"って書いてあるからには、“戻す” もあってしかるべきなのに、この三メソッドには “戻す” ものが無い。動かして、日誌に “動かした” と書いて、それで終わりだ」。

ミラは、俺の指がモニタの @log << "yaw変更" の一行の上で止まっているのを見ていた。

「動作は関数呼び出しの中で消えたの」とミラは言った。「呼び出しの中に溶けて、記録に残らなかった」

俺は少し、その言葉を口の中で転がした。「溶ける、か。……確かに、execute_yaw(+5.0) を呼んだあと、この +5.0 はどこに残ってる? @yaw の中に足し込まれて、消えた。日誌には “yaw変更” とだけ」。ミラは頷いた。「そう。動作の実体は、@yaw の中に足された値と、“yaw変更” の四文字と、それだけ。“5.0” という数字は、どこにも残ってない。数字がどこにも残ってないから、“反対に打つ” ができない」。

俺はその「溶ける」という言葉の下に、事故解析側の実感を敷きたくなった。“yaw変更” の四文字は、動作の種類を書いているだけで、動作そのものを書いてない。あとから日誌を読み返した誰かが、「この時点で yaw は何度動いたのだろう」と思っても、その答えは日誌の中に無い。答えが無い記録は、記録として役立たない。

ミラは、モニタの脇に別のウィンドウを開いて、この HelmConsole の Git のログを画面外に呼び出したような素振りをした。俺の位置からは具体的な行までは見えない。ミラが引いたのは、担当者の名前でも、コミットのハッシュでもなく、この最初の実装が生まれた頃の意図のほうだ。

「この最初の実装、悪意はないの」とミラは言った。「日誌に “何かした” を残す意識は、あった。それが @log << "yaw変更" の一行——残す気は、あった。ただ、“何を・どれだけ・どっちに” を残す粒度と、“戻すには何をすればいいか” の情報が、抜けていた」

「粒度と、戻し方か」と俺は繰り返した。

ミラは頷いた。「そう。“動かした” だけを書いて、“何を動かしたか” と “どう戻すか” は、書いてない。書いてないから、あとから読み返しても、戻せない、解析もできない」

俺は椅子の背に少しだけ寄りかかった。前任者は、雑じゃない。日誌に一行残そう、と決めた時点で、記録する意志はあった。ただ、その一行の中に “何を” を書かなかった。“残す意識” と “残す粒度” は、別の話だ——粒度が抽象になった瞬間、記録は残っていないのと同じになる。実運用の日誌が肥大するのを避けたかった気持ちも分かる、当時の判断としては合理的だ。ただ、その判断は “動作が復元できる粒度” を担保しない。

ミラは、卓の端で伏せてあったカード束を、全て表に返した。コンソールの周りに、順に並べる。

「yaw +5.0」「ballast 50→30」「thrust +0.8」

三枚が横に並んだ。俺の視線は、自然にカードに落ちた。「表に、実行したこと。じゃあ、裏には——」と俺が言いかけると、ミラは無言で、カードを一枚ずつ、裏返していった。

「yaw -5.0」「ballast 30→50」「thrust -0.8」

表と裏が、ペアになった動作の書き付け。

「動作の一個一個に、“何を実行した” と “どう戻すか” の両方を書き込んでおく」とミラは言った。「一枚一枚が、自分の戻し方を、知ってる」

紙の一枚が、動作の一個。裏に、戻し方——俺は自分の中で、そう言葉にした。

ここで俺は、少しだけ手前に戻って確認したくなった。「これ、この前見せてくれたやつと、似てるな」。ミラが、この前ドッキングの制御プログラムを引き直したときの話だ。「オブジェクトに操作を持たせて、HelmConsole みたいなやつが委譲する。あの形だ」。

ミラは、卓の三枚のカードから、一枚だけを手に取って、掌に立てた。「形は近い。入れ物の数が違うの」。ミラは掌のカードを軽く持ち上げてから、卓に戻す。「あれは——Context が、現在の状態オブジェクトを一個だけ差し替えて持つ形。あの形は、Context が “今の状態” を、一個だけ 持つの。今日のは、“した操作” を、複数、順に積む」。

俺は卓の三枚のカードに視線を落とした。「一個じゃない、三個並んでる」

「あれは ‘今’ を握るの」とミラは言った。「今日のは ‘した’ を握る。握ってる場所が違うから、戻せる」

俺はそれを、口の中で繰り返した。「……握ってるものが、違う、と」。今はまだ、全部は繋がらない。ただ、“今” と “した” が別のものだ、というのは、なんとなく身体には入る。ミラは、詳しくはコードを見ながら、と続きを引き取った。

ミラは、コンソールに Before の HelmConsole をロードして、カデットの三手を再現した。yaw を +5.0 動かし、ballast に 300 を打ち込む——赤字の警告が出て、二手目の記録は残らない。そのまま thrust を +0.8 動かす。日誌欄には、["yaw変更", "スラスタ噴射"] の二行だけが並んでいる。

「今、消したい? 消せる?」ミラが訊いた。

俺は首を横に振った。「消せない。undo メソッドが無い。あったとしても、日誌に “yaw変更” とだけ残ってるやつを “反対に” 動かすには、動かした量が要る。その量が、日誌のどこにも残ってない」

ミラは無言で、モニタの @yaw の値を指した。+5.0。「この 5.0 は、@yaw の中に足し込まれて、日誌のどこにも数字として残ってない。反対に打つには、この 5.0 をどこかから取ってこないといけない——取ってくる場所が、無い」

そうだ。そこが、“呼び出しの中に溶けた” という言葉の意味だ。

ミラは、卓のカード束から、「yaw +5.0/yaw -5.0」のペアを一枚、手に取った。表を伏せてから、裏をめくって、裏の一行だけを、声に出して読み上げた。

「yaw、マイナス、五、点、ゼロ」

俺は目を丸くした。「……表を見ないで、裏だけを?」

「そう」とミラは言った。「戻すときに知りたいのは、“戻し方” の一行だけなの。実行した中身は、その一行を出せる場所に置いてあれば、あとで参照すればいい」

裏だけを、読む——動かした本人(カード)が、自分の戻し方を、知ってるから、こっちは、呼ぶだけでいい。俺は自分の中で、そう言い直した。

俺は椅子の背から体を起こして、コンソールに向き直った。「これを、コードにするのか」。

ミラは、頷いた。「動作の一個一個を、カードにする。カードには、表(実行)と裏(戻し方)が書いてある——それを、コードのクラスとして、立てる」

第3幕 引き直し——動作を、物にする

ミラは、新しいエディタ画面を開いた。

「一クラスずつ、見せる」

最初に書き始めたのは、YawCommand だった。

 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 YawCommand
  def initialize(delta)
    @delta = delta
  end

  def execute(context)
    context.apply_yaw(@delta)
  end

  def undo(context)
    context.apply_yaw(-@delta)
  end

  def to_log(kind)
    case kind
    when :done   then "[実行] yaw #{format_signed(@delta)}"
    when :undone then "[巻戻] yaw #{format_signed(-@delta)}"
    end
  end

  private

  def format_signed(v)
    v >= 0 ? "+#{v}" : v.to_s
  end
end

俺は画面を上から下まで一度読んで、最初の違和感を口にした。

「クラスに親がない。継承もインクルードも無い」

「Ruby は、同じ形のメソッドを持つ兄弟クラスで足りるの」とミラは言った。「このクラスと、この後出す二つが、executeundoto_log の三つを持ってる——それだけで、Ruby は、同じ形として扱う。型で聞かず、呼べるかで判断する」

これは、以前ミラが港ごとの方言の話をしたときに置いてくれた足場だ。Ruby は「これは何クラスか」を先に確かめるんじゃなくて、「呼べるかどうか」で判断する。だから、共通の親クラスを立てなくても、executeundoto_log を三つとも持っている三つのクラスは、コードの中で同じように扱える。俺の視線は execute(context)undo(context) の二行の間を、何度か往復した。

execute(context)undo(context)。……表と裏か」

ミラは頷いた。「そう。一枚のカードが、自分の実行と、自分の戻し方の、両方を知ってる」

ミラは卓のカード束から、「yaw +5.0/yaw -5.0」の一枚を、モニタの前にそっと置いた。「これが、YawCommand の一個」。

俺は、コードのほうに視線を戻して、もう一つ気になる点を訊いた。「context って何だ、この引数は」。

context は、HelmConsole のことよ」とミラは言った。「“実際に値を動かす場所” のこと。カードは、自分では yaw の値を持たない——値を持ってるのは HelmConsole の側。カードは HelmConsole に “yaw を、+5.0、動かして” と、頼む。それが context.apply_yaw(@delta) の、一行」

俺は少し、そこで止まった。カード(Command)は、“何を動かすか” と “どれだけ動かすか” だけを、知ってる。“どこの値を動かすか(HelmConsole の中)” は、知らない。値を実際に動かすのは、頼まれた HelmConsole の側だ。カードは頼む側、HelmConsole は動かす側。

context を、initialize じゃなくて execute の引数で受け取るのは、なんでだ」と俺は続けた。「カードが生まれるときに、動かす先の HelmConsole を、一緒に持たせておいてもよさそうだが」

「カードには、“何を・どれだけ” だけを持たせるの」とミラは言った。「“どの HelmConsole に頼むか” は、実行の瞬間に、外から差し込む。同じカードを、別の HelmConsole に対しても、使える形に、しておく——今日は一つの HelmConsole しか出てこないけど、後で複数の卓を持つ場面が来たら、この形が効く」

俺は頷いた。カードは、動かす対象を、事前に決めない——実行の瞬間に、外から渡す。カードの独立性を保つための、設計判断。

「役割が、二つに分かれてる」と俺は言った。「頼む側と、動かす側と」

ミラは、頷いた。「同じ動作を、頼む役と、動かす役に、分ける」

続けて、ThrustCommand を書いた。中身は YawCommand とほぼ同じ形だ。initialize@delta を持ち、executecontext.apply_thrust(@delta)undo-@delta を渡す。to_log の中身も同じ形。同じ形が、二つ。ミラは卓のカード束から「thrust +0.8/thrust -0.8」の一枚を、YawCommand のカードの隣に置いた。「これで、二個」。

次に、ミラは BallastCommand を書き始めた。

途中で、俺の目が止まった。

1
2
3
4
5
6
7
class BallastCommand
  def initialize(new_left_pct)
    raise ArgumentError, "ballast は 0..100 の範囲" unless (0..100).cover?(new_left_pct)

    @new_left = new_left_pct
    @prev_left = nil # execute の瞬間にセット
  end

「あ? @prev_left = nil?」

ミラは、頷いた。「これが今日の、一番大事なところ」。ミラは続きを書いた。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
  def execute(context)
    @prev_left = context.ballast # 実行前の値を、カードが自分の内側に書き留める
    context.apply_ballast(@new_left)
  end

  def undo(context)
    context.apply_ballast(@prev_left)
  end

  def to_log(kind)
    case kind
    when :done   then "[実行] ballast #{@prev_left}#{@new_left}"
    when :undone then "[巻戻] ballast #{@new_left}#{@prev_left}"
    end
  end
end

execute の中で、@prev_left に、context の今の ballast を書き込んでる」と俺は読み下した。「……何のためだ、これは」。

ミラは、卓のカード束から「ballast 50→30/ballast 30→50」の一枚を手に取って、掌に立てた。「Yaw と Thrust は、差分——delta で動かすの。だから、undo-@delta で戻る」。ミラは掌のカードを軽く振った。「Ballast は、絶対値の設定なの。context.apply_ballast(30) は、“ballast を 30 にする”、で、“30 動かす”、じゃない。“30 にする” の反対は、"-30 にする” じゃない——元の値に戻す。だから、- では戻せない」

俺はその言葉を、口の中で確かめた。絶対値と差分は、違う。“30 にする” の反対は、“元の値に戻す”。元の値を、知ってないと、戻せない。

「戻すには、実行前の値を、そのカードが、自分の内側に、書き留めておく必要があるの」とミラは言った。「execute の中で @prev_left = context.ballast の、一行。カードが、自分が動かす前の値を、自分の内側に、書き留める」

ミラは、掌のカードの裏面の余白を指差す真似をしてから、卓に戻した。「カードの表を書いた瞬間には、裏の “50→30” の “50” は、まだ書けない——ballast が今いくつかは、実行の瞬間に、見に行かないと、分からないから。戻し方の一部は、実行の瞬間にしか、手に入らない」。

「これは、Memento っていう、別の航法術と、混ざってるの」ミラは付け加えた。「“実行前の状態を、あとで戻すために取っておく” 発想。Command が Memento の役を兼ねる形も、ペアで使う形もある。今日は Command が、兼ねてる」

俺はカードの三枚目を、卓の他の二枚の隣に置いた。一つの型に、他の型のかけらが、混ざる。融通が利く。教科書のどのページに載っているのかは、俺には分からない。ただ、コードの中で、それが動いて、日誌に “何を・どれだけ・どちらに” が残るのなら、俺は充分だ。

ミラは、次に HelmConsole を書き直し始めた。

 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
34
35
36
class HelmConsole
  attr_reader :yaw, :ballast, :thrust_log, :log

  def initialize
    @yaw = 0.0
    @ballast = 50
    @thrust_log = 0.0
    @history = []
    @log = []
  end

  # Command から呼ばれる "実際に値を動かす" ヘルパー(副作用主体・1行)
  def apply_yaw(delta) = (@yaw += delta)

  def apply_ballast(pct) = (@ballast = pct)

  def apply_thrust(delta) = (@thrust_log += delta)

  # Invoker 相当:Command を受け取って「実行→履歴に積む→自己記述を日誌に残す」の3手を束ねる
  def execute(command)
    command.execute(self)
    @history << command
    @log << command.to_log(:done)
    command
  end

  # 履歴の末尾から1枚取って裏(undo)を呼び、巻戻の1行を日誌に残す
  def undo
    return nil if @history.empty?

    command = @history.pop
    command.undo(self)
    @log << command.to_log(:undone)
    command
  end
end

俺の視線は、まず apply_yaw の一行で止まった。「def apply_yaw(delta) = (@yaw += delta)。これ、def ... end じゃなくて、= の右にそのまま書いてある」

「エンドレスメソッドの、副作用版よ」とミラは言った。「Ruby 3.0 以降で、本体が一行だけのメソッドは、def 名前(引数) = 式 って書けるの。def...end を書かないで、= の右に、そのまま。値を返すだけの一行——例えば def name = :approaching みたいな形が、素直な使い方」

「今日のは、+= の代入だな」

「そう。= の右に、代入や += みたいな副作用を書きたいときは、() で囲む——(@yaw += delta)。囲まないと文法上、右辺として読まれない。副作用主体のメソッドを一行で書くときの、決まり」

俺は頷いた。def...end の三行が、= の右にひとかたまりの式として畳まれる形として、頭に置いた。

次に、俺は下のメソッドに目を戻した。「execute_* が消えて、apply_* に名前が変わった。なんで名前を変えた」。

execute は、Command と HelmConsole の、両方が持つ動詞になったの」とミラは言った。「Command は execute(context) を持ってて、HelmConsole も execute(command) を新しく持つ。ぶつかるから、HelmConsole 側は、“実際に値を動かす” って意味の apply_* に、変えた。Command の execute は “自分の execute を context に頼む”、apply_* は “context が実際に値を動かす”。動詞を、層で分けた」

動詞に、層がある。俺はその言葉を、頭のノートに置いた。一階(Command の execute)と、二階(HelmConsole の apply_*)で、名前を変えた。同じ “実行” を、頼む側と、動かす側で、書き分けた。俺の中で、少しずつ絵が繋がってくる。

ミラは、execute(command) の三行を、指で順に叩いた。

command.execute(self)@history << command@log << command.to_log(:done)。一個の操作を実行するときに、必ず “履歴に積む” と “日誌に自己記述を残す” が、ついてくる」

「三行。全部、順に、走るのか」と俺は言った。

「そう。分けて書くと、履歴に積み忘れたり、日誌に書き忘れたりする。一メソッドに束ねれば、書き忘れる場所が、ない」

一個の実行に、三つの後始末が、必ず、ついてくる。俺は独り言のように、そう言葉にした。忘れる場所が、塞がれている。

俺は下の undo メソッドに視線を移した。

return nil if @history.empty?。……山が空のときは、nil か」

「戻せるものが無ければ、静かに nil を返すの」とミラは言った。「raise で騒がない。呼ぶ側が、nil を見て、何もしないか、次の判断をするかを、決める」

「空の状態で undo を呼ぶこと自体は、想定外じゃないの」ミラは付け加えた。「初期状態からいきなり undo を試したくなる場面はある。想定外じゃない、普通の状態には、例外は、投げない——ArgumentError の 300 みたいな “そもそも受け付けない値” とは、線が違う」

@history.pop。……山の一番上から、一枚、取る」と俺は続けた。

ミラは頷いた。「最後に積んだものから、順に取り出すの。山の形、と呼ぶ人もいる。データ構造の名前としては、“スタック”——後入れ先出し、って呼ぶの。undo は、“直前の一手から順に戻す” のが自然だから、この形と、噛み合う」

俺は配列の <<pop を、頭の中で並べた。<< は末尾に積む。pop は末尾から取り出す。最後に置いたやつから、順に、上から。同じデータ構造の使い方に “履歴スタック” と名前を付けて呼ぶ流儀があるらしい——今日はその、一番シンプルな形が目の前にある。

そうして、俺は少しだけ、椅子の背に体を戻した。三つのカードクラスと、書き直された HelmConsole。全体を、もう一度見渡す。

ミラは、モニタの脇に、書き上がった三クラスと HelmConsole の関係を、一枚の見取り図にまとめて表示した。

HelmConsole から三つのカードクラス(YawCommand・ThrustCommand・BallastCommand)へ「頼む」と書かれた金の矢印が伸びるクラス図。三つのカードクラスは同じ execute / undo / to_log を持つ兄弟で、継承の線はどこにも無い。BallastCommand だけ prev_left を追加で持つ。

三枚のカードクラスが、同じ形を持って横に並ぶ。HelmConsole から三本、矢印が伸びて、それぞれのカードに “頼む” と書いてある。継承の線は、どこにも、無い。

「これ、この前見せてくれた State と、今日のと」と俺は改めて言った。「どっちも、オブジェクトに操作を持たせて、Context か HelmConsole が委譲する。形は、近いな」

ミラは、卓の三枚のカードを右手で示して、左手で「一個」の形を作った。

「形は近いの。入れ物の数が、違う」ミラは左手をそのままに、右手をカード束の上でひらひらと動かした。「あれは、Context が @state に “今の状態” を、一個だけ、持つ。今日のは、HelmConsole が @history に、“した操作” を、複数、順に、積む」

「一個と、積む、と」と俺は言った。

「あれは ‘今’ を握るの」ミラは続けた。「あれは ‘次にどこに進めるか’ が、分かる——今の状態が、次を持ってるから。今日のは ‘した’ を握る。‘前に何をしたか’ が、分かる——履歴に、積まれてるから。握ってる場所が違うから、戻せる」

俺は自分の中で、その言葉を組み立て直した。あれは “今” しか、要らなかった。次に進むから。今日のは “過去” が、要る。戻すから。“今” と “した” は、違う。俺はミラのカードを持つ手を見た。カードが、三枚、卓に並んでいる。あれは、一枚しか要らなかった。今日のは、三枚、積んで、上から順に、取る。

ミラは、掌の一枚を卓に戻して、三枚を横に並べ直した。

「これを、Command——動作パターン、って呼ぶの」

Command——動作パターン。動作の一個一個を、オブジェクトにして、実行と戻し方を、そのオブジェクトが、両方持つ。実行したオブジェクトは、実行した順に、履歴に、積んでいく——ミラは、そう言い添えた。俺は、卓の三枚のカードを、一度、目でなぞった。

「戻し方を、知ってるのは、動かした本人だけ」ミラは、掌に一枚のカードを、そっと載せて、表と裏をゆっくりと見せた。「表が、実行。裏が、戻し方。一枚のカードが、自分の実行と、自分の戻し方の、両方を、知ってる。他の誰も、知らなくていい」

俺は少し、その言葉を頭の中で置き直した。誰が戻し方を知っているか、と、さっき第一幕で問われたとき、俺は答えられなかった。ダグは知らないかもしれない。カデットは自分の記憶を疑うかもしれない。マニュアルには書けない。今、答えが立った——動かした本人(カード)が、知ってる。他の誰も、知らなくていい。

俺は椅子から少しだけ身を乗り出して、卓のカード束の下から、無地の一枚を借りた。ミラのペンを借りて、表に「yaw +5.0」と書いてみる。裏を返して、「yaw -5.0」と書く。書き終わったカードを、卓の他の三枚の隣に、表を上にして置いた。

「カードを書き足すのは、動かす前の、一回だけ」と俺は言った。「書き足したら、あとは execute と undo を、このカード自身に、頼む」

ミラは、俺が書き終わるまで、卓の傍らで、口を挟まずに、待っていた。書き終わったあとも、正誤を口に出しては言わなかった。ただ、俺のカードを、他の三枚と並べて、視線を一度落として、それでよし、というふうに、視線を上げた。

「動作が、物として、日誌に、残るの」ミラはそう続けた。「動作が呼び出しの中に溶けないで、履歴という、物の場所に、順に、積まれる。戻し方は、動作を書いた本人(カード)が、持ってる——HelmConsole でも、外の誰かでもなく、動作、それぞれが」

俺は自分の中で、その言葉のかたちを、静かに、確かめた。“物として、残る” と、“戻し方を、本人が、持つ”。それだけ。それ以上を、ミラは、言わなかった。

第4幕 模擬航走——履歴を、逆再生する

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

「保証を見る前に、一つだけ」

俺は椅子に浅く座り直して、頷いた。

「あれは、“今” を、今日のは、“した” を、それぞれ、握る」とミラは言った。「だから、戻せるのは、Command が “した” を、積んだ分だけ——積んでない過去は、戻せない」

「……なるほど。積んだ分だけ、か」と俺は言った。今日の議論のかたちが、そこで一度、閉じた。次に見るのは、この “積んだ分だけ戻せる” の、内側と、外側だ。

ミラは、テストコードのファイルを、モニタに呼び出した。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
def test_normal_sequence_before_after_state_equivalence
  before = Before::HelmConsole.new
  before.execute_yaw(5.0)
  before.execute_ballast(30)
  before.execute_thrust(0.8)

  after = After::HelmConsole.new
  after.execute(After::YawCommand.new(5.0))
  after.execute(After::BallastCommand.new(30))
  after.execute(After::ThrustCommand.new(0.8))

  assert_equal before.yaw, after.yaw
  assert_equal before.ballast, after.ballast
  assert_equal before.thrust_log, after.thrust_log
end

「まず、挙動を変えていないことを、先頭で証明するの」とミラは言った。

正常系の三手——yaw を +5.0、ballast を 50 から 30 へ、thrust を +0.8。Before の execute_* を直接呼んだときと、After の execute(command) を経由したときで、@yaw@ballast@thrust_log の最終値が、完全に一致する。挙動は変わっていない。引き直しの規律の、最初の一枚だ。俺はモニタの緑のドットを見て、頷いた。

次のテストを、ミラが呼び出した。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
def test_log_format_difference
  before = Before::HelmConsole.new
  before.execute_yaw(5.0)
  before.execute_ballast(30)
  before.execute_thrust(0.8)

  assert_equal ["yaw変更", "バラスト調整", "スラスタ噴射"], before.log

  after = After::HelmConsole.new
  after.execute(After::YawCommand.new(5.0))
  after.execute(After::BallastCommand.new(30))
  after.execute(After::ThrustCommand.new(0.8))

  assert_equal(
    ["[実行] yaw +5.0", "[実行] ballast 50→30", "[実行] thrust +0.8"],
    after.log
  )
end

「日誌の、形が、変わった」と俺は言った。「“yaw変更” が、"[実行] yaw +5.0” になった。何度、どちらに動いたかが、書いてある。ballast も、“50→30” と、変化の前後が、書いてある」

ミラは頷いた。「挙動は同じ。日誌の粒度だけが、違う。事故解析側にとって、この違いが、あとでどれくらい効くかは、日誌を掘る立場の人が、一番よく分かる」

俺は椅子の背に体を戻して、視線をモニタの緑のドットに、少しだけ長く置いた。“yaw変更” じゃない。"[実行] yaw +5.0"。数字が、書いてある。日誌を掘って、“どの時点で、yaw が、何度、動いたか” が、そのまま、読める。この一行だけで、俺の仕事は、格段に楽になる。

ミラは、モニタの隣に、履歴スタックの動きを一枚の図にまとめた。

左の縦積みが実行順(execute 3回)で [実行] yaw +5.0 → [実行] ballast 50→30 → [実行] thrust +0.8。右の縦積みが undo の向きで [巻戻] thrust -0.8 → [巻戻] ballast 30→50 → [巻戻] yaw -5.0。左右をつなぐ横矢印に「popの順序」と書かれ、実行順と巻戻順が鏡合わせになっていることを示すスタック図。

積んだ順と、取り出す順が、鏡合わせになっている。上に置いたやつから、順に、返される——山の形が、そのまま undo の順序になる。

ミラは、次のテストを呼び出した。undo の実演だ。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
def test_undo_restores_initial_state_and_logs_reverse_order
  after = After::HelmConsole.new
  after.execute(After::YawCommand.new(5.0))
  after.execute(After::BallastCommand.new(30))
  after.execute(After::ThrustCommand.new(0.8))

  after.undo
  after.undo
  after.undo

  assert_equal 0.0, after.yaw
  assert_equal 50, after.ballast
  assert_equal 0.0, after.thrust_log

  assert_equal(
    ["[巻戻] thrust -0.8", "[巻戻] ballast 30→50", "[巻戻] yaw -5.0"],
    after.log.last(3)
  )
end

正常系の三手を積んでから、undo を三回、順に呼ぶ。@yaw@ballast@thrust_log は、全て、初期値に戻る。日誌の末尾三行は、[巻戻] thrust -0.8[巻戻] ballast 30→50[巻戻] yaw -5.0——山の上から、順に、取り出したから、巻戻の順序は、“した順” の逆になる。

「実行の順と、巻戻の順は、逆になる」と俺は言った。「山の形、そのままだな」

ミラは頷いた。「最後に積んだやつから、最初に戻る。“した順” と “戻す順” は、鏡合わせ」

次のテストで、ミラは、カデットの誤操作を、コードで再現した。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
def test_misoperation_undo
  after = After::HelmConsole.new
  after.execute(After::YawCommand.new(5.0))

  assert_raises(ArgumentError) { After::BallastCommand.new(300) }

  after.execute(After::ThrustCommand.new(0.8))

  after.undo
  after.undo

  assert_equal 0.0, after.yaw
  assert_equal 50, after.ballast
  assert_equal 0.0, after.thrust_log
  assert_equal 0, after.history_size
end

俺はコードを読み下して、少しだけ止まった。「BallastCommand.new(300) のところで、raise してる。……execute の中じゃなくて、new の中で、raise か」

「そう」とミラは言った。「生成の瞬間に、弾くの。範囲外の値を、そもそも Command のオブジェクトとして、作らせない。execute(command) の三手の途中で、例外を投げないで済む」

俺はその意味を、頭の中で組み立てた。もし execute の中で raise していたら、どこで raise するかで、話が変わる。command.execute(self) の中で context.apply_ballast(...) を呼ぶ前に弾けば、副作用は起きずに済む。ただ、command.execute(self) の中で「先に apply_* を呼んで、それから何かのチェックで raise する」ような設計にしてしまうと、値だけ動いて、履歴にも日誌にも載っていない——中途半端に部分適用された、宙ぶらりんの状態が生まれる。生成の瞬間に弾けば、その中途半端が起きる場所自体が、無くなる。範囲外の値を持った Command オブジェクトが、そもそも生成されない。“しかけて失敗した” は、履歴の外、という一貫性が、initialize の一行で、Command の型そのものに立つ。

「バラストの 300 は、Command として、そもそも、生まれてないから、履歴に、積まれない」と俺は言った。「undo は、二回で、yaw と thrust の、二手が、全部、戻る。……エラーで、生まれなかったやつは、山に、無い」

ミラは頷いた。「“した” と、“しかけて失敗した” は、違うの。積むのは、“した” だけ」

俺は、テストの最後の一行に目を移した。assert_equal 0, after.history_size。山の中身は、ゼロ。二回の undo で、二枚のカードが、山から、消えた。

次のテストを、ミラが呼び出した。to_log の、単体の確認だ。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
def test_to_log_for_each_command
  yaw = After::YawCommand.new(5.0)
  assert_equal "[実行] yaw +5.0", yaw.to_log(:done)
  assert_equal "[巻戻] yaw -5.0", yaw.to_log(:undone)

  yaw_negative = After::YawCommand.new(-3.0)
  assert_equal "[実行] yaw -3.0", yaw_negative.to_log(:done)
  assert_equal "[巻戻] yaw +3.0", yaw_negative.to_log(:undone)

  thrust = After::ThrustCommand.new(0.8)
  assert_equal "[実行] thrust +0.8", thrust.to_log(:done)

  context = After::HelmConsole.new
  ballast = After::BallastCommand.new(30)
  ballast.execute(context)
  assert_equal "[実行] ballast 50→30", ballast.to_log(:done)
  assert_equal "[巻戻] ballast 30→50", ballast.to_log(:undone)
end

俺は yaw_negative の行に目を留めた。「delta が -3.0 のとき、to_log(:done)[実行] yaw -3.0to_log(:undone)[巻戻] yaw +3.0。符号の処理も、ちゃんと入ってるな」

「正の値なら +、負の値ならそのまま——format_signed の、一行の判定」とミラは言った。「日誌の粒度は、符号までを、含む」

俺は少し、その細部の丁寧さに、頷いた。“yaw +5.0” と “yaw -5.0” が、目で見て、すぐ区別できる——これは、事故解析側にとって、地味だが、大きい。

次のテストは、空の山の undo だ。

1
2
3
4
5
6
7
8
9
def test_undo_on_empty_history_returns_nil
  after = After::HelmConsole.new

  assert_nil after.undo
  assert_equal 0.0, after.yaw
  assert_equal 50, after.ballast
  assert_equal 0.0, after.thrust_log
  assert_equal [], after.log
end

初期状態の HelmConsole に対して、いきなり undo を呼ぶと、nil が返る。値は、動かない。日誌にも、何も、積まれない。

「呼ぶ側が、nil を見て、何もしないか、次の判断をするか、を決める」と俺は繰り返した。「raise で、騒がない。……戻せるものが無ければ、静かに、答える」

最後のテストは、BallastCommand の prev の証拠だ。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
def test_ballast_command_holds_its_own_prev_per_instance
  context50 = After::HelmConsole.new
  command50 = After::BallastCommand.new(30)
  context50.execute(command50)
  assert_equal 30, context50.ballast
  context50.undo
  assert_equal 50, context50.ballast

  context40 = After::HelmConsole.new
  context40.execute(After::BallastCommand.new(40)) # ballast を 40 へ寄せておく
  command_second = After::BallastCommand.new(30)
  context40.execute(command_second)
  assert_equal 30, context40.ballast
  context40.undo
  assert_equal 40, context40.ballast
end

俺はコードを、二度読んだ。初期 ballast が 50 の HelmConsole に、BallastCommand.new(30) を execute して、undo すると、50 に戻る。別の場面では、初期 ballast を 40 に寄せてから、別の BallastCommand.new(30) を execute して、undo すると、40 に戻る。同じ設計値の Command でも、実行した瞬間の場面が違えば、戻る場所も、違う。

「同じ 30 に設定するカードでも、実行した瞬間、context.ballast が 50 なら、戻り先は 50。40 なら、戻り先は 40」と俺は言った。「カードは、実行の瞬間の場面を、記憶する」

ミラは頷いた。「そう。だから、一枚のカードは、一回だけ使うの。同じ Command のインスタンスを、二回 execute すると、二回目の @prev_left が、上書きされて、以降の undo は、二回目の直前の値にしか、戻らない」

「一枚、一回、か」

「そう。契約として、そう決めておく」とミラは言った。「Command の内側で、“再利用は禁止” のチェックを、書き足したりはしない——書けば、書くほど、コードが太る。契約違反は、呼ぶ側の責任にする。カードの喩えとも、そのまま繋がる——一枚のカードは、一回だけ、使う」

俺は少し、その割り切りに、頷いた。ミラは、防御コードで太らせない、と決めている。呼ぶ側が、契約を、守る。ドキュメントとテストが、その契約を、示す。俺の位置から見ても、これは、清々しいほうの判断だ。

そこで、俺は椅子に少しだけ寄りかかって、モニタの緑のドット七つ、を、目で数えた。全部、通っている。警告は、無い。

ミラは、コンソールを一度、静かに置いて、卓の端のカード束を、両手で軽く整えた。順に重ねて、伏せて、卓の隅に、そっと置き直す。無音の所作だ。紙片同士の、かすかな擦れ音だけが、シミュレータ室の静けさに、一瞬だけ、混じる。俺はそれを、視界の端で追ってから、視線をモニタに戻した。

「保証しないことも、話しておく」

ミラの、そのひとことを、俺は待っていた。

「今日の題材は、姿勢と、バラストの、数値の話だった。実際に噴射した燃料の消費は、undo で、燃料タンクに、戻ってきたりしない——今日のは、そもそも、そういう話じゃない、と、最初に、切ったやつ」

俺は頷いた。「そうだな。消せない、と言うしかない場面が、俺の仕事にも、ある」。ここは、俺が言葉に出しておく番だ、と思った。「燃料は、戻らない。送った通信も、取り消せない。だから、そういう操作は、“する前に” 止める、設計が、別に、要る」

「Command は、“戻せるものを、戻す道具” なの」とミラは言った。「“戻せないものを、戻せる” とは、言わない」

俺はもう一つ、気になっていたことを、訊いた。「実運用で、これを長く動かすと、@history は、どんどん、太らないか」

「太るの」ミラは、頷いた。「上限をつけて、古いカードを、捨てるか。区切りで、束ねるか。“積む” までが、Command の、仕事、で、“何個まで、積むか” は、運用の、判断。ここは、Command の、外」

俺は、その線引きが、腑に落ちた。Command が、責任を持って言えることと、言えないことの、境目——これが、はっきり引かれている。境目を曖昧にしないから、この道具は、道具として、使える。

「もう一つ、思い出した」と俺は続けた。「もし、新しい Command クラスに、undo を、書き忘れたら、どうなる」

「undo を、呼んだ瞬間に、NoMethodError で、鳴るの」とミラは言った。「Ruby は、動的型付けだから、書き忘れは、実行時にしか、鳴らない。だから、模擬航走が、生命線」

俺は独り言のように、「シリーズの、支柱だな」と、口の中で言った。書き忘れは、実行時にしか、鳴らない——ミラと仕事をしてきて、何度も聞いた話だ。だから、テストを、常に、走らせる。ここは、変わらない。

ミラは、最後に、少しだけ声を落として言った。「Ruby には、instance_variable_set みたいな、反則技もあるの。HelmConsole の外から、@history を、勝手に空にすることも、書けてしまう。そこは、規約と、レビューで、守る領分」

俺は頷いた。ここも、境目の話だ。言語で守れることと、規約とレビューで守ることの、境目——境目を、Command のせいにしない。

ミラは、椅子を回転させて、シミュレータ室のダミー操舵卓の方に、体を向けた。

「もう一度、走らせてみるの」

俺は姿勢を戻して、モニタを見た。ミラは、カデットの三手を、綺麗に、再現した——バラストの 300 の桁ミスは、再現しない。今回は、BallastCommand.new(30) を、正しく、渡す。三手は、順に、積まれる。

日誌欄に、三行が、積まれた。

[実行] yaw +5.0 [実行] ballast 50→30 [実行] thrust +0.8

何を、どれだけ、どちらに動かしたかが、三行に、残っている。「“yaw変更” じゃない、[実行] yaw +5.0。数字が、書いてある」と、俺はもう一度、心の中で確かめた。

ミラは、undo を、一度、呼んだ。

@thrust_log が、+0.8 から、0.0 に、落ちた。日誌欄の末尾に、[巻戻] thrust -0.8 の一行が、積まれる。

ミラは、少し間を置いてから、二度目の undo を呼んだ。

@ballast が、30 から、50 に、戻った。日誌欄の末尾に、[巻戻] ballast 30→50 の一行が、積まれる。

ミラは、また、少しだけ、間を置いた。

俺は、モニタの @yaw の表示が、+5.0 のまま、待っているのを見た。あとひとつ、undo が呼ばれれば、この 5.0 も、0.0 に落ちる。俺はその、待っている一瞬に、少しだけ、考えた。

動かしたのは、さっきの、カデットだ。戻し方を、知ってるのは——カデットが、書いた? いや、カデットは、書いてない。書けなかった。カデットが叩いた三手を、あとから、コードの中で、積み直したこのカードの、裏だ。動かした本人と、書いた本人と、実行した本人は、コードの上では、同じ物になる

ミラが、三度目の undo を、呼んだ。

@yaw が、+5.0 から、0.0 に、落ちた。日誌欄の末尾に、[巻戻] yaw -5.0 の一行が、積まれる。

モニタの数値表示は、yaw = 0.0ballast = 50thrust_log = 0.0。全て、初期値だ。日誌欄には、六行の記録が、順に、並んでいる。実行の三行と、巻戻の三行と。表と裏が、順に、読まれ、順に、返された。

俺は自分が書いた無地のカードを、卓の隅の束に、ゆっくりと戻した。表を伏せて、他の五枚の上に、そっと重ねる。指先に、紙のかすかな厚みが残った。

六枚の記録が、日誌に、順に、並んだ。表、三枚、裏、三枚。実行の順に、積んで、実行の逆順に、返した。動かしたのは本人、戻し方を、知ってるのも、動かした本人。

ミラは、卓の端で、カード束を、伏せて、重ね終えた。俺の位置からは、ミラの手が、動いたのか、動かなかったのか、はっきりとは、分からない。ただ、卓の隅の紙片の束は、静かに、置かれている。

通路のほうから、遠く、足音が近づいてきた。カデットが、水を一杯飲んで、戻ってくる頃合いだ。俺は姿勢を、教官席の座り方に、戻した。次の一手を、どう指導するかは、この記録を、少しだけ、カデットにも見せてから、決めることになる。


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

  • 航法術(パターン名): Command(動作を execute / undo / to_log を持つオブジェクトに引き上げ、履歴スタックに順に積む振る舞いパターン。Ruby の兄弟クラス群と、配列の << / pop の"山の形"で実装した)
  • 発生事象(症状): シミュレータ室での訓練セッション中、副操舵手見習いがバラスト値を三十と打つべきところ三百と打ち間違え、範囲チェックの ArgumentError で二手目は拒否された。だが一手目の yaw と、そのあと続けて叩いた三手目の thrust は既に実行済みで、戻す手段がコードに無かった。日誌には「yaw変更」「スラスタ噴射」とだけ残っていて、何度・どちらに動かしたかが、記録から復元できなかった。訓練プログラムのコードは実運用と同じコードベースを共有しているため、この綻びは実運用の事故解析にも波及するリスクがあった
  • 原因(旧航路の問題): HelmConsoleexecute_yaw / execute_ballast / execute_thrust を直接呼びで即時反映し、日誌には @log << "yaw変更" のような動作の種類名だけを一行残す設計だった。動作の実体は関数呼び出しの中に溶けて、5.0 という数字がコードのどこにも残らず、戻し方の情報もどこにも書かれていなかった。前任者に “残す意識” はあった(@log << "..." の一行がある)が、“残す粒度” と “戻し方の情報” が抜けていた——粒度が抽象になった瞬間、記録は残っていないのと同じになる
  • 処置(引き直しの要点): 動作を YawCommand / ThrustCommand / BallastCommand の三つの兄弟クラス(継承なし・ダックタイピング)に引き上げ、各クラスに execute(context) / undo(context) / to_log(kind) を持たせた。Yaw と Thrust は差分ベースで undo-@delta。Ballast は絶対値操作のため、execute の瞬間に @prev_left = context.ballast の一行で元の値をカード自身の内側に書き留め、undocontext.apply_ballast(@prev_left) で戻す(Memento の役を Command が兼ねる形)。HelmConsole#execute(command) は「実行→履歴に積む→自己記述を日誌に残す」の三手を必ず束ね、undo は履歴の末尾から一枚取って裏を呼び、巻戻の一行を日誌に残す(空スタックのときは静かに nil を返す)。Command 側の execute と重複するのを避けて、HelmConsole 側の “実際に値を動かす” メソッドは apply_yaw / apply_ballast / apply_thrust にリネームした——動詞を層で分けた
  • 保証外事項: 外部副作用は戻らない(実際に噴射した燃料の消費・通信相手に届いた電波・送信済みコマンドは undo で戻せない、Command が戻すのはコード上の記録された数値の状態だけ)/履歴を無制限に積むとメモリを食う(上限や区切りは Command の外の運用の判断)/同時実行は想定外(マルチスレッド対応は別の航法術=同期・排他制御の領分)/Ruby は動的型付けで undoto_log を書き忘れれば実行時に NoMethodError で鳴る(模擬航走が生命線)/instance_variable_set などの反則技で @history を外から書き換えられるのは言語では防げない(規約とレビューで守る)/契約「一枚のカードは一回だけ使う(1 Command = 1 実行)」は Command の内側でチェックせず呼び出し側の責任にする
  • 模擬航走結果: 全 7 runs / 30 assertions、警告ゼロで全灯緑(正常系の Before=After 挙動同値・日誌の形式差の明示・undo 三手の実演・誤操作シナリオでの ArgumentError 拒否と undo 二回での初期値復帰・to_log の構造化記録の各 Command 単体確認・空履歴 undo の nil 返し・BallastCommand の prev 保持を異なる初期状態から確認)
  • 次の針路: マクロコマンド(複数の Command を一つに束ねる型)と、履歴上限の運用ルール(古いカードを FIFO で捨てるか、区切りで束ねるか)の導入を検討予定。マクロ化しても既存三クラス(YawCommand / BallastCommand / ThrustCommand)は無改変で済む
comments powered by Disqus
システム開発・AIワークフローのご相談は Meetsource
Hugo で構築されています。
テーマ StackJimmy によって設計されています。