Featured image of post コード探偵ロックの事件簿【Double Dispatch】消えた発注書〜型の組み合わせが生んだ空白〜

コード探偵ロックの事件簿【Double Dispatch】消えた発注書〜型の組み合わせが生んだ空白〜

帳票×出力形式のif-elsif連鎖でCSV出力に発注書の分岐漏れが発生。Double Dispatchのaccept/visit二段構えとMoo::Role requiresで追加漏れをクラス構築時に検出する実装を解説。

空欄だらけの表

ドアをノックした。返事がない。

問い合わせフォームから送った相談内容に対して、「火曜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秒眺めただけで、何を問題にすべきか見当がついている。

型を尋問する共犯者たち

ロックさんが画面を指し、コードの核心部分を読み上げた。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
# lib/Formatter/Csv.pm (Before)
package Formatter::Csv;
use v5.36;
use Moo;

sub format ($self, $document) {
    if ($document->isa('Document::Invoice')) {
        return $self->_format_invoice($document);
    }
    elsif ($document->isa('Document::Quote')) {
        return $self->_format_quote($document);
    }
    elsif ($document->isa('Document::DeliveryNote')) {
        return $self->_format_delivery_note($document);
    }
    # Document::PurchaseOrder の分岐が存在しない!
    return '';
}

「3つのフォーマッターに、この isa の連鎖がそれぞれいくつある?」

「PdfFormatterに4つ、HtmlFormatterに4つ、CsvFormatterに——3つです。追加し忘れたので」

ロックさんがホワイトボードに向き直り、isa('Invoice')isa('Quote')isa('DeliveryNote')??? と矢印を描き足した。

「ワトソン君、この事件の犯人は発注書の分岐を追加し忘れた君ではない」

「では何が犯人だと?」

この構造だ

ロックさんが isa() の連鎖を指した。

「帳票の種類を外から尋問しているのだよ。『お前は何者だ? 請求書か? 見積書か? 納品書か?』——容疑者を一人ずつ連れてきて名札を確認している。名簿に載っていない人間が来たら、素通りさせてしまう」

名簿。確かにそうだ。isa() の連鎖は、フォーマッターが知っている帳票の一覧表だ。一覧表は手で更新する。手動更新は漏れる。

isa() の連鎖は名簿の照合だ。名簿は手動更新。そして手動更新はいつか漏れる」

ロックさんが画面に戻り、return '' の行を指した。

「この空文字列が犯人の逃走ルートだ。分岐に該当しないとき、コードは何もせずに空文字を返す。エラーにならない。ログにも残らない。顧客が気づくまで、コードは何も壊れていないふりをする」

「静かに壊れる」

「その通り。問題はコードの作者ではない。型を外から調べるという判断そのものだ

反論しようとして、止まった。型を調べなければ、どのフォーマットを使うかわからない。でも——型を調べる構造そのものが問題だと言っている。

「でも、型を調べなければ、どのフォーマットを使うかわからないですよね?」

「いい質問だ。——ワトソン君、駅の改札を思い浮かべてくれたまえ。改札機は切符の種類を見て通すか止めるかを決めている。だが改札機は切符に『お前は何の切符だ?』と尋ねているかね」

一瞬考えた。

「いいえ。ICカードが、タッチした時点で自分の情報を送る」

型は外から調べるのではなく、本人に名乗らせる。これが第一のディスパッチだ」

ロックさんがホワイトボードのマトリクス表の横に新しい図を描き始めた。帳票から矢印が伸び、フォーマッターのメソッドに向かっている。矢印の向きがさっきまでと逆だった。

フォーマッターが帳票を尋問するのではなく、帳票がフォーマッターに名乗り出る。

名乗り、そして応答

「帳票は自分が何者か知っている。だから、フォーマッターに自分を渡すとき、自分の型名を込めたメソッドを呼ぶのだよ。請求書なら format_invoice と名乗る。見積書なら format_quote と名乗る。フォーマッターは名前を聞いてから、処理を選ぶ」

ロックさんがコードを書き始めた。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# lib/Document.pm — 基底クラス
package Document;
use v5.36;
use Moo;

has items => (is => 'ro', required => 1);

sub accept ($self, $formatter) {
    die ref($self) . "::accept is not implemented";
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# lib/Document/Invoice.pm
package Document::Invoice;
use v5.36;
use Moo;
extends 'Document';

has tax_rate => (is => 'ro', default => sub { 0.1 });

sub accept ($self, $formatter) {
    $formatter->format_invoice($self);  # 自分が Invoice であることを名乗る
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# lib/Document/Quote.pm
package Document::Quote;
use v5.36;
use Moo;
extends 'Document';

has valid_days => (is => 'ro', default => sub { 30 });

sub accept ($self, $formatter) {
    $formatter->format_quote($self);
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# lib/Document/DeliveryNote.pm
package Document::DeliveryNote;
use v5.36;
use Moo;
extends 'Document';

has shipped_date => (is => 'ro', required => 1);

sub accept ($self, $formatter) {
    $formatter->format_delivery_note($self);
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# lib/Document/PurchaseOrder.pm
package Document::PurchaseOrder;
use v5.36;
use Moo;
extends 'Document';

has order_date => (is => 'ro', required => 1);

sub accept ($self, $formatter) {
    $formatter->format_purchase_order($self);
}

ホワイトボードに近づいた。ロックさんが描いた矢印を指でなぞる。

「待ってください。最初の accept の呼び出しで、Perlのメソッドディスパッチが帳票の型を解決する。Invoiceなら Document::Invoice::accept が呼ばれる。これが1回目」

ロックさんがうなずいた。

Invoice::accept の中で $formatter->format_invoice($self) を呼ぶ。ここでPerlがフォーマッターの型を解決する。CsvFormatterなら Formatter::Csv::format_invoice が呼ばれる。これが2回目」

「その通り」

「二重ディスパッチ。1回目で『誰が』を決め、2回目で『何を』を決める」

「名前は後でいい。大事なのは構造だ。——さて、ここからが本題だよ、ワトソン君」

ロックさんが新しいコードを書いた。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# lib/DocumentFormatter.pm — Role
package DocumentFormatter;
use v5.36;
use Moo::Role;

requires qw(
    format_invoice
    format_quote
    format_delivery_note
    format_purchase_order
);

「この requires は——」

「これが今回の事件の鍵だよ、ワトソン君」

コードを見つめた。requires の行に4つのメソッド名が並んでいる。

「新しいフォーマッタークラスを作って with 'DocumentFormatter' と書いた瞬間、format_purchase_order を実装していなければ Moo がエラーを出す。クラスの構築時に——テストを走らせるまでもなく——追加漏れが発覚する。本番で空のCSVが出力されることは二度とない」

「あ、」

声が漏れた。

「——追加漏れが構造的に不可能になる」

「その通り」

ロックさんが具象フォーマッターのコードを書き始めた。

 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
37
38
39
40
41
42
43
44
45
# lib/Formatter/Csv.pm (After)
package Formatter::Csv;
use v5.36;
use Moo;
with 'DocumentFormatter';  # requires が enforce する

sub format_invoice ($self, $invoice) {
    my @lines = map {
        join(',', $_->{name}, $_->{quantity}, $_->{price})
    } $invoice->items->@*;
    return join("\n", 'name,quantity,price', @lines);
}

sub format_quote ($self, $quote) {
    my @lines = map {
        join(',', $_->{name}, $_->{quantity}, $_->{price})
    } $quote->items->@*;
    return join("\n",
        'name,quantity,price',
        @lines,
        "valid_days," . $quote->valid_days
    );
}

sub format_delivery_note ($self, $note) {
    my @lines = map {
        join(',', $_->{name}, $_->{quantity})
    } $note->items->@*;
    return join("\n",
        'name,quantity',
        @lines,
        "shipped_date," . $note->shipped_date
    );
}

sub format_purchase_order ($self, $order) {
    my @lines = map {
        join(',', $_->{name}, $_->{quantity}, $_->{price})
    } $order->items->@*;
    return join("\n",
        'name,quantity,price',
        @lines,
        "order_date," . $order->order_date
    );
}

Beforeのコードと見比べた。

if-elsif が消えた。完全に」

isa() の一文字も残っていない。型はもう尋問されない。自分で名乗り、相手がそれに応える。対等な会話だ」

ホワイトボードに新しいマトリクス表を描き始めた。4行×3列。各マスにメソッド名を書き込んでいく。format_invoice × Csvformat_invoice × Htmlformat_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)』を追加するとしたまえ」

ロックさんがコードを書いた。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# lib/Document/Receipt.pm(新規追加)
package Document::Receipt;
use v5.36;
use Moo;
extends 'Document';

has payment_date => (is => 'ro', required => 1);

sub accept ($self, $formatter) {
    $formatter->format_receipt($self);
}

「帳票クラスは追加した。次にRoleの requiresformat_receipt を追加する」

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
# lib/DocumentFormatter.pm(更新)
package DocumentFormatter;
use v5.36;
use Moo::Role;

requires qw(
    format_invoice
    format_quote
    format_delivery_note
    format_purchase_order
    format_receipt          # ← 追加
);

「さて、CsvFormatterに format_receipt をまだ実装していない状態で、テストを走らせてみよう」

ロックさんがターミナルでコマンドを叩いた。

1
Can't apply DocumentFormatter to Formatter::Csv - missing 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::Csvformat_receipt を追加し、テストを再実行した。ターミナルに緑の文字が流れた。

All tests successful.

「ビルドが通った。システムに秩序が戻った」

ラップトップを閉じた。帰り支度をする。

「ありがとうございました。一つ聞いてもいいですか」

「どうぞ」

「この構造で——帳票の種類が固定で、操作の方を増やしたい場合。バリデーター、プレビュー生成器、統計集計器。全部同じ accept の構造で追加できますか?」

ロックさんが少し笑った。椅子に深く腰掛ける。

「ワトソン君、それは次の事件だ。この手口を組織的に使い回す手法には名前がある。だが今日はDouble Dispatchだけ持ち帰りたまえ。部品の意味を知らずに建物を建てても、いい建物にはならない

次の事件、と言った。この人にとって、構造の問題はすべて「事件」なのだ。

「報酬の話をしていませんでした」

「いらない。今回は表を描く口実をもらった。それで十分だ」

軽く頭を下げて事務所を出た。

階段を降りながら考えた。4種類×3形式=12メソッド。if-elsif のときは3フォーマッターに4分岐ずつだったから、処理の数は同じだ。コード量はむしろ増えている。

でも——追加漏れは構造的に消える。「気づかない失敗」がなくなる。

それは、コード量では測れない価値だ。

帰りの電車でノートPCを開いた。CsvFormatterの format メソッドを開く。

1
if ($document->isa('Document::Invoice')) {

この行にカーソルを合わせた。

消した。

with 'DocumentFormatter'; と打った。

これで、次の帳票を追加するとき。私は何も忘れない。正確には——忘れることが許されなくなる。


探偵の調査報告書

容疑(アンチパターン)真実(パターン)証拠(効果)
isa() / ref() による型チェック連鎖(if-elsif 地獄)Double Dispatch(二重ディスパッチ)型の判定を外から排除し、追加漏れの検出をクラス構築時に前倒し
条件分岐の追加漏れ(サイレント失敗)Moo::Rolerequires による契約未実装メソッドがあればクラス構築が拒否される(ラウド失敗)
型の組み合わせ爆発を手動管理accept/visit の二段構え新しい型の追加時、全フォーマッターへの実装が構造的に強制される

推理のステップ

  1. 各フォーマッタークラスの format メソッド内にある isa() 連鎖を特定する
  2. Document 基底クラスに accept($formatter) メソッドを定義する
  3. 各帳票クラスの accept メソッドで、自分の型名を込めた $formatter->format_<type>($self) を呼ぶ
  4. DocumentFormatter Role で全ての format_<type> メソッドを requires で宣言する
  5. 各フォーマッタークラスで Role を適用し、型固有メソッドを実装する(isa() は消える)
  6. 新しい帳票型を追加し、requires によるクラス構築時エラーが発生することを確認する

ロックより

型は自分の名前を知っている。知っているのだから、本人に名乗らせればいい。

外から「お前は何者だ?」と尋問する構造は、尋問者の名簿が完全であることを前提にしている。名簿は手動更新だ。手動更新はいつか漏れる。漏れたとき、コードは何も壊れていないふりをする。これが最も危険な失敗だ。

二重ディスパッチは、問題を消すのではない。問題の性質を変えるのだ。「気づかない失敗」を「見逃せないエラー」に変える。

構造は万能ではない。だが、構造があれば失敗の到着地点を選べる。空のCSVが顧客に届くのと、ビルドが止まるのと——どちらの失敗を引き受けるか。その選択こそが設計だ。

comments powered by Disqus
Hugo で構築されています。
テーマ StackJimmy によって設計されています。