電話が鳴ったのは、金曜の夕方だった。
「柴田さん。ライブセッションに出席していない社員に修了証が出ています。200名です」
メディカ製薬の研修管理者、中村さんの声は静かだった。怒鳴られたほうがまだよかった。静かな声のほうが、事の重大さが伝わる。
私は柴田。EdTechスタートアップ「LearnFlow」のバックエンドエンジニア、経験3年、27歳。LearnFlowは企業向けの研修LMS(学習管理システム)を提供していて、私はコンテンツ管理のバックエンドを担当している。
メディカ製薬は薬機法に基づくコンプライアンス研修をLearnFlowで運用していた。動画視聴、確認テスト、感想レポート、グループ討議——4種類のコンテンツをすべて修了した社員にだけ、修了証が自動発行される仕組みだ。先月、「ライブセッション」を追加したいという要望があり、私が実装を担当した。コンテンツクラスを1つ追加し、進捗計算、採点、レポート生成のコードに新しい分岐を書き足した。テストも通った。
1箇所だけ、書き忘れていた。
修了判定の関数。そこだけ、LiveSessionContent の分岐を追加していなかった。未知の型が来ると else 節でデフォルト修了扱いになるコードだった。製薬企業のコンプライアンス研修で偽の修了証——監査上の重大リスクだ。中村さんは契約解除をちらつかせている。
週末を挟んで、月曜の朝。社内のSlackで「バグの原因を特定してくれる変な探偵がいる」という噂を目にしたのは先週のことだ。普段なら相手にしないが、コード品質を見直したいという動機はあった。
検索で見つけたレガシー・コード・インベスティゲーション(LCI)の部屋では、複数のモニターにPDFが映し出されていた。よく見ると——修了証だ。異なるフォーマットの修了証が並んでいる。研修名、日付、受講者名、発行機関。レイアウトの違いを比較しているように見えた。
「修了証の研究ですか?」
モニターの前に座っていた男が、ゆっくりとこちらを向いた。
「フォーマットの研究だよ。どの発行機関の修了証も、構成要素は同じだ——受講者、研修、日付、判定。だが表現形式は全部違う。興味深いね。で、君の修了証は何人に出た?」
200名、と答えた。男は眉を上げた。
「200か。それで、何箇所の分岐がある?」
質問の意図が分からなかった。「分岐って、何の……」
「型チェックの分岐だよ。ref() を何箇所で使っている?」
話が早い。この男——モニターのベゼルに「Locke」のステッカーが貼ってある——は、修了証のバグの構造を見る前から、原因の形を知っているかのような口ぶりだった。
「コードを見せてもらえるかな、ワトソン君」
「柴田です」
「ワトソン君。コードを見せたまえ」
訂正は無駄だと分かった。200名の修了証のほうが、名前よりずっと重い。私はノートPCを開いた。
現場検証:四人の門番と偽の名簿
ロックは私のPCを引き寄せ、コードをスクロールし始めた。
しばらく沈黙が続いた。画面を追う目の動きだけが、この男が何かを読み取っていることを示していた。
「コンテンツのクラス定義を見ているんだね。5つか」
| |
「5つのクラスに問題はない。問題は操作のほうだ」
ロックは手を止めず、操作関数のコードまでスクロールした。そして人差し指でモニターを軽く叩き始めた。何かを数えている。
| |
「calc_progress に5つ。calc_grade にも5つ。generate_report_line にも5つ。check_completion に——」
ロックが画面を止めた。
「——4つ」
| |
「else で return 1。未知の型が来たら、無条件に修了扱いだ」
分かっている。と言いたかったが、声にならなかった。die にしておけば気づけたのに、else でデフォルト値を返してしまった。ほかの3つの操作には正しく追加したのに、1つだけ漏れた。そのためテストをすり抜けた。
ロックは椅子を少し引いて、モニターに映っていた修了証のPDFを指した。
「修了証には発行条件がある。すべてのコンテンツを修了していること。だが check_completion だけが LiveSessionContent を知らなかった。4人の門番が同じ来訪者リストを持っているのに、1人だけリストの更新を忘れている」
graph TD
subgraph "4つの操作関数"
F1["calc_progress<br/>ref() × 5種"]
F2["calc_grade<br/>ref() × 5種"]
F3["check_completion<br/>ref() × 4種 ★漏れ"]
F4["generate_report_line<br/>ref() × 5種"]
end
LS["LiveSessionContent<br/>(新規追加)"]
LS -->|"追加済み ✓"| F1
LS -->|"追加済み ✓"| F2
LS -.->|"追加漏れ ✗"| F3
LS -->|"追加済み ✓"| F4
style F3 fill:#f99,stroke:#333
style LS fill:#ff9,stroke:#333
「散在する型チェック——Scattered Type Checking。4つの関数に ref() による型分岐が散らばっていて、分岐条件は合計19個ある。新しいコンテンツ種別を追加するたびに、4つの操作関数すべてを漏れなく更新しなければならない。これが今回の事件の構造だ」
「構造は分かります。でも、ref() で分岐する以外に方法がありますか? Perlにはインターフェースの型チェックがないですし」
ロックはモニターの修了証PDFを閉じた。その下に、コードエディタの画面が現れた。
「尋問をやめればいい」
推理披露:訪問者の資格(Visitor)
「ref() は尋問だ。呼び出し側が相手に『お前は何者だ』と問い詰めている。逆にすればいい。相手に自分が何者かを名乗らせる」
ロックはエディタにコードを打ち始めた。
「解決策は3つの仕組みで構成される」
- Visitor ロール: すべてのコンテンツ種別に対する
visit_*メソッドをrequiresで強制する - accept メソッド: 各コンテンツクラスが「自分は何者か」を Visitor に名乗る
- 具象 Visitor: 操作ごとに1クラス。全コンテンツ種別への対応が保証される
【After】Visitor ロール(ContentVisitor)
| |
「ContentVisitor ロールは5つの requires を持つ。このロールを with したクラスが、1つでも visit_* を実装し忘れたらエラーになる。requires は Moo::Role の契約だ」
「契約——つまり、check_completion に分岐を書き忘れたような事故を防ぐ?」
「防ぐも何も、クラスのロード時にPerlが止まる。コードが動く前にだ」
【After】各コンテンツクラスに accept メソッドを追加
| |
「accept メソッドは1行だけ。VideoContent なら $visitor->visit_video($self) を呼ぶ。各クラスが自分の型を Visitor に通知している」
「待ってください」私は口を挟んだ。「結局、型で分岐していませんか? accept の中で visit_video を呼ぶか visit_quiz を呼ぶかは、クラスごとに決まっている。ref() でやっていたことと何が違うんですか?」
ロックは手を止めて、こちらを見た。
「いい疑問だ。違いはここにある——ref() の分岐は呼び出し側が型を判定する。操作関数の中に “お前は Video か? Quiz か?” という尋問が埋まっている。一方、accept はオブジェクト自身が型に応じた入口を選ぶ。呼び出し側は $content->accept($visitor) と書くだけで、どの visit_* が呼ばれるかを知らない」
「つまり……」
私は自分の事故を思い出しながら整理した。Before では check_completion の中に ref() の分岐を全種別分書く必要があった。4種類書いて、5種類目を忘れた。After では——
「After では、check_completion 相当の処理に ref() がない。代わりに各コンテンツが accept で適切な visit_* に処理を渡す。呼び出し側には型判定の分岐がない」
「それがダブルディスパッチだ。$content->accept($visitor) と呼ぶと、まず $content の型で accept が選ばれ、次に accept 内部で $visitor の型に応じた visit_* が呼ばれる。2段階の振り分けだ」
正直、一度聞いただけでは腑に落ちなかった。だが一つだけ確かなことがあった——操作関数の中から ref() が消えている。
【After】具象 Visitor(修了判定チェッカー)
| |
「CompletionChecker は with 'ContentVisitor' を宣言している。もし visit_live_session を書き忘れたら——」
「Moo::Role の requires でエラーになる」
「それで200人の事故は防げるんですか?」
核心を聞いた。ロックの顔から芝居がかった表情が消えた。
「沈黙するバグと叫ぶエラー、どちらが200人を守れる? Before の else { return 1 } は沈黙した。出席していない社員を黙って通した。After の requires は叫ぶ。visit_live_session を書き忘れたら、コードが動く前にPerlが止まる。200人に修了証が届く前に」
残りの Visitor クラスも見せてもらった。
| |
「4つの操作が、4つの Visitor クラスに整理された。Before では4つの関数にそれぞれ ref() 分岐が散在していた——」
graph LR
subgraph "ContentVisitor ロール"
R["requires visit_video<br/>requires visit_quiz<br/>requires visit_report<br/>requires visit_discussion<br/>requires visit_live_session"]
end
subgraph "具象 Visitor"
PC["ProgressCalculator"]
GC["GradeCalculator"]
CC["CompletionChecker"]
RG["ReportGenerator"]
end
subgraph "コンテンツ(accept)"
V["VideoContent"]
Q["QuizContent"]
RP["ReportContent"]
D["DiscussionContent"]
LS["LiveSessionContent"]
end
PC -->|"with"| R
GC -->|"with"| R
CC -->|"with"| R
RG -->|"with"| R
V -->|"accept"| PC
Q -->|"accept"| PC
LS -->|"accept"| CC
style R fill:#9f9,stroke:#333
style CC fill:#9f9,stroke:#333
style LS fill:#9f9,stroke:#333
「呼び出し側の ref() はどこにもない。対応漏れを検知する契約は ContentVisitor ロールに集まり、型ごとの振り分けは accept と visit_* に整理された」
「新しい操作を追加するときは?」
「Visitor クラスを1つ作るだけだ。コンテンツクラスには触れない」
| |
Before では grep { check_completion($_) } @contents で5件が返っていた——ライブセッション未参加なのに。After では正しく4件。
解決:正しき資格の証明
ロックがテストを実行した。
「Before 側は現状再現だ。壊れた挙動を固定して、何が起きていたかを先に白日の下にさらす」
| |
All tests successful. の文字を見て、初めて息をついた。
Before のテスト4——未参加のライブセッションが修了扱いになっている。テスト6——5件全部が修了。これが200名の偽修了証の正体だ。After のテスト5——未参加は未修了。テスト8——修了は4件。テスト11——新しい Visitor を追加しても、コンテンツクラスは変更不要。
ただ、テストが通ったことと、200人の修了証を取り消す仕事は別だ。構造改善はこれからの事故を防ぐ。過去の事故は私が始末をつけなければならない。
ロックが口を開いた。
「一つ、警告がある」
「Visitor パターンはコンテンツの種類が安定しているときに力を発揮する。新しい操作を追加するのは簡単だ——Visitor クラスを1つ作るだけだから。だが新しいコンテンツ種別を追加したらどうなる?」
「ContentVisitor ロールに requires を追加して、全 Visitor に新しい visit_* メソッドを……全部直すことになりますね。4つの Visitor すべてに」
「そう。今回はコンテンツ種別が5つで安定しているから Visitor が有効だ。だが種別が頻繁に増えるなら、各コンテンツに操作メソッドを持たせる Strategy 的な構成のほうが適切な場合もある。どちらの軸が変化しやすいか。操作が増えやすいか、構造が増えやすいか。その見極めが、Visitor を使うか使わないかの分水嶺だよ、ワトソン君」
私はLCIを出た。帰りの電車で、メディカ製薬への報告書の下書きを書き始めた。
「原因:修了判定関数における新規コンテンツ種別の型分岐追加漏れ。対策:Visitor パターンによる構造改善を実施。呼び出し側の型判定を排除し、requires による契約で実装漏れをクラスロード時に検出する仕組みを導入」
書いていて、手が止まった。技術的な対策は書ける。だが本質的な原因は「同じ更新を4つの操作関数に配っていた構造を放置していたこと」だ。die にしなかったことでも、テストが不足していたことでもない。同じ知識を複数箇所に散在させた時点で、漏れは時間の問題だった。
私は報告書の最後に、実装修正とは別の項目を追記した。新しいコンテンツ種別を追加するときは、操作の追加点を人間が数えるのではなく、構造側で不足を検知して止められる設計にすること。200名の修了証は取り消せる。次に同じ電話を受けないための書き換えは、ここからだ。
探偵の調査報告書
| 容疑(アンチパターン) | 真実(パターン) | 証拠(効果) |
|---|---|---|
Scattered Type Checking(散在する型チェック)。4つの操作関数に ref() による型分岐が散在し、分岐条件は合計19個。新コンテンツ種別「LiveSessionContent」の追加時に修了判定の1箇所で分岐追加が漏れ、未受講者200名に偽の修了証が発行された。 | Visitor パターン。操作ごとに Visitor クラスを作成し、各コンテンツクラスの accept メソッドによるダブルディスパッチで呼び出し側の型判定を解消する。ContentVisitor ロールの requires が全コンテンツ種別への対応を強制し、追加漏れを構造的に防止。 | 呼び出し側の ref() 分岐が消滅。新しい操作の追加は Visitor クラス1つの作成のみ。コンテンツクラスの変更は accept メソッド1行の追加のみ。requires により visit メソッドの実装漏れはクラスロード時にエラーとして検出。 |
推理のステップ
- 散在する型チェックを特定する:
ref()やblessed()による型分岐が複数の関数に繰り返し現れている箇所を洗い出す。4つの操作関数に分岐がまたがり、条件は合計19個に膨らんでいた。 - Visitor ロールを定義する: すべてのコンテンツ種別に対応する
visit_*メソッドをrequiresで宣言する。これにより、実装漏れがロード時にエラーとして検出される。 - 各コンテンツクラスに accept を追加する:
accept($visitor)メソッドを1行だけ追加し、自分の型に対応するvisit_*を呼び出す。これがダブルディスパッチの起点となる。 - 操作ごとに具象 Visitor を実装する:
ProgressCalculator、GradeCalculator、CompletionChecker、ReportGeneratorの4クラスを作成。各クラスはwith 'ContentVisitor'により全visit_*の実装を強制される。
ロックより
ワトソン君。ref() による型チェックは尋問だ。呼び出し側が「お前は誰だ」と問い詰める。尋問者が知らない顔が来たら——見逃す。それが200人の偽修了証だ。
Visitor は逆のアプローチを取る。オブジェクト自身に適切な入口を選ばせ、訪問者側には全種別への応答を契約として課す。名乗り方か応答のどちらかが欠けていれば、そこで止まる。止まるからこそ、気づける。
Visitor パターンの本質は「操作と構造の分離」だ。コンテンツという構造は変えず、操作だけを外から差し込む。新しい操作が必要になったら、新しい訪問者を招けばいい。構造に手を触れる必要はない。ただし——構造自体が変わるとき、新しい部屋が増えたときは、すべての訪問者にその部屋の訪問方法を教え直す必要がある。どちらの軸が変化しやすいか。その見極めが分水嶺だ。
