Featured image of post コードナビゲーター【Facade】出航前点検、ブリッジに漏れた四系統の作法〜入口は一つでも、部屋は四つ残る〜

コードナビゲーター【Facade】出航前点検、ブリッジに漏れた四系統の作法〜入口は一つでも、部屋は四つ残る〜

出航前点検の手順書は「点検を1回呼ぶ」と1行なのに、実コードのブリッジ側では四系統の呼び出しが11手続き分並び、同じ11手続きが7箇所に散らばっていた。下位の内部順序が変わるたびに7箇所全部を直す羽目になる旧設計を、一枚の窓口 PreflightCheck に段取りを集約する Facade パターンで引き直します。Rubyのキーワード引数のデフォルト値による依存注入で、既定の入口を提供しつつ下位への直接アクセス余地を残す実装を解説します。

第1幕 異変——手順書一行、コード十一行

機関室の低い振動が、俺の背中の椅子越しにゆっくり伝わってくる。循環器の唸り、金属と油の匂い、床の合金が微かに温まる感覚。俺はダグ。この恒星間貨物船〈オリオール号〉の機関士だ。船暮らしはもう二十年を超えていて、機関室の壁ラックに何がどの順に並んでいるかは目を閉じても言える。

今日は巡航シフトの初日で、俺はシフト前の恒例の作業をしている。ラックの一番上の段から、紙で綴じられた出航前点検の手順書を抜き取って、教官席——機関コンソールの脇の作業椅子——に座って、該当ページを開く。〈オリオール号〉では機関士も出航前点検の全系統立ち会い義務がある。機関始動前に、全系統が正常であることを、機関系の最終責任者として確認する規定だ。手順書の最終照合は、シフト開始前に必ずやる。二十年やり続けている。

指がページの上を静かに滑って、該当ページで止まる。そのページには、たった一行だけ書かれている。

「出航前点検:PreflightCheck を1回呼ぶ」

俺は余白に鉛筆で小さな印を入れる。読了の印だ。長い年月の癖で、印を入れないとページを閉じられない。手順書はこの半年でどこかの誰かが改訂した記憶がある。前任者だか、その前だか、誰かがページを整理して、「1回呼ぶ」と縮めた。以前はもう少し行数があった気がするが、正確には覚えていない。

ページを閉じかけて——、手が止まった。

「1回呼ぶ」だけ、で済むのか。

俺は鉛筆の先を紙面から少しだけ浮かせたまま、視線をページに戻した。ここ半年、実運用の緊急対応やスケジュール調整の相談で、俺は何度かブリッジ側のコードを覗いた記憶がある。整備後のセッションで、副機関士が接続確認に来たときにも一緒に画面を見た。その度に、モニタには四系統の呼び出しが順序込みで並んでいた。三、三、二、三で、合わせて十一行。全部で十一行を、上から下に順に叩く形をしていた。

手順書には「1回呼ぶ」と書いてある。だが、コードは十一行、俺の記憶では並んでいた。手順書とコード、どっちが本当の「出航前点検」なのか。手順書は改訂された。コードは、改訂されたのか。

俺は鉛筆を置いて、頭の中で仮説を組み立てる。多分、コードが古い。手順書が新しい版で、コードが追いついていない。前任者が手順書だけ整理して、コード側は動いているから触らなかった。ありそうな話だ。動いているコードに触るリスクは俺だって理解する。触った瞬間に思いもよらないところが鳴る、というのはこの船の実運用で何度か見てきた光景だ。

次いで、自分に確認する。今日の話は「呼び方」の話だ。実際に燃料を送ったり、電文を出したりする話じゃない。コード上でどう呼ぶかの話、実噴射の話じゃない。実運用の噴射・電文発火・機関始動は、そもそも別の場所で発火する。手順書の「1回呼ぶ」は、そのコード上の呼び方を一つに絞る、という話に見える。

そこまで組み立てて、俺は手順書をラックに戻した。俺の頭の中の仮説を、俺の頭の中だけで確定させるわけにはいかない。ミラに聞くしかない。

俺は機関室のドアを出て、通路を進んだ。機関室の振動が徐々に遠ざかり、通路の中ほどで振動が完全に消えた。ブリッジに近づくにつれ、静けさが増していく。船の中の静けさは、いつも同じ質感ではない。機関室のすぐ外の静けさは「振動が消えた静けさ」で、ブリッジの静けさは「窓の外に音がない静けさ」だ。俺は後者に足を踏み入れた。

ブリッジ。コンソールが数台並んでいる。窓の外は暗い深宇宙、恒星が数点。ミラは中央のコンソールの前に立って、モニタの画面に何かを開いていた。画面の詳細は俺の立ち位置からはよく見えない。ミラの手はキーボードに置かれていない。

ミラの手元、コンソールの脇に、書類ラックが備え付けられている。普段は使う機会が少ない古い書類が挿し込まれている場所だ。そのラックから、四折の紙地図が半分だけはみ出していた。折り目の縁が薄く白く色褪せている。ブリッジに常備されている船内配管の運用地図——通常運用ではまず参照しない、緊急時の紙のバックアップ資料だ。

俺の視線は一度、その地図の縁に落ちて、また前を向いた。またか、あの人の道具は今日はあれか、と口の中だけで呟く。ミラの手癖には長年慣れている。理由は本人がいずれ言う。俺から先に聞くことはしない。

「悪いんだが、ちょっと聞きたいことがある」

ミラはモニタから顔を上げて、俺を見た。頷く。

「手順書には、出航前点検が『PreflightCheck を1回呼ぶ』って一行だけ書いてある。だが、俺の記憶ではブリッジのコードは、四系統の呼び出しが十一行並んでたはずだ。手順書とコードが食い違ってる。……多分、コードが古い。手順書が新しい版で、コードが追いついてないんじゃないか」

ミラは俺の言葉が終わる少し前から、視線をモニタに戻していた。頷きながら言う。

「見てみよう」

ミラの指がキーボードの上で数回動いて、モニタに Bridge#preflight_check のコードが開いた。俺は隣のコンソールに近づいて、画面を覗き込んだ。

十一行の、内側に埋め込まれた呼び出し。ずらりと並んでいた。fuel = FuelSystem.new から始まって、engine.calibrate_sensor で終わる十一行。俺の記憶と寸分違わない形をしている。

視線が真ん中あたりで止まった。engine.check_oil_circulationengine.warmupengine.calibrate_sensor

……俺の系統の呼び順が、ここに書いてある。

オイル循環、暖機、センサ校正。俺が機関室で毎日守っている作法が、ブリッジで諳んじられている。俺のシフト表と同じ手順を、ブリッジのこの画面が知っている。俺が入っていない部屋で、俺の手つきが読み上げられている。

俺は残りの三系統に視線を移した。燃料系の三行、生命維持系の三行、通信系の二行。同じ形で並んでいる。多分、燃料系の担当も、生命維持系の担当も、通信士も、俺と同じ気持ちだろう——自分の系統の内部事情が、この場所で読み上げられているのを見て。

俺は口に出した。

「……うちの系統の呼び順が、ここに書いてある。多分、他系統の担当たちも、同じ気持ちだろう」

ミラがモニタから顔を上げて、俺を見た。

「同じ気持ち、って?」

「自分の内部事情が、ここに漏れてる、って」

ミラはコンソールから一歩下がった。書類ラックに手を伸ばして、四折の紙地図を引き抜いた。地図をコンソールの卓に広げる。四面全てが開かれた状態、船内配管の系統図が印刷されている。俺の視線は、そこで初めて紙地図に固定された。今日の話に関係する道具だ、と分かった。

ミラは、地図の右上を軽く指した。

「ダグ、手順書が古いんじゃないの。手順書が先に整理されて、『1回呼ぶ』に縮んでる。コードは、追いついていない」

俺は動きが止まった。

「……手順書が、先?」

ミラが頷いた。

「わたしも半年前にこの手順書の改訂を通した。あのとき前任者に確認したの——手順書は『1回呼ぶ』に整理する、コードは触らない、って」

「なんで、コードは触らなかったんだ」

「動いていたから」

俺は黙った。ミラが続ける。

「触らない判断は、当時としては合理的だった。動いているコードに触るリスクと、手順書だけ整理するリスクを天秤にかけて、後者を選んだ。もっと昔、生命維持系の暖機順を1手増やしたときに、ブリッジ側のコードを七箇所全部直した、って前任者から聞いてる」

七箇所か。俺は口を開かずに、頭の中で数を数え直した。確かに、七箇所同時に手を入れるのは、実運用中の船では相当のリスクだ。触らない判断は、俺だって選ぶ可能性がある。動いていたコードに触るのは、動いていないコードを新しく書くのより慎重にやらなければいけない。手順書を直すのは、書類仕事だ。書類は間違えても書き直せる。動いているコードは、間違えたら動いている船が止まる。

「じゃあ、今日は、そのコード側を追いつかせる話か」

俺は言った。ミラが頷く。

「手順書の『1回呼ぶ』が本当の設計。コードを、手順書に追いつかせる」

畳まれずに広げられたままの紙地図を、ミラは卓の中央に置いた。四面の系統図が、ブリッジの静かな照明の下で薄く光った。

第2幕 解析——漏れ出た四系統、諳んじる利用側

ミラがモニタから顔を上げて、俺に一言添えた。

「今日の題材は出航前点検——コード上の呼び順と、点検状態の集約。噴射も電文も、実際には出さない。コード上のフラグが立つだけ。『呼び方を整える話』に絞っての話」

俺の頭の中で組み立てた仮説と、同じ範囲だ。機関室で俺が手順書を閉じる前に自分に確認したスコープが、ミラの一言で外部化された。実噴射は今日の議論の外にある。実運用の副作用は、今日は触らない。Facade を語る前に、その線を先に引いておく、ということだ。

ミラがモニタに Before の Bridge#preflight_check を全面表示した。十一行が縦に並ぶ。

 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 Bridge
  def preflight_check
    fuel = FuelSystem.new
    fuel.check_tank_pressure
    fuel.verify_valve_state
    fuel.check_line_seal

    life_support = LifeSupport.new
    life_support.check_atmosphere_pressure
    life_support.check_temperature_range
    life_support.verify_scrubber

    communications = Communications.new
    communications.check_antenna_alignment
    communications.verify_frequency_lock

    engine = Engine.new
    engine.check_oil_circulation
    engine.warmup
    engine.calibrate_sensor

    fuel.ready? && life_support.ready? && communications.ready? && engine.ready?
  end

  def rescheduled_preflight_check
    # ...同じ十一手続きが、また inline されている...
  end

  # 他にも五つ、同じ十一手続きの inline が Bridge に散らばっている
  # emergency_preflight_check / simulator_preflight_check /
  # docking_preflight_check / audit_preflight_check /
  # post_maintenance_preflight_check
end

俺は画面に顔を近づけて、上から下に読み下した。

「燃料系を作って、三回叩く。生命維持系を作って、三回叩く。通信系を作って、二回叩く。機関系を作って、三回叩く。最後に、全系統の ready?&& で繋ぐ。……十一行、全部、ブリッジが下位の順序を諳んじてる」

ミラはモニタを下にスクロールした。二つ目のメソッド rescheduled_preflight_check が現れる。同じ十一行が inline されている。

「……同じのが、また十一行。これも」

「もう五箇所ある。緊急出航用、シミュレータ用、停泊解除用、監査記録用、整備後用」

「合計、七箇所か。七箇所全部に、同じ十一行が inline されてるのか」

ミラが頷いた。俺は、さっき機関室で聞いた「生命維持系の暖機順を1手増やしたときに、ブリッジ側のコードを七箇所全部直した」という前任者の労苦を思い返した。あれは、この状態を触った経験だ。七つの部屋を渡り歩いて、同じ場所に同じ一行を書き足して、書き漏らしがないことを確認して回った作業だ。

「1回呼ぶ」って手順書には書いてあるのに、実装では下位の細かい呼び順を利用側が全部知っている。下位の内部事情が、利用側に漏れ出ている。

ミラが言った。

「サブシステムの内部が、外に漏れ出てるの。呼び順、初期化の順序、集約の方法——全部、Bridge が知ってる。Bridge は、下位のことを、下位以上に諳んじる立場にある」

サブシステムというのは、船の系統に近い言葉だ。船の側では燃料系や生命維持系のことを系統と呼ぶが、コードの側では、こういう一群の下位クラスを「サブシステム」と呼ぶ習慣がある。ミラの言い方は、船の系統とコードのサブシステムをぴたりと重ねてくる。

「漏れ出る、か。……確かに、Bridge を書く人は、燃料系の三手、生命維持系の三手、通信系の二手、機関系の三手を諳んじないと書けない」

「そう。書く人が下位を諳んじる。新しい人が Bridge を書きに来たら、まず十一手を覚えるところから始める。利用側の学習コストが、下位の系統数に比例して重い」

ミラは、モニタ上に Git のログを開いた。誰の名前も出さないように、コミットメッセージの前後だけを見せてくる。ミラはこういう気配りをする航法士だ。前任者の名前を挙げて責める話にはしない。

「この最初の実装、悪意はないの。当時としては、これが自然な書き方だった。手順書はまだ整理されていなかったし、下位クラスも段階的に増えていった。増えるたびに Bridge の各メソッドに inline で1系統ずつ足してきた——気付いたら十一手になっていた、というのが実態だと思う」

「足してきた、か」

俺は言葉を反復した。ミラが続ける。

「その後、前任者が手順書側だけを整理した——『1回呼ぶ』と縮めた。それは正しい設計判断だった。設計は先に整理された。実装は追いつかなかっただけ」

前任者は雑じゃない。「設計判断」と「実装の追随」は、別の話だ。設計判断が先に済んでいても、実装が追いつくには「動いているコードに触るリスク」を越える必要がある。そのリスクを避けたのは、当時の判断としてはむしろ堅実だ。堅実さと、後々の綻びは、必ずしも矛盾しない。

ミラはモニタから体を戻して、卓に広げた紙地図の前に立った。地図の右端の一面を、左に折り込んだ。四面が三面になる。

「……畳んでるのか」

俺は、地図に落ちた視線をそのまま持続させた。ミラは無言で続ける。次の一面を、右端から左に折り込む。三面が二面になる。さらにもう一面を折り込む。二面が一面になる。地図は最小の一面まで畳まれた。最後の一面には「PREFLIGHT」と刷り込みがある。

ミラは畳んだ地図をコンソールの卓に置いて、指を最後の一面の中央に置いた。

「ここ」

静かな声だった。俺は黙る。ミラは指を離さないまま、続けた。

「この畳みは、必要なときは開いていい」

ミラは指を一面から離さないまま、卓に置いた手順書の方に視線を送って、もう一言添えた。

「手順書と地図、同じことをしてる。四面あるものを、一面に絞る。手順書は文字で、地図は紙で。どっちも既定の入口を作ってる」

畳む——一面に絞る、けれど、開けば元の四面が読める。閉じてはいない、絞っただけ。手順書と地図が、同じ思想で作られている。「1回呼ぶ」の「1回」は、この畳みの「1面」のことか。俺は口の中でだけ、そう反復した。

俺は少し間を置いてから、口を開いた。

「これ、この前見せてくれた Adapter と似てるな。下位のクラスを直接呼ばずに、間にクラスを一つ挟んで、そこから呼ぶ、あれ」

ミラは、畳んだ地図から指を離して、コンソールに向き直った。

「形は近い。何をしてるかが違う」

「……訳すのと、束ねるのと?」

「そう。細かくは、コードを書きながら。Adapter は『外形を訳す』、これから作るのは『呼び順を束ねる』」

ミラはコンソールに向き直って、Before のコードをもう一度開いた。今度は動かして見せる、ということだ。ミラは Before の Bridge#preflight_check を呼び出した。十一手が順に走る。モニタの隅で各下位系統の内部フラグが順に true に立っていく——タンク圧、バルブ状態、ライン封止、大気圧、温度範囲、スクラバ、アンテナ角、周波数ロック、オイル循環、暖機、センサ校正——十一個のフラグが縦に並んで、全て true。最後の行、fuel.ready? && life_support.ready? && communications.ready? && engine.ready? が評価されて、true が返った。

「今、正常系。全系統 GREEN で、出航前点検は通ってる。……この状態を変えずに、コードだけ引き直す」

ミラは畳んだ地図をもう一度手に取って、今度は一面だけ開いて、また閉じた。

「開くこともできる。閉じることもできる。既定は閉じてるけど、選べる」

俺は、その所作を目の端で追った。開けるし、閉じられる。選べる、が今日の話の裏側にある、ということらしい。ミラの手癖はこういう仕掛けを、話の前に一度身体で見せてから、あとでコードとして再現する。俺はいつもそれを、後から気づく側で見ている。

「これを、コードにするのか」

俺は言った。ミラが頷いて、コンソールの前に座り直す。

「下位の呼び順を、一枚の窓口の中に集める。集めるクラスを、PreflightCheck って名前で立てる」

畳んだ紙地図は、コンソールの脇に置いたまま、開かれてはいなかった。

第3幕 引き直し——一枚の窓口、四つの部屋

ミラは新しいエディタ画面を開いた。まず最初にしたことは、下位四系統のクラスを開いて、指で軽く画面をなぞらずに、視線だけを上から下に流すことだった。

「下位のクラスは、そのままか」

俺は言った。

「そのまま。引き直すのは、『呼び順を知る場所』だけ。下位クラス自体は、フラグの立て方も、ready? の集約も、Before と完全に同じ」

Ruby のメソッド名は末尾に ? を付けられる——真偽値を返すメソッドの目印だ。ready? は「準備できてる?」と聞いて truefalse を返す慣習で、Before の下位クラスにも同じ形で書いてある。下位は無改変。動いていたコードを触らない、という前任者の判断は、そのままここでも活きる。ミラの引き直しは、動いている部品を壊さない。前任者と今のミラは、同じ堅実さの上で、違う場所を直しているだけだ。

ミラは新しいクラスを書き始めた。class PreflightCheck と打って、def initialize の中身から入る。ここで、俺の見慣れない書き方が出てきた。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
class PreflightCheck
  def initialize(
    fuel_system: FuelSystem.new,
    life_support: LifeSupport.new,
    communications: Communications.new,
    engine: Engine.new
  )
    @fuel_system = fuel_system
    @life_support = life_support
    @communications = communications
    @engine = engine
  end
end

「その fuel_system: FuelSystem.new の書き方は、なんだ。渡さなくても既定で作られる、ってことか?」

俺は聞いた。ミラが頷く。

「引数の名前と : のあとに = ... を書くと、渡さないとその既定値が使われる。渡せば、渡した方が使われる。既定と外注入の両方に対応できる書き方」

初出の構文だ。Ruby はキーワード引数——引数を名前で渡す仕組み——を持っていて、そこにデフォルト値を書ける。引数がたくさんあるときは ( の後で改行して縦に並べても同じ意味だ(今回の四系統のように長いときによく使う)。PreflightCheck.new と呼べば四系統が自動で用意される。PreflightCheck.new(fuel_system: FuelSystem.new) と書けば、燃料系だけ外から渡した実体が使われる。渡さなければ勝手にインスタンスができる、渡せば渡した方が使われる、という二段構えの書き方だ。

「下位の実体を、外から差し替えたい人には出口を用意する——テストで別バージョンを渡したいとか、診断用に別実装を渡したいとか」

ミラは、そう言いながら、卓の脇に置いてあった畳んだ地図を片手で持ち上げた。もう片方の手を添えて、一面だけを軽く開いて、また閉じる。

「この畳みは、必要なときは開いていい」

さっきと同じ台詞だ。既定は閉じてるけれど、開けるようにしてある。地図の畳みと同じだ、と俺は口の中で呟いた。既定で自動、必要なら外注入。扉を閉めないで、既定だけ用意する。

ミラは def run を書き始めた。中身は、十一行のメソッド呼び出しと、最後の集約一行。下位のフラグを立てる実処理は一切書かない。全て下位クラスへの委譲——自分は呼ぶだけで、実際の処理は下位に任せること——だ。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
def run
  @fuel_system.check_tank_pressure
  @fuel_system.verify_valve_state
  @fuel_system.check_line_seal

  @life_support.check_atmosphere_pressure
  @life_support.check_temperature_range
  @life_support.verify_scrubber

  @communications.check_antenna_alignment
  @communications.verify_frequency_lock

  @engine.check_oil_circulation
  @engine.warmup
  @engine.calibrate_sensor

  all_ready?
end

俺は画面を見て、一つ引っかかった。

「PreflightCheck の中に、十一行が全部書かれている。……これはこれで、また同じ問題にならないか」

ミラは頷いた。

「junior な人が最初に詰まる所ね」

ミラは、まず一つ確認するように言葉を続けた。

「同じ十一行に見えるけど、二つのことが違う。数の話と、中身の話——別に見てほしい。数の話は、Before は七箇所に重複して書かれていた十一行、After は一箇所にだけある十一行。七十七行が、十一行に減っただけでも、まず数の話が違う。次に、その一箇所の十一行の中身の話——」

俺は頷く。数の話は俺にも即座に見える。七十七行から十一行への減少は、それだけでも既にかなりの引き直しだ。ミラが言う「中身の話」の方が、俺にはまだ見えていない。

ミラはモニタを分割して、Before と After の一行例を並列で表示した。左に Before、右に After。同じ「燃料系のタンク圧を叩く一手」を、二つの書き方で見せる形だ。

1
2
3
4
5
6
# --- Before(Bridge の1行例) ---
fuel = FuelSystem.new
fuel.check_tank_pressure

# --- After(PreflightCheck の1行例) ---
@fuel_system.check_tank_pressure

「Before は『下位の作り方まで Bridge が知ってる』状態。FuelSystem.new でインスタンスを作って、そのあとメソッドを叩く——『作る』と『叩く』の二つが Bridge に漏れてる。After は『下位は既に注入されてる、Facade は呼ぶだけ』状態。@fuel_systeminitialize の時点で既に注入されてる。run の中では叩くだけ」

ミラは並列表示の下線を指した。

「同じ十一行に見えても、書いてある情報の量が違うの。Before は各行に『作る』と『叩く』の二つの意味、After は各行に『叩く』の一つの意味。『作り方』は PreflightCheck の initialize に集約されてる、run には出てこない」

俺は、目の前の画面を、もう一度読み直した。同じ十一行に見えても、片方は「作り方まで書かれてる」、もう片方は「叩き方だけ書かれてる」。情報の量が違う。同じに見えたのは、俺の見方が浅かった、ということだ。

@fuel_system.check_tank_pressure の中身——タンク圧をどこまで待つか、閾値がいくつか——は、FuelSystem の中に書いてある。PreflightCheck は、それを呼ぶだけ。実処理は下位、呼び順は Facade。線が引いてある」

ミラは言葉を続けた。

「この線を越えると、Fat Facade っていう失敗になる。Facade の中に実処理まで書いてしまう。そうすると Facade が God Class——巨大化した何でもクラス——になって、また元の木阿弥。『順番を知る』と『実際にやる』を、部屋で分ける」

順番を知る部屋と、実際にやる部屋。同じ人が両方はやらない。俺の頭の中で、PreflightCheck の位置がすとんと決まった。

続けて def all_ready? の一行が書かれる。

1
2
3
def all_ready?
  @fuel_system.ready? && @life_support.ready? && @communications.ready? && @engine.ready?
end

「集約は Facade の仕事、と」

「集約も『段取りの一部』だから、ここに置く。呼び順を知る場所と、集約結果を返す場所は、同じ場所でいい」

&& は左から評価して、途中で false が出たらそこで false を返す仕組み——短絡評価、と呼ばれる。全部 true のときだけ全体が true になる。この一行で、四つの ready? を横に繋げる形だ。

ミラは Bridge クラスを開いた。書き直しに入る。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
class Bridge
  def preflight_check
    PreflightCheck.new.run
  end

  def rescheduled_preflight_check
    PreflightCheck.new.run
  end

  # 他五つも全て、同じ一行
  # emergency_preflight_check         → PreflightCheck.new.run
  # simulator_preflight_check         → PreflightCheck.new.run
  # docking_preflight_check           → PreflightCheck.new.run
  # audit_preflight_check             → PreflightCheck.new.run
  # post_maintenance_preflight_check  → PreflightCheck.new.run
end

……十一行が、一行に。

「他五つも全部同じ一行。七箇所 × 十一行 = 七十七行が、七箇所 × 一行 = 七行に縮む」

俺は口を開かずに、頭の中で数を数え直した。七十七行が七行。Bridge を書く人は、もう下位の十一手を諳んじなくていい。「PreflightCheck を呼ぶ」だけ覚えればいい。

俺は少し離れたところに立って、地図を見た。畳んで一面になった紙が、卓の脇で静かに置かれている。ミラの手が、そこで一度だけ止まっている。

「これ、この前見せてくれた Adapter と、今日のこれ。どっちも、下位のクラスを直接呼ばずに、間にクラスを一つ挟んで、そこから呼ぶ。形は近いな」

俺は、話しかけた。ミラは片手で「訳す」の口の動きを作って、もう片方の腕を交差させて「束ねる」の身振りをした。

「形は近い。何をしてるかが違う」

「訳す」の手を強調する。

「Adapter は『相手を変えず、外形を訳す』」

「束ねる」の腕を強調する。

「これは『相手を変えず、呼び順を束ねる』」

「訳すのと、束ねるのと」

「Adapter は、相手が一つ。相手の外形が合わないから、間に『訳す人』を挟む。これは、相手が複数。相手たちを叩く順番が長すぎるから、間に『段取りを知る人』を挟む」

Adapter は相手一個の外形を訳す。これは相手四個の呼び順を束ねる。訳すと、束ねるは、違う仕事だ。ミラの畳んだ地図を、俺はもう一度見た。

「……地図の畳みは、『束ねる』の方だな。四面あるものを、一面に集める。訳してはいない、形は同じまま」

「そう。畳んでも、開けば四面のまま」

俺は、もう一つ追い問いを重ねた。junior 目線で言えば当然出る疑問のはずだ、と自分に納得させながら口に出した。

「Adapter を四つ並べれば、これの代わりになるんじゃないか。燃料系用の Adapter、生命維持系用の Adapter、通信系用の Adapter、機関系用の Adapter を作って、Bridge から順に呼べば……」

ミラは首を横に振った。

「Adapter を四個並べても、呼び順の集約は誰もしない。Adapter は各系統の外形を訳すだけで、『四系統をどの順で叩くか』は Bridge が知り続ける。順番を知る場所が別に必要——それが今日のやつ。Adapter を並べてもこれは生まれない」

Adapter を四個並べても、順番を知る場所ができるわけじゃない。訳すのと、順番を知るのは、別の役割だ。俺はミラの手の動きを見ながら、そう飲み込んだ。

ミラはコンソールの前に戻って、卓のカードでもなく、地図でもなく、PreflightCheck のコードを指した。

「これを Facade——外観パターン、とも訳す——って言うの。複数のサブシステム——下位系統のこと——を、一枚の窓口の裏に束ねる。窓口は、呼び順の段取りだけを知る。実処理は各サブシステムの中に残す」

ミラは、畳んだ地図の一面を掌に載せて、ゆっくりと開いて、また閉じた。

「入口は一つでも、部屋は四つ残る」

窓口は PreflightCheck の一つ。裏には燃料系・生命維持系・通信系・機関系の四つの部屋が残っている。扉は開くし、閉じる。既定は閉じているけれど、選べる。俺の頭の中で、二つの部屋——「順番を知る部屋」と「実際にやる部屋」——が、それぞれの位置に収まった。

畳んだ地図の一面と、その裏に残る四面。二つの位置関係を、俺は目の前の卓の上で見直した。

Facadeパターンの Before/After 構造比較図。Before はブリッジが下位4系統(燃料系・生命維持系・通信系・機関系)の内部順序を直接叩く。After はブリッジが PreflightCheck を1回呼び、PreflightCheck が下位を束ねる。下位への直接アクセス余地は点線で残る。

俺は、ミラが持っている畳んだ地図を、手を伸ばして自分の手に借りた。地図はミラの畳みで最小の一面まで折り込まれている。俺はその一面を、もう一度開いた。四面の系統図が、卓の上に広がる。俺は自分の手で、最初から四面→二面→一面まで折り畳んでいった。ミラの手つきをなぞるようにして、俺の手つきで折る。

折り畳み終えると、一面には「PREFLIGHT」の刷り込みが表になった。俺はその地図を裏返した。無地の裏面。ペンをミラから借りて、鉛筆で一行だけ書いた。

PreflightCheck#run

書き終わって、俺は地図を卓に置いた。表を向けて置いた。ミラは口を挟まなかった。俺が書き終わるまで、卓の傍らで待っていた。

地図の一面に、PreflightCheck#run と書く。呼ぶ場所は一つ、書き付けるのも一行。

ミラが、俺の書いた地図を見ながら、静かに言った。

「下位系統の内部順序を変えても、利用側は書き換えなくていい——これが今日の一番の効能。ブリッジの七箇所を書き換える代わりに、PreflightCheck の一箇所だけを書き換える。変更が起きる場所を、七箇所から一箇所に集める」

「もう一つは、利用側は『PreflightCheck を呼ぶ』だけ覚えればいい。下位十一手の順序を諳んじる必要が消える」

変更耐性と、学習コスト削減。それだけ。それ以上を、ミラは言わない。「なんでも隠せる」とも「万能の窓口」とも、口にしない。

第4幕 模擬航走——変更耐性の実演と、隠していないこと

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

「保証を見る前に、一つだけ。Adapter は『外形を訳す』、Facade は『呼び順を束ねる』。訳した相手は形が変わるけど、束ねられた相手は形のまま、部屋の中に残る」

「……なるほど。部屋の中に、残る、か」

俺は言った。この一言で、さっきの Adapter 対比が、これから始まる保証議論の入口に繋がった。下位への直接アクセス余地が残る、というのは、Facade が「隠す」道具ではないから、というだけの話だ。訳す道具なら、訳された相手はもう元の形では見えない。束ねる道具なら、束ねられた相手は元の形で残る。

ミラは Minitest のテストファイルを開いた。標準添付の Minitest——Ruby に最初から入っているテストフレームワーク——だ。外部ライブラリは要らない、モックも使わない、実クラスの実インスタンスを渡す形で書いてある。

「順に走らせる」

一つ目は、正常系の Before=After 同値。四系統全て点検通過のシナリオで、Before の Bridge#preflight_check——inline 十一行版——と、After の Bridge#preflight_check——PreflightCheck.new.run の一行版——が両方 true を返すことを assert する。

assert_equal true, before_bridge.preflight_check assert_equal true, after_bridge.preflight_check

両方 true。……引き直しても、返す値は変わらない。挙動を変えない証明を先頭に、というのはこのシリーズの回で毎度見ている規律だ。パターンを入れて挙動が変わったら、それはリファクタリングではない。パターンを入れて挙動が変わらないことを、まず先に証明する。

二つ目は、正常系の各下位状態の同値。実行後の各下位系統の内部フラグ——タンク圧、バルブ状態、ライン封止、大気圧、温度範囲、スクラバ、アンテナ角、周波数ロック、オイル循環、暖機、センサ校正——全十一フラグが、Before と After で完全一致することを assert する。下位クラス自体は Before/After で無改変だから、フラグの立ち方も同一になる。当たり前と言えば当たり前だが、これを黙って通すのと、テストで通すのは違う。

三つ目は、下位への直接アクセス保証。Facade を経由せずに、各下位クラスを直接叩けることを確認する。

1
2
3
4
5
fs = FuelSystem.new
fs.check_tank_pressure
fs.verify_valve_state
fs.check_line_seal
assert fs.ready?

四系統それぞれで同じ形のテスト。四つ全部が緑になる。

PreflightCheck を作った後も、下位は個別に叩ける、と」

俺は言った。ミラが頷く。

「叩ける。入口を一つ作っただけ、他の入口を封じたわけじゃない。診断ツールで燃料系だけ叩きたい、テストで下位一つだけ差し替えたい、そういう場面で下位に直接触れる」

四つ目は、依存注入によるスタブ差し替え。

1
2
3
4
custom_fuel = FuelSystem.new
preflight = PreflightCheck.new(fuel_system: custom_fuel)
preflight.run
assert custom_fuel.ready?

外から FuelSystem の実体を渡すと、PreflightCheckrun の中でその実体が使われる。渡した方の custom_fuel のフラグが立つ。モックは使わない。実クラスの実インスタンスを渡す。

「これも『下位への直接アクセス余地』の一部——Facade の中身の下位を、外から選べる」

ここまで来て、五つ目——今日の主役——に入った。変更耐性の実演だ。

ミラは新しいクラス、LifeSupportV2 を開いた。

 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
class LifeSupportV2
  def initialize
    @atmosphere_ok = false
    @ventilator_ok = false
    @temperature_ok = false
    @scrubber_ok = false
  end

  def check_atmosphere_pressure
    @atmosphere_ok = true
  end

  def warmup_ventilator
    @ventilator_ok = true
  end

  def check_temperature_range
    @temperature_ok = true
  end

  def verify_scrubber
    @scrubber_ok = true
  end

  def ready?
    @atmosphere_ok && @ventilator_ok && @temperature_ok && @scrubber_ok
  end
end

生命維持系の起動プロトコル更新版だ。check_atmosphere_pressure の直後に warmup_ventilator が一手追加され、その後 check_temperature_rangeverify_scrubber の順に変わる。全四手。元の三手+一手追加。

PreflightCheck を、この V2 に対応させる」

ミラは、PreflightCheck#run の生命維持系のブロックに、一行を足した。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
def run
  @fuel_system.check_tank_pressure
  @fuel_system.verify_valve_state
  @fuel_system.check_line_seal

  @life_support.check_atmosphere_pressure
  @life_support.warmup_ventilator if @life_support.respond_to?(:warmup_ventilator)
  @life_support.check_temperature_range
  @life_support.verify_scrubber

  @communications.check_antenna_alignment
  @communications.verify_frequency_lock

  @engine.check_oil_circulation
  @engine.warmup
  @engine.calibrate_sensor

  all_ready?
end

respond_to? は、『そのメソッドを持ってるか』を聞く道具。港の通訳を立てたときに見せたダックタイピング——Ruby は型で聞かず、『呼べるか』で判断する——の応用。元の LifeSupportwarmup_ventilator を持ってないから、この行は何もせず素通り。LifeSupportV2 を注入したときだけ、warmup_ventilator が呼ばれる」

Ruby は式の後ろに if 条件 を書ける——後置 if 修飾子、と呼ばれる。条件が真のときだけ左の式が走る形だ。:warmup_ventilator の頭についている : は、シンボル——「メソッドの名前そのもの」を値として渡す書き方——で、respond_to? はその名前を受け取って、そのメソッドを自分が持っているかを真偽で答える。

元の LifeSupport は無改変。PreflightCheck に一行足しただけ。下位クラスは触ってない、Facade だけが下位の変更を追随している。前任者が七箇所直した労苦は、この一箇所に集約された。

テストを走らせる。

1
2
preflight_v2 = PreflightCheck.new(life_support: LifeSupportV2.new)
assert preflight_v2.run

Bridge 側の呼び出しは無改変のまま、LifeSupportV2 を注入した PreflightCheck.new(life_support: LifeSupportV2.new).run が、四系統全て緑で true を返した。緑だ。

俺は口に出した。

「七箇所の Bridge コードが、一行に縮んだ。……次に生命維持系の暖機順が変わるときも、直すのは PreflightCheck の一箇所で済むのか」

「済む。変更が起きる場所を、七箇所から一箇所に集めた——それが今日の一番の効能」

ミラは、続けて補足した。

LifeSupportV2 を導入すると、PreflightCheckrun にも一行足す必要がある——ここは変更を『追う』のが Facade の仕事。ただし Bridge の七箇所は無改変。変更が波及する場所を、七箇所から一箇所に集めた、って意味」

修正の総量は減らない。修正が起きる場所が七から一に集約される。Facade は下位変更を追随する当然の修正、Bridge は無改変。利用側の労苦が消える、が今日の効能だ。俺は、そう頭の中で並べ直した。前任者が七箇所直した労苦を、次に起きたときは一箇所で済ます。この引き直しは、次の担当者への引き渡しだ。

「前任者の七箇所を、次は一箇所で済ますの。変更が波及する場所を、地図の上で見せておく」

Facadeパターンの変更耐性の場所集約図。LifeSupportV2 導入時、Before はブリッジの七つの呼び出しメソッド(preflight_check ほか)全てに手を入れる必要があるが、After は PreflightCheck#run の一行追加だけで、ブリッジ七箇所はすべて無改変。

六つ目のテストは、各系統が not ready なら preflight_checkfalse になること。燃料系だけ点検を一手抜いて check_tank_pressure を呼ばない場合、fuel_system.ready?falsepreflight_checkfalse を返す。四系統ごと、四つのテスト。全系統 GREEN でないと出航前点検は通らない。

七つ目のテストは、Fat Facade 予防の証拠。

1
assert_equal [:all_ready?, :run], PreflightCheck.instance_methods(false).sort

instance_methods(false)false は「継承したメソッドは含めず、このクラスで定義した分だけを返す」の意味だ。だから Object 由来のメソッドは混じらず、PreflightCheck が自分で持っているメソッドだけが並ぶ。PreflightCheck にメソッドが二つだけ——runall_ready?——であることを assert する。他のメソッドが増えていない、ということは、実処理を書き込んでいない、ということだ。

PreflightCheck の中には、実処理は無い、と」

「無い。メソッドは runall_ready? の二つだけ。実処理は下位の中に残ってる」

ミラは、コンソールの脇に置いた畳んだ紙地図を、ほんの少し指の腹で撫でて、また指を離した。紙の縁のかすかな擦れ音だけが、ブリッジの静けさに混じる。俺は、その指の動きを、目の端で追った。ミラの手癖が、こういう場面で無音で動くのは、俺には慣れた合図だ。話が一段落した、ということだ。

ミラは、続けて、保証外事項に入った。誠実ビート——保証すること・保証しないことを、順に区別して言語化する場面だ。

一つ目。外部副作用は Facade の外。今日は出航前点検——コード上のフラグを立てる話——で、実際にタンクから燃料を送ったり、アンテナから電文を出したりする実噴射・実送信は、Facade の外の世界の話だ。「呼び順を集める」だけを Facade に集める。噴射も送信も、Facade の外。

二つ目。下位への直接アクセス余地は残る。PreflightCheck を作った後も、下位のクラスを直接呼ぶことはできる。それが今日の設計の核だ。Facade は「既定の入口を提供する」だけで、「他の入口を封じる」わけじゃない。入口は一つ、部屋は四つ残る。

三つ目。Fat Facade / God Class 化の予防。PreflightCheck の中に実処理を書き込むと、Fat Facade という失敗になる。PreflightCheck が下位の状態や設定を持ち始めたら、それは Facade ではなくて God Class になりかけの兆候だ。「段取りのみ・実処理は下位」のルールを守る。

四つ目。Mediator との違い。ミラは、この話は一文だけで済ませた。

「Facade に少しだけ形が似た Mediator っていう別の航法術もある。Mediator は、全員が必ず仲介者を経由する双方向仲介。Facade は『利用側 → Facade → 下位』の一方向で、下位から利用側に呼び返す線はない——だから下位に直接触れる逃げ道も残る。詳しくは、別の航路で」

五つ目。Ruby 動的型による実装忘れの実行時判明。新しい下位クラスに ready? を書き忘れたら、all_ready? を呼んだ瞬間に NoMethodError で鳴る。書き忘れは実行時にしか鳴らない——このシリーズで何度も出てきている技術的な支柱だ。だから模擬航走が生命線、と俺はもう自然に受け入れている。

六つ目。instance_variable_set などの反則技は、言語で防げない。instance_variable_set は、クラスの外から @fuel_system のような内部変数を名前指定で直接書き換えられる仕組みで、本来触ってはいけない場所を、規約を無視して触れてしまう。PreflightCheck の外から @fuel_system を勝手に書き換えることは、Ruby では書けてしまう。それは規約とレビューで守る領分だ。

ミラは、これで保証議論を終えた。

「最後に、二つ並べて動かしてみる」

ミラは、モニタを左右の二画面に分けた。

左半分——通常版 LifeSupportPreflightCheck.new.run の模擬航走結果。四系統全 GREEN、返り値 true

右半分——変更版 LifeSupportV2 を注入した PreflightCheck.new(life_support: LifeSupportV2.new).run の模擬航走結果。LifeSupportV2 の四手全 GREEN、他三系統も GREEN、返り値 true

両方が GREEN。Bridge 側の呼び出しは、全く同じ形——PreflightCheck.new.run——のまま。片方は既定で四系統、もう片方は生命維持系だけ V2 の四手版が注入されている。Bridge の側からは、その違いは見えない。呼ぶ側は「PreflightCheck を呼ぶ」だけを覚えていればいい。中身が V1 か V2 かは、PreflightCheck を作るときの引数で決まる。

俺はモニタの下部に開いた Bridge コードの diff を見た。左側の Before は十六行、右側の After は一行。俺は行数だけを短く読み上げた。

「Before の十六行が、After の一行」

……前任者が七箇所直したのは、この十一行の inline を、七箇所全部書き換える労苦だった。次に生命維持系の暖機順が変わるときは、この右側の PreflightCheck の中で一行足すだけで済む。ブリッジの七箇所は、無改変。手順書の「1回呼ぶ」は、ようやくコードに追いついた。

十一行が、一行になった。七箇所が、一箇所になった。手順書と、コードが、揃った。入口は PreflightCheck の一つ、裏の部屋は燃料系・生命維持系・通信系・機関系の四つ、そのまま。

ミラは、卓に置いた畳んだ紙地図を、一面だけ開いて、ブリッジのコンソールの脇に立てかけた。開かれた一面には「PREFLIGHT」の刷り込みが表になっている。畳まれていない一面は、下位が消えていないことを、そのまま卓の上で示している。

ミラはそこで動かなかった。地図の脇に指を置いたまま、そのまま立ち去らなかった。

ブリッジのコンソール、モニタは左右並列 GREEN のまま光っている。俺は、教官席の椅子の背にはもたれず、コンソール前に立ったまま、少しだけ機関室の方向を振り返った。ずっと遠く、機関の振動が、微かに感じられる程度の静けさ。通路の遠くから足音は聞こえない。誰も来ない。誰も呼ばない。ただ引き直しが済んだ、静けさだけがあった。


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

  • 航法術(パターン名): Facade(複数のサブシステム群に対して単一の「窓口」インターフェースを提供する GoF 構造パターン。窓口は呼び順の段取りのみを知り、実処理は各サブシステムの中に残す)
  • 発生事象(症状): 出航前点検の手順書には「PreflightCheck を1回呼ぶ」と1行だけ書かれていたが、実コードのブリッジ側では四系統(燃料系・生命維持系・通信系・機関系)の呼び出しが順序込みで11手続き分並び、同じ11手続きが7箇所(通常・再スケジュール・緊急・シミュレータ・停泊解除・監査・整備後)の異なるメソッドに inline されていた。手順書と実コードが乖離した状態が半年以上続き、下位系統の内部順序を変えたい場面では、ブリッジ側の7箇所全てに横断的修正を入れる必要があった
  • 原因(旧航路の問題): サブシステム内部への直接依存——ブリッジ側が下位系統の呼び順・初期化の作法を全て諳んじる設計になっていた。動作は下位クラスに実装されていたが、「呼ぶ順序を知っている場所」が利用側(ブリッジ)に散らばっていた。前任者は手順書側だけを「1回呼ぶ」に整理する保守判断を選んだが、「動いているコードに触るリスク」を避けたためコード側の追随は行われなかった。設計判断(手順書の整理)と実装の追随が別軸で進み、設計だけが先に整理されていた
  • 処置(引き直しの要点): 四系統の呼び順を集約する PreflightCheck Facade クラスを立て、initialize はキーワード引数のデフォルト値で下位クラスを依存注入可能にした(既定は自動生成、必要なら外から差し替え)。run メソッドは四系統の呼び出しを段取りのみ持ち、実処理(フラグの立て方・範囲判定)は各下位クラスに完全に残した。all_ready? で全系統の集約を1行で返す。ブリッジ側の7箇所は全て PreflightCheck.new.run の1行に縮み、下位系統の内部順序が変わっても Facade の1箇所を修正するだけで済むようになった(変更耐性)。下位クラス自体は Before/After で完全同一・無改変
  • 保証外事項: 実際の噴射・電文送出・機関始動といった外部副作用は Facade の外の世界(Facade は「コード上の呼び順を束ねる道具」で「船を動かす道具」ではない)/下位への直接アクセス余地は残る(PreflightCheck は既定の入口を提供するだけで、下位クラスを封じたわけではなく、診断・テスト・特別な用途では下位を直接叩ける)/Fat Facade の予防は設計判断(PreflightCheck に実処理を書き込むと God Class 化する)/Mediator との違いは「双方向仲介 vs 片方向既定入口」(Mediator は初出扱いで1文言及のみ)/Ruby は動的型付けで下位クラスに ready? を書き忘れれば all_ready? 呼び出し時に NoMethodError で鳴る(模擬航走が生命線)/instance_variable_set などの反則技で PreflightCheck の下位を外から書き換えられるのは言語では防げない(規約とレビューで守る)
  • 模擬航走結果: 全15テスト GREEN、警告ゼロ。正常系の Before=After 同値・各下位状態の同値・下位への直接アクセス保証4系統分・依存注入によるスタブ差し替え・変更耐性実演(LifeSupportV2 を注入して Bridge 無改変で動作)・各系統 not ready で false 4パターン・Fat Facade 予防の証拠(PreflightCheck.instance_methods(false).sort[:all_ready?, :run])。Bridge の LOC 比較——Before 16行 / After 1行——も出力に含めた
  • 次の針路: 下位系統の追加(例: Navigation 系統)と、既存 Facade の拡張ルール(新系統は PreflightCheckinitialize に追加し、run にブロックを1つ追加するだけで済む)の運用化を検討する。ただし PreflightCheck#run が20系統を超えて肥大化する場合は、別の航法術(CompositeChain of Responsibility)を検討する目安に入る
comments powered by Disqus
システム開発・AIワークフローのご相談は Meetsource
Hugo で構築されています。
テーマ StackJimmy によって設計されています。