空欄だらけの表
ドアをノックした。返事がない。
問い合わせフォームから送った相談内容に対して、「火曜14時。帳票の構造がわかるサンプルコード必須」とだけ書かれた返信が来たのは先週のことだ。LTスライドに載っていたURLを辿ってたどり着いた「レガシー・コード・インベスティゲーション」なる団体。雑居ビルの三階。すりガラスの向こうに人影は見えるが、こちらのノックには無反応だった。
ドアノブを回すと、鍵は開いている。仕方なく中に入った。
最初に目に入ったのは、壁一面に広がるホワイトボードだった。そこに描かれた表——縦4行、横3列のマトリクス。縦軸に「請求書」「見積書」「納品書」「???」、横軸に「PDF」「CSV」「HTML」。マスの大半には「?」が書き込まれている。
表に向かって立っている長身の男が、チョークを走らせている。漂うエナジードリンクの甘い匂い。デスクの上には飲みかけの缶が3本。
咳払いをした。
男が振り返った。私を一瞥し、すぐにホワイトボードに目を戻す。
「ちょうどいい。ワトソン君、この表の4行目——」
「私の名前は田中です。問い合わせフォームから連絡した者ですが」
「ああ、帳票の。知ってるよ。で、ワトソン君、この表の4行目に何が入るか、君は知っているはずだ」
ため息を飲み込んだ。訂正のコストに見合わない。それよりも、この表が気になった。問い合わせフォームに「帳票出力のif文が増え続けて破綻した」と書いただけだ。それだけの情報でマトリクスを描いている。
「……発注書です」
ロックさんはホワイトボードの「???」を消し、「発注書」と書いた。そしてCSV列と発注書行が交差するマスに、赤いチョークで大きくバツを入れた。
「ここが空欄になった。そうだね?」
「……はい。CSVフォーマッターに発注書の分岐を追加し忘れました」
「経緯を聞かせてくれたまえ。サンプルコードは持ってきたかね」
ラップトップを開いた。帳票出力システムの概要を説明する。請求書、見積書、納品書の3種類。出力形式はPDF、CSV、HTMLの3つ。先週「発注書」を追加した。PdfFormatterとHtmlFormatterには分岐を入れた。CsvFormatterを見落とした。CSVエクスポートで空文字が返り、顧客の経理部門から「CSVに発注書のデータがない」と連絡が来た。テストは落ちなかった。CSV×発注書のテストケースがそもそも存在しなかったから。
ロックさんがラップトップの画面を覗き込み、CsvFormatterのコードをスクロールした。
「何行目から何行目が format メソッドかね」
「15行目から58行目です」
「即答できるあたり、何度もこのコードと格闘したのだろう。——いい現場だ」
褒められたのか皮肉なのか判断できなかった。ただ、この人はコードを見る目が確かだ。フォーマッターのコードを10秒眺めただけで、何を問題にすべきか見当がついている。
型を尋問する共犯者たち
ロックさんが画面を指し、コードの核心部分を読み上げた。
| |
「3つのフォーマッターに、この isa の連鎖がそれぞれいくつある?」
「PdfFormatterに4つ、HtmlFormatterに4つ、CsvFormatterに——3つです。追加し忘れたので」
ロックさんがホワイトボードに向き直り、isa('Invoice') → isa('Quote') → isa('DeliveryNote') → ??? と矢印を描き足した。
「ワトソン君、この事件の犯人は発注書の分岐を追加し忘れた君ではない」
「では何が犯人だと?」
「この構造だ」
ロックさんが isa() の連鎖を指した。
「帳票の種類を外から尋問しているのだよ。『お前は何者だ? 請求書か? 見積書か? 納品書か?』——容疑者を一人ずつ連れてきて名札を確認している。名簿に載っていない人間が来たら、素通りさせてしまう」
名簿。確かにそうだ。isa() の連鎖は、フォーマッターが知っている帳票の一覧表だ。一覧表は手で更新する。手動更新は漏れる。
「isa() の連鎖は名簿の照合だ。名簿は手動更新。そして手動更新はいつか漏れる」
ロックさんが画面に戻り、return '' の行を指した。
「この空文字列が犯人の逃走ルートだ。分岐に該当しないとき、コードは何もせずに空文字を返す。エラーにならない。ログにも残らない。顧客が気づくまで、コードは何も壊れていないふりをする」
「静かに壊れる」
「その通り。問題はコードの作者ではない。型を外から調べるという判断そのものだ」
反論しようとして、止まった。型を調べなければ、どのフォーマットを使うかわからない。でも——型を調べる構造そのものが問題だと言っている。
「でも、型を調べなければ、どのフォーマットを使うかわからないですよね?」
「いい質問だ。——ワトソン君、駅の改札を思い浮かべてくれたまえ。改札機は切符の種類を見て通すか止めるかを決めている。だが改札機は切符に『お前は何の切符だ?』と尋ねているかね」
一瞬考えた。
「いいえ。ICカードが、タッチした時点で自分の情報を送る」
「型は外から調べるのではなく、本人に名乗らせる。これが第一のディスパッチだ」
ロックさんがホワイトボードのマトリクス表の横に新しい図を描き始めた。帳票から矢印が伸び、フォーマッターのメソッドに向かっている。矢印の向きがさっきまでと逆だった。
フォーマッターが帳票を尋問するのではなく、帳票がフォーマッターに名乗り出る。
名乗り、そして応答
「帳票は自分が何者か知っている。だから、フォーマッターに自分を渡すとき、自分の型名を込めたメソッドを呼ぶのだよ。請求書なら format_invoice と名乗る。見積書なら format_quote と名乗る。フォーマッターは名前を聞いてから、処理を選ぶ」
ロックさんがコードを書き始めた。
| |
| |
| |
| |
| |
ホワイトボードに近づいた。ロックさんが描いた矢印を指でなぞる。
「待ってください。最初の accept の呼び出しで、Perlのメソッドディスパッチが帳票の型を解決する。Invoiceなら Document::Invoice::accept が呼ばれる。これが1回目」
ロックさんがうなずいた。
「Invoice::accept の中で $formatter->format_invoice($self) を呼ぶ。ここでPerlがフォーマッターの型を解決する。CsvFormatterなら Formatter::Csv::format_invoice が呼ばれる。これが2回目」
「その通り」
「二重ディスパッチ。1回目で『誰が』を決め、2回目で『何を』を決める」
「名前は後でいい。大事なのは構造だ。——さて、ここからが本題だよ、ワトソン君」
ロックさんが新しいコードを書いた。
| |
「この requires は——」
「これが今回の事件の鍵だよ、ワトソン君」
コードを見つめた。requires の行に4つのメソッド名が並んでいる。
「新しいフォーマッタークラスを作って with 'DocumentFormatter' と書いた瞬間、format_purchase_order を実装していなければ Moo がエラーを出す。クラスの構築時に——テストを走らせるまでもなく——追加漏れが発覚する。本番で空のCSVが出力されることは二度とない」
「あ、」
声が漏れた。
「——追加漏れが構造的に不可能になる」
「その通り」
ロックさんが具象フォーマッターのコードを書き始めた。
| |
Beforeのコードと見比べた。
「if-elsif が消えた。完全に」
「isa() の一文字も残っていない。型はもう尋問されない。自分で名乗り、相手がそれに応える。対等な会話だ」
ホワイトボードに新しいマトリクス表を描き始めた。4行×3列。各マスにメソッド名を書き込んでいく。format_invoice × Csv、format_invoice × Html、format_invoice × Pdf……。
「帳票を追加するたびに、Roleに新しいメソッド名を requires に追加する。そして全てのフォーマッターに新しいメソッドを実装する」
手が止まった。
「修正箇所は減っていません」
「鋭い指摘だ」
ロックさんが少し笑った。珍しい表情だった。
「修正箇所が分散しているのは事実だ。だがワトソン君、if-elsif地獄と決定的に違う点が一つある」
「何ですか」
「if-elsif は分岐を追加し忘れても動く。空文字が返る。顧客が気づくまで誰も気づかない。だが requires はメソッドを追加し忘れたらクラスが構築できない」
ロックさんがホワイトボードの赤いバツ——最初の事件現場マーク——を指した。
「『気づかない失敗』が『見逃せないエラー』に変わる。——ワトソン君、完全な解決策など存在しない。問題の性質を変えるのだ」
完全な解決策など存在しない。問題の性質を変える。
if-elsif では、忘れても動く。Double Dispatch では、忘れたら止まる。修正箇所の数は同じでも、忘れたときに何が起きるかが決定的に違う。
ホワイトボードのマトリクス表を見つめた。全てのマスが埋まっている。もう「?」はない。
sequenceDiagram
participant C as Caller
participant D as Document::Invoice
participant F as Formatter::Csv
C->>D: $invoice->accept($csv_formatter)
Note over D: 1st dispatch: Invoice::accept
D->>F: $formatter->format_invoice($self)
Note over F: 2nd dispatch: Csv::format_invoice
F-->>C: CSV文字列を返却
静かな崩壊が終わる日
「実演しよう。新しい帳票『領収書(Receipt)』を追加するとしたまえ」
ロックさんがコードを書いた。
| |
「帳票クラスは追加した。次にRoleの requires に format_receipt を追加する」
| |
「さて、CsvFormatterに format_receipt をまだ実装していない状態で、テストを走らせてみよう」
ロックさんがターミナルでコマンドを叩いた。
| |
「テストの中身に到達する前に、クラスの構築段階で止まった」
「これが二重ディスパッチの真価だ。型は自分で名乗り、受け手はその名乗りに応える義務を負う。義務を果たさなければ、システムが止まる。静かな崩壊ではなく、明白な拒絶だ」
classDiagram
class Document {
+items
+accept(formatter)
}
class Document_Invoice {
+tax_rate
+accept(formatter)
}
class Document_Quote {
+valid_days
+accept(formatter)
}
class Document_DeliveryNote {
+shipped_date
+accept(formatter)
}
class Document_PurchaseOrder {
+order_date
+accept(formatter)
}
class DocumentFormatter {
<<Role>>
+format_invoice()*
+format_quote()*
+format_delivery_note()*
+format_purchase_order()*
}
class Formatter_Csv {
+format_invoice()
+format_quote()
+format_delivery_note()
+format_purchase_order()
}
class Formatter_Html {
+format_invoice()
+format_quote()
+format_delivery_note()
+format_purchase_order()
}
Document <|-- Document_Invoice
Document <|-- Document_Quote
Document <|-- Document_DeliveryNote
Document <|-- Document_PurchaseOrder
DocumentFormatter <|.. Formatter_Csv
DocumentFormatter <|.. Formatter_Html
もし先週、この構造になっていたら。発注書を追加した時点で、CsvFormatterがエラーを出していた。空のCSVは出力されなかった。顧客からの電話もなかった。
起きてしまったことは変えられない。だが、同じことを二度と起こさない構造にはできる。
ロックさんが Formatter::Csv に format_receipt を追加し、テストを再実行した。ターミナルに緑の文字が流れた。
All tests successful.
「ビルドが通った。システムに秩序が戻った」
ラップトップを閉じた。帰り支度をする。
「ありがとうございました。一つ聞いてもいいですか」
「どうぞ」
「この構造で——帳票の種類が固定で、操作の方を増やしたい場合。バリデーター、プレビュー生成器、統計集計器。全部同じ accept の構造で追加できますか?」
ロックさんが少し笑った。椅子に深く腰掛ける。
「ワトソン君、それは次の事件だ。この手口を組織的に使い回す手法には名前がある。だが今日はDouble Dispatchだけ持ち帰りたまえ。部品の意味を知らずに建物を建てても、いい建物にはならない」
次の事件、と言った。この人にとって、構造の問題はすべて「事件」なのだ。
「報酬の話をしていませんでした」
「いらない。今回は表を描く口実をもらった。それで十分だ」
軽く頭を下げて事務所を出た。
階段を降りながら考えた。4種類×3形式=12メソッド。if-elsif のときは3フォーマッターに4分岐ずつだったから、処理の数は同じだ。コード量はむしろ増えている。
でも——追加漏れは構造的に消える。「気づかない失敗」がなくなる。
それは、コード量では測れない価値だ。
帰りの電車でノートPCを開いた。CsvFormatterの format メソッドを開く。
| |
この行にカーソルを合わせた。
消した。
with 'DocumentFormatter'; と打った。
これで、次の帳票を追加するとき。私は何も忘れない。正確には——忘れることが許されなくなる。
探偵の調査報告書
| 容疑(アンチパターン) | 真実(パターン) | 証拠(効果) |
|---|---|---|
isa() / ref() による型チェック連鎖(if-elsif 地獄) | Double Dispatch(二重ディスパッチ) | 型の判定を外から排除し、追加漏れの検出をクラス構築時に前倒し |
| 条件分岐の追加漏れ(サイレント失敗) | Moo::Role の requires による契約 | 未実装メソッドがあればクラス構築が拒否される(ラウド失敗) |
| 型の組み合わせ爆発を手動管理 | accept/visit の二段構え | 新しい型の追加時、全フォーマッターへの実装が構造的に強制される |
推理のステップ
- 各フォーマッタークラスの
formatメソッド内にあるisa()連鎖を特定する Document基底クラスにaccept($formatter)メソッドを定義する- 各帳票クラスの
acceptメソッドで、自分の型名を込めた$formatter->format_<type>($self)を呼ぶ DocumentFormatterRole で全てのformat_<type>メソッドをrequiresで宣言する- 各フォーマッタークラスで Role を適用し、型固有メソッドを実装する(
isa()は消える) - 新しい帳票型を追加し、
requiresによるクラス構築時エラーが発生することを確認する
ロックより
型は自分の名前を知っている。知っているのだから、本人に名乗らせればいい。
外から「お前は何者だ?」と尋問する構造は、尋問者の名簿が完全であることを前提にしている。名簿は手動更新だ。手動更新はいつか漏れる。漏れたとき、コードは何も壊れていないふりをする。これが最も危険な失敗だ。
二重ディスパッチは、問題を消すのではない。問題の性質を変えるのだ。「気づかない失敗」を「見逃せないエラー」に変える。
構造は万能ではない。だが、構造があれば失敗の到着地点を選べる。空のCSVが顧客に届くのと、ビルドが止まるのと——どちらの失敗を引き受けるか。その選択こそが設計だ。
