承認済みの顔をした、却下許可証
僕はリオ。探索者組合の受付窓口で、迷宮や危険区域への立入許可証の発行事務を担当している。組合に入って、まだ2年目だ。
窓口の壁には、申請の台帳がずらりと並んでいる。誰が、いつ、何の許可を求め、今どうなっているか。それを正確に記録するのが、僕の仕事だ。危険な場所へ人を通す紙切れである以上、間違いは、そのまま誰かの命に関わる。
台帳は、こう回っている。申請が来ると、まず一定の条件を満たしているかを機械的に確かめ、満たしていれば「仮承認」の印が自動でつく。効率を考えて、僕が組んだ仕組みだ。そのあと、審査担当が改めて人の目で見て、問題があれば却下する。
先週、そこで事故が起きた。
ある申請者は、仮承認の印がついたあと、審査担当が「素行に懸念あり」として却下していた。台帳の記録上は、確かに却下の印が立っていた。なのに、一覧表示は「承認済み」の顔をしたまま、棚に並び続けていたのだ。
危うく気づかず終わるところだった。同僚が、別に控えていた紙の記録でたまたま食い違いに気づき、その申請者が迷宮の門をくぐる寸前で、僕らは慌てて止めた。
原因を洗ったのは、僕自身だった。却下の処理を書いたコードに、一行、漏れがあった。
| |
isApproved を、元の false に戻す一行。それだけが、無い。だから、仮承認の印を持ったまま却下された許可証は、isApproved=true と isRejected=true を、両方持ったままになる。
そして、台帳の一覧を作る関数は、こうなっていた。
| |
isApproved を、真っ先に見る。だから、却下の印が立っていても、承認の印が残っていれば、表示は「承認済み」のまま。あの一行さえ、書いていれば。
台帳を見返すたび、指先が冷たくなる。あの一行を書いた自分と、それに気づかず何日も窓口に立っていた自分が、同じ人間だとは思いたくなかった。
組合長の命で、丘のルークへ
組合長には、当然、こっぴどく叱られた。「二度と、この手の事故を起こすな」。それだけでなく、組合長はこうも言った。
「アカデミーの丘に、こういう記録の乱れを片付ける見習いがいるらしい。相談してこい」
藁にもすがる思いというより、命じられて、というほうが正しい。近く、審査の手続きに「差し戻し(要修正)」——申請者に書き直しを求める、新しい中間状態を加えると決まったことも、僕の不安に拍車をかけていた。今の作りのまま条件が増えれば、また同じことが起きるのではないか。
台帳の写しを一枚抱えて、僕は重い足取りで丘を上った。
工房は、僕の窓口とはまるで違う場所だった。壁一面の本、宙にちらちら浮かぶ光の粒。几帳面な僕の目には、道具の置き場所も本の並びも、整理されているようには見えなかった。
その工房の隅で、若い見習いが一人、机に幾つもの小さな判子を並べ、順番を変えては押す練習を、根気よく繰り返していた。
「……この判は、この判のあとでなければ、押せない」
そう独りごちながら、また判を持ち替えている。僕が声をかけると、判子を置いたまま顔を上げた。
「あ……台帳の、ご相談ですか」
これが、噂のルークか。派手な魔法を振り回す様子は、どこにもない。
僕は台帳の写しを机に広げ、事の経緯を語った。仮承認、審査、却下、そして——却下したはずなのに、承認済みの顔をしたまま棚に並んでいた許可証のこと。ルークは写しを覗き込み、isApproved と isRejected が両方 true になっている行を、しばらく黙って見比べていた。
「これは……許可証の"姿"を、3つの別々の印(Boolean——真か偽かの2択を持つ型)で表していますね。印はそれぞれ独立しているから、“承認済み"と"却下"の印が、両方立ってしまうことがある」
ルークは、その状態に名前をつけた。
「本来存在してはいけないはずの組み合わせが、型の上では作れてしまう——これを、不正状態と呼びます」
不正状態。ルークはそう名付けたが、僕にとっては、ただの専門用語では済まなかった。あの一行の書き忘れが、まさにそれを生んだのだから。
「そうです」と僕は答えた。「却下の処理を書いたとき、承認の印を戻す一行を、僕が書き忘れました」
あり得る組み合わせを、一つ残らず
ルークは写しをしばらく見つめていた。それから、何か思いついた顔になる。
「なるほど……なら、あり得る組み合わせを、一つ残らず、先に潰しておきましょう」
誇示というより、僕の負い目に応えたい一心の、前のめりな早口だった。ルークはその場で、判定関数を書き直し始める。
| |
「見てください。あり得ない組み合わせには、それぞれ"矛盾"という名前をつけて、先に弾いておきました。これで、どんな組み合わせが来ても、判定に困りません」
独立した印が3つあると、真偽の組み合わせは 2×2×2 で8通りになる。うち意味があるのは4通りだけで、残り4通りが"矛盾"だとルークは言った。分岐の数がぴったり8個にならないのは、上から順に確かめていく判定なので、幾つかの矛盾がまとめて弾かれるからだ。
一度は、僕も感心した。「……すごい。もう、漏れは無さそうですね」
それでも、引っかかりが残った。
「でも……“矛盾"の組み合わせが、そもそも台帳に書き込まれてしまう、という点は、直っていない気がします」
ルークは、一度、自信をもって応じた。「表示さえ正しく弾ければ、実害はありません。これで、判定のほうは万全です」
僕の疑問は、たぶん的を射ていた。でもルークはまだ気づいていないようで、そのまま先へ進んでしまう。
工房の隅では、白髪の老人が一人、小さな仕掛け箱の蓋を、かちり、かちりと開け閉めしていた。誰なのかは訊かなかった。工房に出入りする客か、あるいは道具番の年寄りか——僕には、その程度の興味しか引かなかった。ルークが判定関数に手を入れるたび、その音だけが、一つ、また一つと続いていた。
潰したはずの穴が、また開く
そこへ、組合長からの正式な知らせが届く。「差し戻し(要修正)」の手続きを、正式に加えることになった、と。
僕がそれをルークに伝えると、彼は判定関数に4つ目の印——isRevisionRequested——を書き足し始めた。3つの印で8通りだった組み合わせが、4つになった瞬間、一気に16通りに膨れる。ルークは急いで、差し戻しの手続きを書いた。
| |
動かして確かめた僕は、目を疑った。差し戻しにしたはずの許可証が、まだ「承認済み」の印を残したまま、一覧に出ていたのだ。
ルークが requestRevision を見直し、顔を強張らせる。
「……isApproved を戻す一行を、また、書き忘れていました」
ルークの詫びを、僕は黙って聞いていた。だが「戻す一行」という言葉だけが、耳の奥に引っかかって離れない。自分が却下処理に書いたのと、寸分違わぬ欠落だったからだ。
「……僕がやったのと、同じ場所です。あのときも、承認の印を、戻す一行が抜けていました」
感情が重なった、というより、まったく同じ種類の書き忘れが、あれだけ防御を積んだはずのコードでも、また起きた——その事実が、二人の間にひやりと横たわった。
ルークは判定関数から手を離し、並べた分岐を、一つずつ目で追っていた。
「……潰したつもりだったのに。組み合わせを、いくら先回りして潰しても、印を戻す一行を、また忘れる。なぜ、同じ場所で、同じように、つまずくんだろう」
机に並んでいた判子の列を、ルークがそっと崩した。残ったのは、一枚だけ。僕の書き忘れと、寸分違わぬ書き忘れを、この人も今、自分の手でやらかしたところだった。
仕掛け箱の、開く蓋はいつも一つ
ルークが、隅の老人にふと目をやった。それから、自分から歩み寄っていく。
「お師匠さま、また悪戯を」
苦笑まじりの声だった。僕は、その老人が名の知れた大魔術師マグナスさまだと、ルークの呼びかけで初めて知った。
マグナスさまは、いじっていた仕掛け箱を、そのまま差し出した。蓋がいくつか付いていて、今開いている蓋の位置によって、次に開けられる蓋が一つに決まる作りらしい。無理に別の蓋を開けようとしても、からくりがそれを許さなかった。
「——のう。この箱は、今開いている場所からしか、次の場所へ動けないだろう。なぜ、確かめなくても、そう作れるんだろうね?」
それだけ言うと、マグナスさまは、また蓋を一つ、静かに閉じる遊びへ戻ってしまった。答えは言わない。
ルークの目が、仕掛け箱の"今の蓋の位置が、次を決める"作りに、じっと留まっていた。
ルークが、机に残っていた最後の一枚の判子を、指先で軽く押さえる。
「……印を、増やすんじゃない。姿を、型で決め直すんです」
ルークはそう言うと、机の上の余白に素早く図を描き殴った。
「見てください。今までのやり方は、許可証という一つの板に、別々の印をいくつもペタペタと押していたようなものです」

「これだと、“承認"と"却下"の印が、両方とも押されたままになる事故が物理的に防げません。僕らは、矛盾した印を慌てて消す処理ばかりに気を取られていました。そうではなく——」
ルークは、新しく描いた図の右側を指し示した。
「許可証の"姿"そのものを、型という頑丈な箱で定義し直すんです。下書き、申請中、差し戻し中、承認済み、却下。これらはすべて、独立した印ではなく、許可証が取り得る『ただ一つの姿』です。これなら、承認済みでありながら却下されている、なんていう矛盾した姿そのものが、この世に存在できなくなります」
許可証の"姿"を、型で決め直す
ルークは、僕の写しの上に、新しい設計を書き始めた。
| |
「これは sealed class——取りうる"姿"を、あらかじめ全部登録した型です。Draft(下書き)、Submitted(申請中)、RevisionRequested(差し戻し中)、Approved(承認済み)、Rejected(却下)。登録した姿以外は、後から勝手に作れません」
Draft にだけ data object という書き方がついているのが気になって、僕は訊いた。
「これは何が違うんですか」
「data object は、データを持たない、ただ一つだけの姿を表す書き方です。下書きには、特に持たせるデータが無いので。他の姿は data class——状態ごとに固有のデータを持たせられる書き方にしています」
「でも……」と僕は、いちばん気になっていたことを訊いた。「1つのインスタンスが、“承認済み"と"却下"の両方を、なぜ同時に持てなくなるんですか」
「Approved という型は approvedBy(承認者名)だけを持ちます。Rejected という型は reason(却下理由)だけを持ちます。同じ器が、両方のデータを同時に持つ書き方自体が、もう存在しないんです。以前は"戻し忘れる印"があったから事故が起きた。今は、戻し忘れる対象の場所自体が、コードのどこにもありません」
「台帳の Permit は、どうなるんですか。あの3つの印は、まだ残るんですか」
「無くなります」とルークは言った。「Permit は、isSubmitted や isApproved の代わりに、state: PermitState という一つのフィールドだけを持てばよくなります。表示も、その一つの state を when で見るだけです。それと、これまでの approve や reject は、印を直接書き換えていましたよね。今度は違います。今の姿を受け取って、次の姿を新しく返すだけ——元の Permit を書き換えるのではなく、val newState = approve(permit.state, ...) のように、返ってきた新しい姿を、台帳に上書きする形になります」
続けて、ルークは状態を移す関数を書いていく。
| |
「それぞれの手続きは、when で『今の姿から許される、次の姿だけ』を書きます。is——渡された値が、どの"姿"かを確かめる言葉です。else は、書きません」
「is を、カンマで並べている所がありますね」と僕は、submit の下のほうを指した。
「ああ、それは『どれか一つに当てはまれば、同じ処理でいい』という意味です。Submitted・Approved・Rejected のどれから申請しようとしても、同じエラーで弾いて構わないので、まとめて書いています」
「else で残りを全部まとめてしまえば、楽ではないですか」と僕は訊いた。
「else を書くと、“この姿は全部扱いました"という確認を、魔導具——コンパイラのことです——がしてくれなくなります。新しい姿を後で1つ足したとき、else があると、そこへ紛れ込んで、扱い忘れに誰も気づけません。else を書かないから、姿が増えた瞬間、扱っていない場所すべてで、魔導具が『まだ決めていません』と教えてくれるんです」
これが、when の網羅性チェック——sealed class を相手にした when で、姿の扱い漏れがあるとコンパイルが通らなくなる仕組みだと、ルークは付け加えた。不正な遷移を試みると、error(...)——理由を添えてその場で処理を止める、Kotlin の短い書き方——で弾かれる。error(...) は、内側で IllegalStateException——「今の状態では、その操作はできない」という例外——を投げる。だから模擬詠唱では、assertFailsWith<IllegalStateException> で、ちゃんと弾かれたかを確かめる。
「前に別の見習いさんから、“アルゴリズムを差し替える"仕組みを聞いたことがあります。それとは違うんですか」
「似ていますが別物です。差し替えの仕組みは、どの状態でも同じ操作が使えます。今回は逆で、今の状態が、使える操作そのものを決めるんです。今の姿が、次に許される手続きを決める——これが、State という定石の骨格です。今回は、その骨格を Kotlin の型で固めた形になります」
State。今の状態が、次に許される操作そのものを決める——ルークが名付けたその定石は、僕の許可証台帳にも、そのまま当てはまった。
「本物の許可証台帳に触れる前に、紙の許可証を一枚だけ、机の上で動かしてみます」
ルークはそう言って、模擬詠唱——本番の前に、安全な形で試しに動かして確かめる作法——のコードを書いた。
| |
「assertEquals は、動かした後の姿が持つデータが、狙いどおりか——承認者名や却下理由が一致しているかを確かめます。assertFailsWith は、許されない手続きを試したとき、ちゃんと止まってくれるかを確かめます」
一つずつ、ルークが読み上げてくれた。下書きから申請すれば、申請中の姿になり、申請者名が残る。承認すれば承認済みの姿になり、承認者名が残る。却下すれば却下の姿になり、理由が残る。差し戻しにしても、書き直して再申請すれば、また申請中の姿に戻れる。そして——下書きのまま承認しようとすると、ちゃんと止まる。
結界の中に、「ALL TESTS PASSED」の灯がともった。
戻し忘れる場所が、もう無い
書き直された許可証の仕組みを、僕は受け取った。3つ、4つあった独立した印は消え、残ったのは、一つずつの"姿"と、その姿だけが持つデータだった。
「……戻し忘れる場所が、もう無いんですね」
先週の事故が、少しだけ、遠くなっていく気がした。
「前は、印を一つ戻す一行を、僕が忘れました。今度は、忘れる場所自体が、コードの中に存在しない」
あの門の手前で立ち止まってもらった申請者のことを、僕はまだ覚えている。
「起きてから、直すんじゃない……起こせない形に、しておけばよかったんです」
新しい姿を足しても、魔導具が教えてくれる
「もう一つ、確かめたいことがあります」
僕は、ずっと引っかかっていたことを切り出した。「許可証には、審査が長引くと"失効"する仕組みも要る、と聞いています。もし今、Expired という姿を1つ足したら、どうなりますか」
ルークが sealed class に、data object Expired を一行、書き足す。
その瞬間、机に広げていた submit・approve・reject・requestRevision——すべての when に、赤い印が並んだ。『'when' expression must be exhaustive'——姿の扱い漏れがある』というコンパイルエラーだ。
「ほら。新しい姿を足しただけで、“まだ決めていない場所"を、魔導具が全部、先に教えてくれます」
僕は、その赤い印の並びに、安堵に近いものを覚えた。
「……起きてから見つけるんじゃない。作った瞬間に、教えてもらえるんですね」
隅の仕掛け箱は、もう蓋を閉じて静まっていた。マグナスさまの手には、次の遊び道具——今度は小さな鍵束——が握られている。ルークに向かって何か言うでもなく、ただ鍵を一つ、鍵穴に合わせては外す仕草を、繰り返していた。ヒントはあの箱一つと、一つの問いだけ。答えを聞いた覚えは、ついぞ無い。
マグナスさまは、仕掛け箱の蓋を一つ、かちりと閉じてみせて、ぽつりと言った。
「一度に開く蓋は、いつも一つだ。今開いている蓋が、次に開く蓋を決めておる」
窓口へ戻った僕は、まず Expired の一件を、実際の台帳に足した。赤いコンパイルエラーが並んだ場所を、一つずつ埋めていく。埋め終えたとき、ふと気づいた——もう、あの事故を思い出して手が止まることが、無い。
印を増やすのではなく、姿を型で決め直す。それだけで、忘れる場所そのものが、消えていた。
📖 見習い魔術師ルークの魔導書メモ(Luke’s Spellbook Notes)
- 修復した呪文(クラス/モジュール名):
PermitState(許可証の姿の sealed class)、submit/approve/reject/requestRevision(遷移関数) - 乱れの要因(アンチパターンの課題):
- 許可証の状態を、3つの独立した
Boolean(isSubmitted/isApproved/isRejected)で管理していた。フラグ同士に依存関係が無いため、「承認済みかつ却下」のような不正な組み合わせが型の上で作れてしまい、reject()の一行漏れ(isApprovedを戻し忘れる)で実際に発生した。判定関数が印を見る順序次第で、事故が起きても表示上は隠れてしまった。
- 許可証の状態を、3つの独立した
- 過剰設計の反省(背伸びの過ち):
- あり得る組み合わせを、巨大な判定関数で一つ残らず先回りして潰そうとした。だがこれは症状を検出して弾いているだけで、フィールドの矛盾そのものは直っていない。新しい手続き(差し戻し)が1つ増えただけで組み合わせが倍増し、同じ種類の書き忘れ(印を戻す一行の欠落)を、防御を積んだはずの2度目でも再発させてしまった。
- 魔術の定石(学んだ設計パターン):
- State+Kotlin の
sealed class。今の状態が、次に許される操作を決めるという State の骨格を、sealed classの型階層とwhenの網羅性チェックで固めた。各状態(Draft/Submitted/RevisionRequested/Approved/Rejected)は固有のデータだけを持ち、矛盾する組み合わせをそもそも構文として書けなくする。遷移関数はelseを書かず全サブタイプを列挙するため、新しい状態を1つ足すと、扱い漏れのある箇所すべてでコンパイルエラーが起き、書き忘れが実行前に見つかる。
- State+Kotlin の
- 模擬詠唱の記録(検証のポイント):
- 正常な遷移(申請・承認・却下・差し戻し→再申請)で固有データが正しく残ること、下書きから直接承認しようとする等の不正な遷移が
error()で弾かれることを確認した。
- 正常な遷移(申請・承認・却下・差し戻し→再申請)で固有データが正しく残ること、下書きから直接承認しようとする等の不正な遷移が
- ちなみに:
- 状態の種類が少なく、振る舞いの違いも単純なら、フルセットの State 化はかえって過剰なこともある。Kotlin には
sealed classのほかにsealed interfaceもあり、複数の sealed 階層を1つの型に組み合わせたいときに使う。
- 状態の種類が少なく、振る舞いの違いも単純なら、フルセットの State 化はかえって過剰なこともある。Kotlin には
- 師匠マグナスの格言:
- 「一度に開く蓋は、いつも一つだ。今開いている蓋が、次に開く蓋を決めておる。」
