第1幕 異変——見張り六個、書き足す俺が七個目
天測室の丸窓の向こう、深宇宙の恒星が数点。俺の背中に丸窓があり、机の上にラップトップがあり、開いた画面には Hull#status_report のコード差分が並んでいる。俺はダグ。この恒星間貨物船〈オリオール号〉の機関士だ。船暮らしはもう二十年を超えていて、機関室の壁ラックの並びは目を閉じても言える。今日は俺は機関室にいない。天測室の机の前に座っている。
天測室は普段、航法系のクルーが観測業務で使う小さな部屋だ。丸い観測窓が一つ、机が一つ、椅子が二脚、書類棚が一つ、それだけの部屋。壁は薄い金属パネルで、機関室のような油の匂いも振動もない。窓越しの恒星光だけが、暗い机上に細い筋になって差し込んでいる。空調のかすかな音以外に音源はなく、俺の指がキーボードを叩けば、その音が壁に反射して自分の耳に返ってくる。船内で「静か」と呼べる場所はいくつかあるが、天測室の静けさは、機関室の隣の通路の静けさ——振動が消えた静けさ——ともブリッジの静けさ——窓の外に音がない静けさ——とも違って、部屋そのものが音を吸い込むように黙る、と俺は勝手に呼んでいる。
俺がここにいる理由は、機関系の仕事だ。外周センサー——船体外周に配置された六機のセンサー——の読み取りコードは、機関系の低いレイヤの実装として俺が担当している。外周センサーの生データは、船体隔壁の温度分布監視や、生命維持系の外気推定にも回されるから、機関系との結合が深い。センサー自体は物理的には航法系の管轄下だが、コード上の集計処理は俺のところに落ちてくる、という配線になっている。設計判断はミラの領分でも、実装の面倒を見るのは俺だ。今日はその外周センサー読取モジュールの改修をコミットする予定で、コミット前の目視レビューをしていた。天測室に来たのは、単純に静かだからだ。機関室の作業卓は工具と伝票で埋まっているし、休憩室は当直明けのクルーが仮眠に使っている時間帯だ。天測室で作業させてくれ、と航法系の当直に一言断って、丸窓を背にした机を借りた。「観測窓を塞がないでね」とだけ返された。俺はラップトップを机の左端に寄せて、丸窓に俺の背中の隙間だけ空けて座った。
シフト開始まで四十分。ラップトップの画面には Hull#status_report の Before 側のコードが開いている。前任者——半年前に配属替えになった機関士——が書いた集計メソッドだ。俺は縦にスクロールしながら、目で追う。
| |
if @sensors[何番].nil? が、縦に六個並んでいる。センサーは外周に六機。うち二機は今、欠測している——一機は前航海で微流星帯を通過中に被弾して、外殻ごと剥がれて未修理のまま船体外周に据え置き(次の寄港基地までは修理予算が下りない)、もう一機は当初から予算削減で未設置(設計図には配線だけ残っている)。前航海の被弾は俺の当直ではなかったが、翌シフトで隔壁監視の警報履歴を確認したから、経緯は覚えている。欠測二機は当分このままだ。だから六機のうち二機分は、Hull#status_report を呼び出したときに nil が返ってくる。前任者は、それを毎回 if sensor.nil? で見張って、欠測なら既定安全値の 0.0 を代わりに集計に足す形をとった。
「見張り」——俺はこの if sensor.nil? の並びを、頭の中でそう呼んでいる。当直中に俺が舷窓の外を見張るのと同じ意味合いで、コード上で欠測を見張る場所。当直で舷窓の外を見張るとき、俺は「もし今、微流星が来たら」と一度だけ想像してから、視線を巡らせる。想像しないで見張りに立つと、視線が滑る。前任者もたぶん同じで、書き足すたびに一度だけ「もし今、欠測してたら」と想像してから、if sensor.nil? を一個増やした。今、六個並んでいる。
俺は今日、この status_report の外側に、新しい振動センサー一機分の集計ロジックを追加しようとしていた。……当然、俺も七個目の見張りを一個増やすつもりで、コミットの直前までコードを書いていた。
俺の指がキーボードから浮いて、視線が丸窓の外の恒星光に一瞬移った。
見張りが六個並んでいる。俺が足すのは、七個目だ。
その瞬間、頭の中で、一つの映像が走った。
半年後、深宇宙巡航中の当直。夜——船内時刻の夜——で、機関室の照明は当直用の弱い青に落ちている。俺は開発用のラップトップを膝に載せて、機関室の作業椅子で外周センサーの読み取りコードを触っている。船体外周に新しい振動センサーを一機追加する話が上がっていて、その集計ロジックを俺が書いている。既存の見張りを上から順に一個ずつコピーして、if @sensors[N].nil? を書き並べていく。書き並べていく途中で、うち一つで、if @sensors[N].nil? を書き忘れる。コピーの手元が一瞬滑って、貼り付けた行の一つが if の頭から丸ごと欠ける——あるいは頭の中で「もう書いた」と勘違いして次の行に進む。ラップトップのバッテリー残量は五時間、機関室の当直交代まで四時間。俺は書き忘れに気づかないままコミットして、模擬航走を走らせる。GREEN。テストは欠測でないケースだけで書かれているから、書き忘れた見張りは一度も呼ばれない。俺は満足してラップトップを閉じる。
数週間後、深宇宙で微流星帯を通過中に、その振動センサー——追加した新しい方ではなく、既に欠測扱いに戻っていた別の一機——が新たに故障する。故障のタイミングは、俺のコミットとは無関係だ。故障して nil になる。集計側は書き忘れた見張りに突入する。その日の当直は、俺自身。機関室コンソールに赤い行が吐かれる。
| |
船内アラームが鳴る。低音のうねりが機関室の壁を這って、俺の耳に届く。当直中に鳴らせちゃいけない類の音だ。機関系がフェイルセーフに落ちる。推力は落ちない、生命維持は落ちない、だが集計系が止まる——止まったのは俺の書き忘れた場所で、集計系が止まると隔壁監視の温度分布も更新が止まる。深宇宙で、俺の書き忘れで、俺が当直している晩に、集計が止まる。俺はコンソールに向かって手を伸ばす、その手が誰の手だか、映像の中でうまく判別できない。
——ここまでを、俺は天測室の机の前で、頭の中だけで一回走らせた。走らせ終わって、視線が丸窓から手元の diff に戻った。指先が少しだけ冷たい。実際には俺のシフトはまだ始まっていない。天測室の空調は一定で、俺は座ったまま動いていない。だが、頭の中の映像の残響が指先に残っている感じがする。
これが起きる前に、書き忘れる余地を消したい。だけど、見張り自体は必要だろう。欠測しているセンサーがあるんだから、nil が返ってくる。それを止められるのか。
天測室のドアが薄く開いた。ミラが入ってきた。手にはラップトップと、なぜか、透かし紙の楕円形のシールが二枚。トランプ大の大きさで、片面に “SENSOR” のブロック体印字がある。
「ダグ、おはよう。診断中?」
俺は視線を diff から一度離して、ミラの掌のシール二枚に一瞬止めて、また diff に戻す。またか、と口の中だけで呟く。ミラの手癖には長年慣れている。理由は本人がいずれ言う。俺から先に聞くことはしない。
「六箇所並んでる if sensor.nil?。……一個書き忘れたら、深宇宙で鳴る」
ミラは椅子を引いて、俺の隣のラップトップを机の隅に置いた。掌のシール二枚は、ぴたりと重ねたまま。まだ何も言わない。
「見張りは六個。ダグが足すのは七個目。……前任者は毎回、書き足すたびに一個ずつ増やしてた?」
俺は頷いた。
「たぶん。書き忘れは一度もなかったはずだ、diff 履歴を見た限り」
ミラは少し間を置いた。丸窓の外の恒星光が、机上の diff 画面と、ミラの掌のシール二枚に、同じ角度で差し込んでいる。
「“書き忘れなかった” 前任者の警告心を、“書き忘れる余地” にしてしまうのは、もったいない」
俺は初めて視線を上げた。ミラの掌のシールに、俺の目が留まった。
「余地? でも、見張り自体は必要だろう。欠測してるセンサーは実在する。nil は返ってくる」
ミラは掌のシール二枚を、指で少しだけ動かした。片方を親指で軽く持ち上げて、また戻す。無言のまま、少しの間、待った。俺は視線を diff に戻せなかった。
見張りは必要——じゃないのか?
第2幕 解析——見張ることと、見張る場所を毎回書くこと
「見張りは必要」
ミラが静かに言った。俺は頷いた。
「でも、“見張ること” と、“見張る場所を毎回書くこと” は、別」
俺は diff の見張り六箇所を指した。
「じゃあ、この六箇所は?」
「この六箇所は、前任者が “nil は異常” と毎回意識した警告心の証拠。五個も六個も並んで残っているのは、“抜け” じゃなくて、“守り” の跡」
俺は少し間を置いた。
「守り、か」
ミラは掌のシール二枚のうち一枚を、指で少しだけ持ち上げた。もう一枚が下に残る。二枚は完全に同じ模様で、下の一枚が透けて見えるわけでもない。
「前任者は “nil は異常” を意識してた。意識は正しい。抜けてたのは、意識ではなく、“意識を型で表現する手段”。だから、意識の跡が、五個も六個も、見張りとして残った」
俺の頭の中で、前任者の顔がぼんやり浮かんだ。半年前に配属替えになった機関士。真面目な人だったし、コミット履歴を見ても手を抜いた感じはしない。むしろ、書き足すたびに毎回「これも、これも」と一個ずつ見張りを増やしていったのが、この六個並びの由来だ。守ってきた。俺は差分を追いながら、そう受け取った。
ミラは掌のシール二枚を、また指で軽く動かして重ね直した。それから、少し声のトーンを落として、続けた。
「ただし、“nil を消していい” と、“nil を消しちゃいけない” は別」
俺は視線を上げた。
「欠測は “予期された不在” ——センサーが物理的に無い、あるいは故障で読めないことは、ドメイン上、正常な状態。だから “何もしない” で置き換えていい。……ただし、“本来存在するはずのものが nil で返ってきた” ときは、それは “予期されぬ不在”。そのときは raise して航行を止めるべき」
俺は口の中だけで反芻した。予期された不在。予期されぬ不在。分けるのか。
「今日の外周センサーは前者。だから、置いていい」
ミラは頷いた。俺も頷いた。線引きの話が来る、と分かっていた話でもないのに、頷いた。
俺は diff に視線を戻した。六個の見張り。それぞれの見張りは、「欠測なら 0.0、そうでなければ read を呼ぶ」という同じ形をしている。書いてある意味は、六箇所とも同じだ。違うのは、@sensors[何番] の番号だけ。
「模擬航走で、書き忘れを再現してみて」
ミラの声だった。俺は頷いて、テストコードを開いた。
status_report の見張りを一つ、例えば三番目の if @sensors[3].nil? を、意図的にコメントアウトする。そのうえで、@sensors[3] に nil を渡す模擬航走を一本走らせる。俺は Minitest の実行ボタンを叩いた。
コンソールに、赤い行が出た。
| |
俺は少し、間を置いた。
「深宇宙で聞こえるはずの警報が、天測室のラップトップで鳴った。……音は同じか」
「同じ」
ミラは頷いた。
「Ruby は動的型付け。書き忘れは出航前には鳴らない、航行中に鳴る」
動的型付け——変数や引数の型を宣言しない代わりに、メソッド不在みたいな誤りは実行時(航行中)にしか鳴らない、というやつだ。航路計算機の一件でミラが言ってた、「だから模擬航走が生命線」の理由がこれだ。書き忘れた見張りは、コンパイル時には検出されない。走らせて、そのケースに当たって、初めて鳴る。テストが欠測でないケースだけを網羅していれば、書き忘れた見張りは何ヶ月も静かに眠り続ける。眠り続けて、深宇宙で目を覚ます。
ミラは掌のシール二枚をまだ載せたまま、椅子を立った。天測室の丸窓に、静かに近づく。窓の縁に指を一本だけ触れて、掌のシールは開いたまま、視線を窓の外の恒星に移した。
無言で数秒、立った。
恒星光が机上に細い筋になって差し込んで、机の diff 画面の赤い NoMethodError の行と、隣り合わせに並んだ。俺はその様を見ていた。シールが掌にあり、窓の外に恒星があり、机の上に赤い警報が残っている。三つが同じ視界に収まる瞬間だった。天測室の空調のかすかな音以外に音源はなく、ミラの呼吸音も俺の呼吸音も、部屋の壁に吸われて戻ってこない。ミラの背中は丸窓の枠の右半分に収まっていて、掌のシールの縁が窓のガラスに触れていない、その僅かな距離が俺の目からは判別できる。
この光景を、覚えておこう。深宇宙の光と、天測室の警報と、ミラの掌のシール。俺は机の diff の赤い行を、一度だけコンソールでスクロールして、その次の行まで見た。二行目以降には何もない、書き忘れの結果は一行だけで済む、その一行が全てを止める、というのは俺の当直経験でも同じだ。機関系で本当に危ない警報は、たいてい一行で済む。長い警報のほうが、まだ、対処の時間がある。
第3幕 引き直し——本物と同じ顔で、“何もしない” を置く
ミラは窓から離れて、椅子に戻った。掌のシール二枚はまだ手にある。
「引き直しを、見せる」
俺は視線をラップトップに戻した。ミラはキーボードに手を置いた。
「Null Object(ヌル・オブジェクト) ——“何もしない” 実装を、本物と同じインターフェースで用意して、nil チェックを利用側から消す模様。GoF の原著には無いけど、Robert Martin が定式化して、Fowler が Refactoring に “Introduce Special Case” として収録した」
素の定義。俺の頭の中で言い直す。“何もしない” 実装を、“本物と同じ形” で用意する。それを “nil の代わりに” 置く。だから、呼び出し側は “空でも本物でも” 同じ書き方で扱える。……ダックタイピング——型で聞かず「このメソッドが呼べるか」で判断する Ruby の書き方——が、これを支えるはずだ。港の通訳を立てたときにミラが「相手は変えられない、変えられるのは受け取り方だけ」と言った、あの日に見た足場。
ミラはキーボードから手を離した。掌のシール二枚を、両手で持って、ぴたりと重ねた。それから——、天測室の丸窓に向かって、シール二枚を掌ごと持ち上げて、恒星光にかざした。
透かした。
「見えるでしょう。同じ形。同じ模様。光にかざしても、区別がつかない」
俺は視線を、ミラの掌のシールに固定した。恒星光が二枚のシールを通り抜けて、少しだけ透けて見える。二枚は同じ印刷。透かし紙で刷ってあって、光にかざしても、どっちが表でどっちが裏か——というより、どっちが本物でどっちが偽物か、が分からない。同一の模様が、二重に、光を透過している。“SENSOR” のブロック体印字が、二枚重なっていても一枚のときと同じ形に見える。輪郭が少しだけ濃くなっているような気もするが、それも二枚重ねだからだと言われないと分からない。透かした状態で、ミラは掌を数秒、動かさない。俺は視線を動かさない。恒星光の角度が変わっていないから、シールの見え方も変わらない。この “見え方が変わらない” という事実そのものが、ミラが今、俺に見せたい一点なのだと、俺は少し遅れて理解した。
ミラはシールを掌に戻した。
「Null Object も、これと同じ。本物のセンサーと、“何もしない” NullSensor。呼び出し側から見たら、同じ形、同じインターフェース。区別する必要がない」
ミラはキーボードに手を戻して、After 側のコードを画面に出した。
| |
「これが NullSensor。本物のセンサーと同じ read と type を持って、read は既定の安全値 0.0 を返す。……何もしない、を “本物と同じ顔で” 置く」
俺はコードを目で追った。read は 0.0 を返すだけ。type は初期化時に受け取った値をそのまま返す。書いてあるのは二つのメソッドだけ。中身は、ほとんど無いに等しい。だが、本物のセンサーと同じ形をしている。
ミラは画面を切り替えて、Hull#status_report の After 側を出した。
| |
俺は画面を上から下に、二度、目で追った。
if sensor.nil? が、消えている。
さっきの六個並びのあった場所に、each のブロックが一つ、case sensor.type の分岐が一つ、それだけがある。集計ロジック本文が、丸ごと剥き出しになっている。見張りが消えて、本文だけが残った、という形をしている。Before では六箇所の見張りに埋もれて見えなかった「型別に振り分けて集める」という本来の意図が、After のコードでは真ん中に据わって読める。上から順に読むと、each に入って、sensor.type で分岐して、対応する配列に sensor.read を追加する、という三段の動きが、そのまま日本語で言い換えられる形をしている。
そうか、集計側は「本物か NullSensor か」を気にしない。どっちが来ても、同じ形で扱える、から。ダックタイピング——sensor.read と書いたときに、Ruby は sensor の型を聞かない。read が呼べるか、だけを見る。本物のセンサーにも read はある。NullSensor にも read はある。だから、集計側は分岐しなくていい。sensor.type も同じで、本物にも NullSensor にも type はある。同じインターフェース——同じ形の呼び出し口——を持たせておくと、集計側は “誰が来たか” を確認しない。確認する必要がないから、確認するコードも要らない。要らないから、if sensor.nil? は消える。
ミラの掌のシールが二枚で同じ模様に見えたのと同じで、Hull からは四つのセンサープレートが同じ形で並んでいる。

港の通訳を立てたときに聞いた「相手は変えられない、変えられるのは受け取り方だけ」を、俺はもう一度、頭の中で呟いた。あの回は、外の相手が別のインターフェースで話してくるのを、こちらで受け取り方(Adapter)に変換して吸収する話だった。今回は、こちらが渡す方——本物か NullSensor か——のインターフェースを揃えて、受け取る側(集計)が分岐しないで済むようにする話だ。方向は逆だが、根っこは同じ「受け取り側から分岐を消す」思想の別の顔だ、と俺は勝手に整理した。
俺は口を開けた。分かってはいるが、junior だった頃の俺なら詰まっただろう場所を、今、確認しておきたかった。
「これ、&. でも同じことができるんじゃないか。sensor&.read で書けば、欠測でも例外は出ない」
&.——ぼっち演算子、と現場で呼ばれている、obj&.method の記法。obj が nil のとき、nil を返して例外を出さない構文。Ruby 2.3 で追加されて、実務でよく見る。俺自身、これまで何度か使ったことがある。
ミラは掌のシール二枚のうち、一枚だけを卓の上に置いた。もう一枚は掌に残した。卓のシールを、指で軽く指し示した。
「&. は “呼び出し側が nil を扱う” 演算子。sensor&.read は sensor が nil のとき、nil を返す。じゃあ、その nil を、呼び出し側は何に使うの? 集計に足す? 平均を取る? そのたびに、呼び出し側で “nil のとき何をするか” を書く」
ミラは卓のシールを、指でもう一度軽く動かした。
「&. は “誰が nil を扱うか” を “呼び出し側” に押しつけたまま。Null Object は “nil のときの振る舞い” を “定義側(NullSensor 自身)” に閉じ込める。……押しつけ先が違うだけ」
押しつけ先。俺は頭の中で、卓のシールと掌のシールを、二つの絵として並べた。
役割分担が違う。&. は呼び出し側で判断する。Null Object は NullSensor 自身が判断する。押しつけ先が違うと、書き忘れる場所も違う——&. は「呼ぶ側で毎回どうするか書く」、Null Object は「定義側で一回書いたら終わり」。
俺は視線を、卓のシールに落とした。ミラの言葉を、少しの間、繰り返した。定義側で一回書いたら、終わり。
俺は卓のシール一枚を、指で借りた。掌の上に載せて、もう一枚——ミラの掌に残っていた方——をミラに借りて、自分の掌で重ねた。二枚を、ぴたりと重ねた。
天測室の丸窓に、俺の手で、シールを掲げた。窓の外の恒星光を、俺の掌の上で透かした。
シールの模様が、恒星光を透過して、少しだけ透けた。二枚の模様が、重なって、同じ模様が二重になって見えた。区別がつかない。
俺は視線を、シールから、窓の外の恒星光に移した。少しの間、そのまま見た。それから、また、シールに戻した。
ミラは口を挟まなかった。卓の傍らで、俺の手元とシールを、静かに見ていた。
俺はシールを机の上に降ろした。一枚を裏返して、無地の裏面に、鉛筆で一語だけ書いた。
NullSensor
書いたのは、クラスの名前。「NullSensor」と一語だけ。他には何も書かない。定義の説明も、既定値も、read メソッドの中身も、書かない。名前だけ。ミラは書き終わるまで待った。俺が鉛筆を置くと、ミラは頷いて、視線をラップトップに戻した。
名前を書くと、この「見えない模様」が、明日から俺の頭の中で「同じ形で扱える一つのクラス」として並ぶ気がした。書かなかったら、俺の頭の中では今日の光景——シールが透けて、恒星光が机上に差して——だけが残って、明日には「あれは何だったっけ」で流れていったかもしれない。名前を書くと、光景の代わりに、クラスの名前で呼び出せるようになる。過去にも、ミラの引き直しに立ち会うたびに、俺は何かに——机の隅の紙片に、手元の書類の余白に、時には自分の掌の上に——その回の一語を書き付けてきた気がする。書き方は回によって違うが、書いた瞬間に頭の中に何かが並ぶ、その並び方だけは毎回同じだ。今日は一語。「NullSensor」の八文字。それだけで、たぶん明日の俺のコミット前レビューは、少し違う目で if sensor.nil? を見る。
第4幕 模擬航走——見張り数、六個から零個
ミラはコンソールから顔を上げた。
「保証を見る前に、一つだけ。……&. は “呼び出し側で nil を毎回さばく”、Null Object は “定義側で nil の代わりを一回配置する”。同じ nil 対策でも、“誰が nil のときの振る舞いを決めるか” が違う」
俺は頷いた。掌のシール一枚は、まだ俺の卓の上にある。裏面の “NullSensor” の鉛筆書きが、正面から見える。
ミラはラップトップの画面を左右に分けた。二つの模擬航走を並列で流す準備だ。
「左半分。実センサー全機で、Hull.new(sensors: [...]).status_report。……右半分。うち二機を NullSensor に差し替えて、同じ呼び出し」
ミラは実行ボタンを叩いた。二つのターミナルが左右で走った。カウントダウンが同時に進み、同時に止まった。
左半分(実センサー六機):
| |
status_report が返したハッシュ:
| |
右半分(実センサー四機+ NullSensor 二機):
| |
status_report が返したハッシュ:
| |
俺は両方の GREEN を、少しの間、見た。左半分の GREEN と右半分の GREEN は、表示のフォントも色も同じで、走った時間も揃っている。カウントダウンの . の並びが左右で同じ数だけ点いたのを、俺は左から右へ、右から左へ、二度、目で追った。
両方通った。集計側 status_report の実装は、一行も変わっていない。変わったのは、“渡すもの” だけ。本物のセンサーの代わりに、NullSensor を渡した。……それだけで、集計は続いている。右半分の結果では、欠測二機分が 0.0 に落ちて、他は実値のまま。ハッシュの形は、左と同じ形をしている。キーの順序も、値の並びも、左と右で違和感がない。集計側から見れば、右半分は “欠測が二機ある船体外周” の当たり前の姿として振る舞っている。当たり前、という感触が、俺の中で少しずつ形を得ていく。半年前の前任者が毎回 if sensor.nil? を書き足していた時代には、この “当たり前” は無かった。当時は、nil が返ってくること自体が、集計側の毎回の緊張の元だった。今、その緊張は、build_hull_sensors の一箇所——船体を組み立てるときの判断——に移っている。集計側は、緊張しなくていい。
俺は少し、思ってから、口を開けた。junior だった頃の俺が引っかかりそうな場所を、もう一つだけ、確認しておきたかった。
「じゃあ、“何もしない” が悪い場合はないのか。……本当に nil が返っちゃいけない場所で、“何もしない” で通してしまったら」
ミラは頷いた。予想していた問いだった、という頷きだった。
「ある。……機関室の温度センサーが nil を返してきたら、それは “本来存在するはずの機器がロストした” 状態。ここで NullSensor に差し替えて 0.0 を返したら、“温度が 0 度” と誤って集計されて、機関系が異常低温フェイルセーフを誤発動する」
俺は口の中で反芻した。異常低温フェイルセーフ。温度が 0 度と誤って集計される。……本当に、鳴らないと駄目な警報が、0.0 に置き換わって、届かない。
「予期された不在(今日の外周欠測)は NullSensor。予期されぬ不在(機関室の必須機器のロスト)は raise。……判別のルールは一つ。“その nil はドメイン上、正常な状態か?” を、一度、問う」
俺は頷いた。頭の中で、金言の前置きが自分の声で回った。隠していい nil と、隠してはいけない nil。今回のは、予期された欠測。だから置いていい。
ミラは頷き返した。それから、もう二本、模擬航走を続けて走らせた。
「もう一つだけ。欠測が零機のケースと、六機全部欠測のケースと、集計側は同じ形で通る」
三本目の模擬航走。全機実センサー。GREEN。ハッシュは六件全部が実値。
四本目の模擬航走。全機 NullSensor。GREEN。ハッシュは六件全部が 0.0。
status_report の実装は、四本の模擬航走のいずれでも、無改変。俺は少しの間、四本の GREEN を見た。
“欠測パターンが変わっても、集計側は無改変”。この “書き換え不要” が、“書き忘れる余地の消滅” の反対側にある。書き忘れる場所が消えたから、書き換える場所も消えた。同じことを、逆から見ている。
俺は、Before と After の diff を、もう一度、画面に並べた。左に Before、右に After。
Before の status_report には、if sensor.nil? が六個並んでいた。After の status_report には、if sensor.nil? が、一個もない。
俺は画面を指しながら、diff を読み上げた。行数ではなく、“見張り数” で。
「Before の if sensor.nil? が六個並んでいた。After は零個」
並べてみると、消えた場所の数が、そのまま残っている場所の少なさとして目に見える。

行数の diff じゃない。俺が数えたのは、“忘れたら壊れる場所” の数だ。それが六個から零個になった。……忘れる余地が、消えた。
ミラは掌に残っていた “SENSOR” シール二枚のうち、一枚だけを卓の端に伏せて置いた。表を下にして、机の縁近くに、そっと。もう一枚は掌に残した。
「“ない” を、“何もしない” で置くの」
ミラの声は静かだった。金言と分かる声だった。
俺は伏せられた一枚と、ミラの掌に残った一枚を、同じ視界に収めた。「ない」は消えない。ただ、「何もしない」が同じ形で、そこに置かれる。本物は、掌に残る。
天測室の丸窓の外、深宇宙の恒星光だけが、変わらず机上に細い筋になって差し込んでいる。
俺は伏せられたシールを、指で軽く触れて、そのまま残した。触れた指先で、シールの表面の透かし紙の質感が、乾いていて滑らかで、少しだけ紙の繊維の凹凸がある、というのが伝わってきた。二枚のうちの一枚——伏せて置かれた方——は、今、“何もしない” 側だ。もう一枚——ミラが掌に残していた方——は、“本物” 側。物理的にはどちらが本物でどちらが偽物か、二枚を並べても分からない。だが、ミラが「一枚を伏せる」という所作を選んだことで、俺の頭の中では二枚に役割が付いた。伏せられた方が NullSensor、掌に残った方が実センサー。役割は物理ではなく、置き方で付いた。
ミラは掌のシールを、静かに書類棚の上に戻した。書類棚の上段の棚板に、シールの片面が触れる音が、微かに聞こえた。書類棚の上には、他の観測資料が数冊、背表紙を揃えて並んでいる。シールは、その資料の並びの隅に、そっと置かれた。次の航法士がこの部屋を使うときに、シールが視界の隅に入るかどうか、それは俺には分からない。分からないが、置かれた場所は、俺の頭の中では覚えておこう、という位置だった。
天測室を出よう。今日の diff は、コミットできる。
補記——本物と同じ顔、を Ruby で
junior 読者の後追い用に、After のコードで押さえておきたい足場を、二つだけ整理しておく。ミラの言葉のうち、実装のディテールが要る部分だ。
一つは、NullSensor が「本物と同じ顔」であるために、何を持てばいいか、という話。今日の外周センサーの場合は、read と type の二つを持てば足りた。集計側の status_report が使うのは、sensor.type と sensor.read の二箇所だけだからだ。呼び出し側が使うメソッドの分だけ、Null Object 側にも同じメソッドを用意する——これがダックタイピングと Null Object の相性の良さでもある。もし呼び出し側が sensor.calibrate みたいなメソッドも使っていたら、NullSensor 側にも def calibrate; end(何もしない)を足せばいい。
もう一つは、read が返す既定安全値の話。今日の場合は 0.0 を返している。これは “呼び出し側の集計ロジックが、この値を受け取っても壊れない・むしろ意味のある値として扱える” ことを前提にしている。温度・振動・電磁波の集計で 0.0 が入っても、ハッシュの形は変わらないし、平均を取ってもゼロ除算にならない(実センサーの他機の値と一緒に平均される想定)。……ここは本文でミラも触れていた通り、“予期された不在” が “ドメイン上、正常な状態” として扱えるからこそ成立する既定値だ。もし 0.0 が集計側の計算に致命的な影響を与えるドメイン(例えば、0.0 を “アラーム発火の閾値” と誤解する集計)なら、既定値は別の値にするか、そもそも Null Object を使わずに raise する側の判断になる。
Proxy、Decorator、Optional/Maybe、Composite——Null Object と近縁のパターンや対比軸は他にも複数あるが、今日の話の主戦場は「呼び出し側の nil チェックを、定義側の “何もしない” 実装に置き換える」という一点だ。近縁は名前だけ挙げて、別の航路で読み直す価値のあるものとしておく。
俺は天測室を出て、通路を進んだ。丸窓の恒星光が背後で少しずつ遠ざかっていく。振動が微かに戻ってきて、機関室が近づいているのが分かる。今日のコミットは、七個目の見張りを書き足すコミットではなく、六個の見張りを消すコミットに、書き直してから出す。書き直しの時間は、シフト前に十分間に合う。
🚀 オリオール号 航海日誌(Navigator’s Log)
- 航法術(パターン名): Null Object(ヌル・オブジェクト)——“何もしない” 実装を本物と同じインターフェースで用意し、呼び出し側の
nilチェックを消す構造の模様 - 発生事象(症状): 天測室で外周センサー読取モジュールのコミット前レビュー中、
Hull#status_reportにif sensor.nil?の見張りが六箇所並んでいるのを発見。書き足すたびに一つ増える構造で、一箇所書き忘れれば深宇宙でNoMethodErrorが鳴る予告 - 原因(旧航路の問題): 欠測(未設置・故障のセンサー)を
nilで表現していたため、集計側(呼び出し側)が nil チェックを毎回書く責任を負っていた。前任者の警告心(“nil は異常” の意識)は正しかったが、意識を型で表現する手段が欠けていた - 処置(引き直しの要点):
NullSensor(本物のセンサーと同じread/typeインターフェースを持つ “何もしない” 実装)を導入し、欠測を NullSensor に置き換えた。呼び出し側のif sensor.nil?は六箇所すべて消え、集計ロジックは “本物か NullSensor か” を気にしない。判断は “船体の構築時” に一箇所集約 - 保証外事項: 本来存在するはずの機器が nil を返してきたとき(予期されぬ不在)に NullSensor で隠すのは危険。そのケースでは
raiseして航行を止めるべき——判別は “その nil はドメイン上、正常な状態か” を一度問う。また、&.(safe navigation)とは役割分担が違い、&.は呼び出し側で nil を毎回さばく演算子、Null Object は定義側で nil の代わりを一回配置する模様、で使い分ける - 模擬航走結果: 実センサー全機での集計・欠測二機を NullSensor に差し替えた集計・全機欠測・全機実センサーの四パターン、いずれも GREEN。
status_reportの実装は一行も無改変 - 次の針路: 深宇宙の外周巡回航路へ、次のシフトから。書き忘れる余地が、今日消えた
