Featured image of post コードウィザード【Observer】知らせ先が増えるたびに在庫処理を開いて直す羽目に〜報せの糸を解いて結び直し、見張る側を一本足すだけで本体は無傷にする〜

コードウィザード【Observer】知らせ先が増えるたびに在庫処理を開いて直す羽目に〜報せの糸を解いて結び直し、見張る側を一本足すだけで本体は無傷にする〜

資材蔵のダグは、在庫を減らす詠唱に知らせ先を継ぎ足すたび、蔵の心臓部を開けていた。見習い魔術師ルークは自前の巨大な報せ盤(イベントバス)を組むが、宛名の綴り違いで報せが黙って消える壁に当たる。師匠の問いから、変化を発する側と受け取る側を約束で切り離す Observer へ。Kotlin で描く。

蔵の奥を、開けるのが怖い

私はダグ。錬金術ギルドの、資材蔵の番人だ。

蔵には、術師たちが使う触媒が並んでいる。火竜の鱗、銀砂、硫黄の結晶。それに、扱いの難しい揮発性の品——月光草の雫。棚の一つひとつに、残りいくつと、私は頭を入れている。在庫は、要るときに、要るだけ、間違いなく。それが番人の務めだ。

在庫が動けば、私は方々へ知らせる。まず、出納帳へ書き入れる。減って残りが乏しくなれば、仕入れ方へ早鐘を鳴らす。先月からは、店先に残量の札も掛けた。品を出したその場で、知らせ先が、一つ、また一つと増えていく。

知らせ先が増えるたび、私は蔵の「心臓部」を開ける。在庫を減らす、あの詠唱そのものに、知らせ先を一行ずつ、書き足していくのだ。

そのことで、私は先月、しくじった。

店先の残量札を継ぎ足すために、心臓部を開けた。その手で、ついでにと、早鐘の宛先を直そうとして——一字、書き損じた。それきり、月光草の雫が残りわずかになっても、早鐘は鳴らなかった。誰も気づかないまま、儀式の最中に、雫は底をついた。

あれ以来だ。蔵の奥を開けるのが、怖い。

そして、近いうちに、私はまた開けねばならない。揮発性の品が乏しくなったら防火番へ報せる——そういう新しい見張りを、足さねばならないからだ。あの心臓部に、また手を入れる。また、どこかを書き損じるかもしれない。指が、動かない。

丘のルーク

近ごろ、ギルドで一つの名を、よく耳にするようになっていた。

アカデミーの丘に、こんがらがった魔導式をほどくのが得意な見習いがいる、と。ルーク、という。半信半疑だった。見習いに、蔵の心臓部が診られるものか。だが、このまま指をこまねいているよりはましだ。私は蔵の写しを一枚携えて、重い足で丘を上った。

工房は、蔵とはまるで違う場所だった。壁は本で埋まり、宙には光の粒がちらちら浮いている。几帳面な私の目には、道具の置き場所が気になって仕方のない、雑然とした部屋だった。

その部屋の隅で、若い見習いが一人、妙なことを繰り返していた。小さな使い魔に、小石をひとつ、皿から皿へと運ばせている。運び終えると首をひねり、また小石を戻して、初めからやり直す。何度も、何度も。派手な魔法を振り回すでもなく、ただ、根気よく同じ手を確かめている。

私が軽く咳払いをすると、彼は使い魔を指に留まらせたまま、顔を上げた。

「——あ、すみません、根を詰めていて。……蔵の、ご相談ですか」

これが、噂のルークか。思っていたより、ずっと地味に手を動かす子だ。私は写しを机に広げ、蔵の心臓部を見せた。

「在庫を減らすと、ここで出納帳に書き、早鐘を鳴らす。知らせ先が増えるたび、この詠唱に、一行ずつ書き足してきた」

ルークは写しをのぞき込み、しばらく指でなぞってから、頷いた。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
// 蔵の心臓部:在庫を減らすと、受け手を名指しで直に呼ぶ
class Warehouse {
    private val stock = mutableMapOf<String, Int>()
    private val ledger = LedgerKeeper()   // 出納帳を直に知っている
    private val bell = ReorderBell()      // 早鐘を直に知っている

    fun reduceStock(item: String, amount: Int) {
        val old = stock.getOrDefault(item, 0)
        val new = (old - amount).coerceAtLeast(0)
        stock[item] = new
        ledger.record(item, old, new)     // ← 受け手を直に呼ぶ
        bell.ringIfLow(item, new)         // ← 受け手を直に呼ぶ
        // 店先の残量札を足すなら、ここにもう一行、直に書き足す
    }
}

ルークが一つずつ指して、教えてくれた。品目ごとの残りは、mutableMapOf という、品目を鍵にして数を引く帳面で覚えている。在庫を減らすと、まず古い残り old を引く。getOrDefault の後ろに添えた 0 は、「まだ帳面に無い品目なら、残りを零とみなす」という意味だそうだ。そこから引いた、新しい残り new を求める。coerceAtLeast(0) は、下限を零に留める印で、いくら引いても在庫が負にならないようにする仕掛けだ。

「問題は、その後です」とルークは、下の二行を指した。「残りを直したあと、蔵の詠唱そのものが、出納帳と早鐘を、名指しで直に呼んでいる。ledgerbell と、受け手の名を、詠唱が知ってしまっている」

彼は言った。「これは、密結合というやつです。蔵が、受け手の名を直に握っている。だから、受け手が増えたり減ったりするたび、蔵の詠唱そのものを開けて、書き換えなければならない」

蔵が受け手の名を直に握り、その増減が蔵の本体まで巻き込む——この状態を密結合と呼ぶのだと、ルークは教えてくれた。その名を、私は初めて知った。だが、身に覚えは、ありすぎるほどあった。

「そうだ。だから、開けるのが怖い。先月も、開けたついでに、宛先を書き損じた」

すべてを、一つの大盤に

ルークは、しばらく写しを眺めていた。それから、何か思いついた顔で、宙に大きな盤を一枚、描き出した。

「知らせ先が増えて絡まるなら——いっそ、すべての報せを、一つの大盤に集めましょう」

大盤、と私は聞き返した。彼は続けた。「中央に、報せの大盤を据えるんです。蔵は、在庫が動いたら、その大盤に『宛名』を付けて報せを放り込むだけ。受け手は、自分の宛名の報せを、大盤から拾う。蔵は、もう受け手の名を、直に知らなくていい」

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// 中央の大盤(ただ一つ):宛名(文字列)で報せを配る
object EventBus {
    private val handlers = mutableMapOf<String, MutableList<(Any) -> Unit>>()

    fun subscribe(topic: String, handler: (Any) -> Unit) {
        handlers.getOrPut(topic) { mutableListOf() }.add(handler)
    }
    fun post(topic: String, payload: Any) {
        handlers[topic]?.forEach { it(payload) }
        // 宛名が一致しなければ、黙って何もしない
    }
}

object と付いた大盤は、この世にただ一つだけ立つ盤なのだという。大盤は、宛名(文字列)を鍵にして、受け手の処理をまとめて抱えている。蔵はそこへ post、宛名を付けて報せ——品目と、前後の残りをまとめた三つ組——を放り込む。受け手は subscribe、宛名を言って報せを待つ。(getOrPut(Any) -> Unit といった、大盤の内側の細かな作りは、この後で捨てる案なので、追わなくていい。)

そのやり方だと、蔵の詠唱からは、確かに受け手の名が消えた。

1
2
3
4
5
6
fun reduceStock(item: String, amount: Int) {
    val old = stock.getOrDefault(item, 0)
    val new = (old - amount).coerceAtLeast(0)
    stock[item] = new
    EventBus.post("stock.changed", Triple(item, old, new))  // 宛名で放り込む
}

「ほら、これで蔵は、受け手を名指ししません。大盤に放り込むだけです」。ルークは、控えめに胸を張った。出納帳と早鐘の二つを繋いでみせると、なるほど、報せは中央にいったんまとまって見えた。

「お、真ん中に、集まった……ように、見えるな」

だが、私はすぐに引っかかった。番人の性だ。

「だが、ルーク。その大盤とやらに、『宛名』を書くのだろう。……私が先月、書き損じたのと、同じことに、ならんか」

ルークは、一度は自信で応じた。「大盤なら、宛名を一度、書いて登録するだけです。あちこちの詠唱に書き足していくより、ずっと安全ですよ」。私の懸念を、彼はまだ、軽く見ているようだった。

蔵の隅では、白髪の老人が一人、黙々と手を動かしていた。もつれた紐の束を、一本ずつほどいては、緩い結びで脇へ掛け直している。私は何度かその手元を目にしたが、縄でも繕う下働きだろうと、気にも留めなかった。

宛名が、揃わない

三つ目の見張り——あの防火番を、ルークが大盤に繋ごうとした。そこで、綻びが、次々に露わになった。

一つ目。防火番は "stock.changed" という宛名で報せを待った。だが、蔵の側が放り込む宛名は "stockChanged" になっていた。点の有る無し、たった一字。綴りが揃わず、防火番には、何も届かない。

1
2
3
// 蔵は "stockChanged"、防火番は "stock.changed" を待つ——綴りが揃わない
EventBus.post("stockChanged", Triple("月光草の雫", 50, 45))
EventBus.subscribe("stock.changed") { /* ……ここへは、来ない */ }

二つ目。宛名の札が増えるほど、どの受け手が、どの宛名で拾っているのか、大盤の上に散らばって、追えなくなる。中央に集めたはずが、中央そのものが、新しい絡まりになっていた。

三つ目。そして、いちばん質が悪い。宛名は、ただの文字だ。だから綴りを間違えても、魔導具は——ルークいわく、術を組み上げるときに間違いを咎めてくれる相棒のことだ——何も言わない。間違ったまま、術は組み上がってしまう。本番で、報せが黙って消えるまで、誰も気づけない。

ルークの手が、大盤の前で止まった。「まとめたはずなのに……どこへ繋がっているのか、見えない」

私は、はっとした。

「——それだ。先月、私がやった、しくじりと、同じだ。宛先を一字、書き損じて、早鐘が鳴らなかった。あの大盤も、書き損じを、黙って握り潰す」

ルークは、大盤から手を離した。指先で、散らばった宛名の札を、一枚ずつ辿りながら、低く呟いた。私に言うというより、自分に問うような声だった。

「……束ねるほど、ほどけなくなる。なぜだろう。一つにまとめれば、楽になるはずだったのに。集めたのは私なのに、どこへ繋がっているか、私が一番、分からなくなっている」

小石を運んでは、やり直していた、あの背中を思い出した。私の蔵の絡まりを、この子は今、自分の手で作り直して、同じ壁にぶつかっている。

紐を繕う人の、問い

そのときだった。隅の老人が、繕いかけの紐束を膝に置いて、大盤に屈み込むルークへ、静かに声をかけた。

ルークが、綯いかけの手元から顔を上げた。そして、ふっと肩の力を抜いて、「……お師匠さま」と、その人を呼んだ。気負いではない。どこか、安堵したような呼び方だった。

——師匠。私は、蔵の隅で縄でも繕う下働きかと、目もくれずにいた。その老人こそ、丘で名の知れた大魔術師マグナスさま、その人だったのだ。私は、ばつが悪くなって、口の中で小さく詫びた。

マグナスさまは、ルークの大盤には、目もくれなかった。ただ、手元の紐束を、二つ、持ち上げてみせた。片方は、真ん中で固く一つに、束ねて結わえてある。もう片方は、めいめいの紐が、緩く一本ずつ、掛けてある。

固いほうから一本を抜こうとすると、束ごと、ほどく羽目になった。緩いほうは、ひと引きで、その一本だけが、するりと外れた。

「——のう。なぜ、糸が多いほど、切りにくいのだろうね?」

それだけ言うと、マグナスさまは、また紐を一本、緩い結びに掛け直す手仕事へ、戻ってしまった。答えは、言わない。さっき私に密結合の名を教えてくれたルークのように、手取り足取り、とはいかないらしい。問いを一つ、置いて、あとは待つ。そういう人のようだった。

ルークの目が、その、緩い結びに、じっと留まった。

固く一つに結わえた束は、一本抜くのに、全部を巻き込む。緩く掛けた束は、いつでも、一本だけ、足せて、外せる。

ルークは、中央の大盤から、報せの糸を一本、そっと抜き取った。そして、蔵の札から、見張りの札へと、じかに、緩い結びで、結び直した。

「——大盤は、要らない」と、彼は言った。「蔵が、自分の見張りの控えを、じかに持てばいい。宛名で呼び出さなくても、控えに、順に声をかけるだけで、足りるんだ」

約束を決めて、控えを持たせる

ルークは、宙の大盤を、すうっと消した。あんなに大きかった盤が、跡形もなくなる。代わりに、彼は新しい仕組みを、書き始めた。

まず、受け手が守る「約束」を、一枚、決める。

1
2
3
4
// 受け手が守る「約束」:報せが来たら、こう受け取る
interface InventoryObserver {
    fun onStockChanged(item: String, old: Int, new: Int)
}

「これは InventoryObserver、報せを受け取る側の約束です」とルーク。「『在庫が変わったと報せが来たら、品目と、前の残りと、後の残りを受け取る』。その約束の形だけを、先に決めておく」

interface——中身ではなく、「報せが来たら、こう受け取る」という約束の型だけを取り決めた設計図なのだという。出納帳も、早鐘も、防火番も、みな、この一枚の約束を守るだけ。それ以外は、めいめい好きにしてよい。

次に、蔵そのものだ。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
// 蔵:見張りの控えを自分で持ち、変化のとき順に声をかける
class Warehouse {
    private val stock = mutableMapOf<String, Int>()
    private val observers = mutableListOf<InventoryObserver>()   // 見張りの控え

    fun setStock(item: String, amount: Int) { stock[item] = amount }
    fun attach(o: InventoryObserver) { observers.add(o) }        // 結ぶ
    fun detach(o: InventoryObserver) { observers.remove(o) }     // 解く

    fun reduceStock(item: String, amount: Int) {
        val old = stock.getOrDefault(item, 0)
        val new = (old - amount).coerceAtLeast(0)
        stock[item] = new
        notifyObservers(item, old, new)   // 受け手が「誰か」は、知らない
    }

    private fun notifyObservers(item: String, old: Int, new: Int) {
        observers.toList().forEach { it.onStockChanged(item, old, new) }
    }
}

蔵は、見張りの控えを一つ、自分で持つ。mutableListOf——後から一人ずつ、足したり外したりできる並びだ。attach で控えに結び、detach で控えから解く。そして在庫を減らしたら、notifyObservers で、控えに順に声をかける。

私は、先月の恐怖を思い出して、訊いた。

「新しい見張りを足すのに……また、蔵の心臓部を、開けるのか?」

「開けません」とルークは、はっきり答えた。「蔵の詠唱は、『控えに順に声をかける』としか書いていない。誰を控えに加えるかは、蔵の"外"で結びます。だから、足しても外しても、在庫を減らす詠唱そのものは、一文字も変わりません」

そこで私は、あの隅の老人の手元を、思い出した。

「……あの人が、紐を一本ずつ、緩い結びで、掛けたり外したりしていた。あれか。固く一つに結わえずに、めいめいを緩く。だから、いつでも一本、足せて、外せる」

「ええ」とルークは、少し嬉しそうに頷いた。この、変えるほう(蔵)が、受け手の名を知らず、ただ一枚の約束だけに頼る関係を、疎結合と呼ぶのだという。結び目が緩いから、受け手の増減が、蔵の本体を巻き込まない。

私の頭の中に、二つの光景が重なって見えた。私が今まで怯えながらやってきたことと、ルークが今示してくれたことの違いが、光の糸となって宙に浮かび上がる。

Observer pattern class diagram comparing tight coupling and observer-based loose coupling

なるほど。左側は、蔵の心臓部からいくつもの糸が伸びて、相手の首根っこを直接掴んでいる。糸を足すには、蔵の奥深くの結び目をほどかねばならない。右側は、蔵から伸びる糸は「約束」という中継の輪だけ。見張りたちは、その輪に自分の糸をゆるく引っ掛けているだけだ。

「“外"というのは、どこだ」と私は訊いた。心臓部でないなら、いったいどこで結ぶのか。

「蔵を建てて、見張りを繋ぐのは、ここ——アプリを立ち上げる入口、main のような、組み立ての場です」。ルークは、別の場所を指した。

1
2
3
4
// 組み立ての場:蔵を建てて、見張りを結ぶのはここ
val warehouse = Warehouse()
warehouse.attach(LedgerKeeper())               // 出納帳を結ぶ
warehouse.attach(ReorderBell(reorderPoint = 20))  // 早鐘を結ぶ

「見張りを結ぶのは、この組み立ての場。声をかけるのは、reduceStock の中。——繋ぐ場所と、声をかける場所が、別なんです。だから、繋ぎを増やしても、声をかける詠唱には、手が届かない」

繋ぐ場所と、声をかける場所が、別。心臓部を開けずに済む理由が、私にも、ようやく腑に落ちた。

その見張りたちは、こうだ。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// 見張りたち:みな同じ約束を守るだけ
class LedgerKeeper : InventoryObserver {            // 出納帳に書く
    val log = mutableListOf<String>()
    override fun onStockChanged(item: String, old: Int, new: Int) {
        log.add("$item: $old$new")
    }
}

class ReorderBell(private val reorderPoint: Int) : InventoryObserver {  // 残量が閾値以下で発注
    var rang = false
    override fun onStockChanged(item: String, old: Int, new: Int) {
        if (new <= reorderPoint) rang = true
    }
}

: InventoryObserver は、「この約束を守ります」という宣言。override fun onStockChanged は、その約束の受け口を、実際に用意する印だそうだ。出納帳は、来た報せを書き留める。早鐘は、後の残りが決めた線を下回ったら、鳴る。やることはめいめい違うのに、受け口の名前は、どちらも onStockChanged で、そろっている。

そこで、私は思い当たった。さっきの密結合の蔵では、受け手を recordringIfLow と、めいめい違う名で呼んでいた。名が違うから、蔵は一人ずつ、名指しするしかなかったのだ。だが今度は、約束で、全員の受け口を onStockChanged に、そろえた。だから蔵は、控えを順にたどって、同じ一手で全員に声をかけるだけでいい——誰が控えにいるのかを、知らないままで。

私は、もう一つ、確かめたくなった。

「その約束の型とやらは、大盤の『宛名』と、何が違う。あれも、蔵から受け手を、切り離してはいた」

「大盤は、受け手を"宛名”——ただの文字で、探していました」とルーク。「文字は、綴りを間違えても、魔導具が咎めない。だから、黙って届かない。でも今度は、“約束”——型を守る者を、じかに控えに繋ぎます。宛名で探す手順そのものが、無くなるんです。だから、綴りを合わせる相手も、もういない。書き損じも、探し損ないも、起こりようがない。おまけに、型が違う者は、繋ぐより前に、魔導具が撥ねてくれます」

先月の私を握り潰した、あの「黙って届かない」が、型の一枚で、起きなくなる。

「では、大盤は、間違いだったのか」と私は訊いた。

「いえ」とルークは、正直に首を振った。「大盤が、悪いのではありません。報せ先が何百と、街じゅうに散らばるような大掛かりな仕組みなら、大盤も立派な定石です。ただ、この蔵ひとつには——大きすぎた。要る分だけで、足りたんです」

要る分だけ。私は、その言葉を、胸に留めた。

なお、声をかける前に、蔵は控えの「写し」を一枚取っている(observers.toList())。報せの最中に、見張りが一人、糸を解くことがあっても、写しをたどれば、数える列が途中で崩れない、という用心だそうだ。

空の蔵で、試す

「本物の蔵に触れる前に、ここで確かめましょう」

ルークは、机の上に、小さな結界を張った。本物の蔵はそのままに、結界の中へ、空の蔵をひとつ立てる。そこに「聞き役の写し」を繋いで、在庫を動かし、報せが狙いどおり巡るかを検める。この本番前の試しを、魔術師は模擬詠唱と呼ぶそうだ。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
import kotlin.test.assertEquals
import kotlin.test.assertTrue

// 聞き役の写し:何を聞いたか、覚えておく耳
// Triple<品目, 前の残, 後の残> で、一度の報せを記録する
class SpyObserver : InventoryObserver {
    val heard = mutableListOf<Triple<String, Int, Int>>()
    override fun onStockChanged(item: String, old: Int, new: Int) {
        heard.add(Triple(item, old, new))
    }
}

聞き役の写し SpyObserver も、あの一枚の約束を守る、ただの見張りだ。本物の受け手(出納帳や早鐘)の代わりに置く、聞いた報せだけを書き留める身代わり、と思えばいい。だから、これ一つ繋げば、蔵が何を報せたかを、そのまま確かめられる。品目と前の残りと後の残り、三つ組の Triple で、聞いた報せを覚えておく。(こうした検証用の身代わりを、本来は専用の道具——モックライブラリ——で作ることもあるが、約束が一枚あれば、その道具は要らない。)

この後の試しは、一つずつ run { } という囲いで包んでいく。毎回、まっさらな蔵で確かめて、前の試しの結果を持ち越さないためだ。

 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
46
47
48
49
50
51
52
53
54
55
56
57
58
fun main() {
    // ①通知が届く:一つ繋いで動かせば、一度、届く
    run {
        val wh = Warehouse()
        wh.setStock("火竜の鱗", 100)
        val spy = SpyObserver()
        wh.attach(spy)
        wh.reduceStock("火竜の鱗", 10)
        assertEquals(1, spy.heard.size, "①: 見張りに一度届く")
        assertEquals(Triple("火竜の鱗", 100, 90), spy.heard[0], "①: 前の残100・後の残90")
    }

    // ②見張りゼロで例外なし:一人も繋がずに動かしても、蔵は無事
    run {
        val wh = Warehouse()
        wh.setStock("月光草の雫", 50)
        wh.reduceStock("月光草の雫", 5)   // 例外が出なければパス
    }

    // ③解いた後は、届かない
    run {
        val wh = Warehouse()
        wh.setStock("銀砂", 30)
        val spy = SpyObserver()
        wh.attach(spy)
        wh.detach(spy)
        wh.reduceStock("銀砂", 10)
        assertTrue(spy.heard.isEmpty(), "③: 糸を解いた後、その耳には届かない")
    }

    // ④複数の見張りが、各自に届く
    run {
        val wh = Warehouse()
        wh.setStock("硫黄の結晶", 80)
        val spy1 = SpyObserver()
        val spy2 = SpyObserver()
        wh.attach(spy1)
        wh.attach(spy2)
        wh.reduceStock("硫黄の結晶", 20)
        assertEquals(1, spy1.heard.size, "④: 見張り1にも届く")
        assertEquals(1, spy2.heard.size, "④: 見張り2にも届く")
    }

    // ⑤残り1を使い切る境目(1→0)と、それ以上は負にならない
    run {
        val wh = Warehouse()
        wh.setStock("月光草の雫", 1)
        val spy = SpyObserver()
        wh.attach(spy)
        wh.reduceStock("月光草の雫", 1)
        assertEquals(Triple("月光草の雫", 1, 0), spy.heard[0], "⑤: 残1→0で境目の報せ")
        wh.reduceStock("月光草の雫", 5)   // さらに減らしても
        assertEquals(2, spy.heard.size, "⑤: 報せは届く")
        assertEquals(0, spy.heard[1].third, "⑤: 負にならず0のまま")
    }

    println("ALL TESTS PASSED")
}

assertEquals は、聞き役が覚えた報せが、動かした通りの数か——前の残りと、後の残りを、突き合わせて見る道具だという。assertTrue ... isEmpty のほうは、糸を解いた後、その耳に、何も届いていないかを確かめる。

私の目の前で、一つずつ、灯がともった。

見張りを一つ繋げば、一度動かして、一度届く。二つ繋げば、二つとも、各自に届く。糸を解けば、その耳には、もう届かない。そして——一人も繋がずに動かしても、何も起きず、蔵は無事だった。先月、報せの繋ぎをいじって蔵を壊しかけた私には、この「無事」が、ありがたかった。

最後の一つ。残り一つを使い切る、あの境目だ。残り一を、一減らせば、ちゃんと「一から零になった」と報される。さらに減らそうとしても、coerceAtLeast(0) が下限を守り、残りは零のまま。負にはならない。

結界の中に、「ALL TESTS PASSED」——みな、狙いどおり——と、灯がともった。

心臓部は、もう開けなくていい

整え直された、蔵の仕組みを、私は受け取った。

中央にあった、あの大盤は、消えていた。残ったのは、蔵と、蔵が自分で持つ、小さな控え。緩く掛けた報せの糸が、数本だけ。

「これだけ、か。心臓部は、もう……開けなくて、いいのか」

先月、宛先を書き損じて、月光草の雫を切らした夜のことが、遠くなっていく。知らせ先を足すたび、蔵の奥へ手を入れて、また何かを壊すのではないかと、ずっと、指が震えていた。それが、要らない。

「知らせ先を足すたび、奥を開ける」。私は、長いこと、それが番人の宿命だと思っていた。継ぎ足すたびに、怖い思いをするのが、当たり前だと。だが、そうではなかったのだ。継ぎ足すのが悪いのではない。継ぎ足し方が、違っただけだった。

糸を、一本、掛けるだけ

私は、自分の手で、試したくなった。

あの、防火番だ。揮発性の月光草の雫が、残りわずかになったら報せる——ずっと足せずにいた、あの見張り。私は、ルークに教わったとおり、防火番を一つ書いて、蔵の控えに、一本、緩く掛けた。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// 防火番:揮発性の品が、残りわずかになったら報せる
class FireWard(private val threshold: Int) : InventoryObserver {
    val alerts = mutableListOf<String>()
    override fun onStockChanged(item: String, old: Int, new: Int) {
        if (new <= threshold) alerts.add(item)
    }
}

// 蔵の控えに、一本、結ぶだけ。心臓部は、開けない
warehouse.attach(FireWard(threshold = 5))

蔵の心臓部は、一文字も、開けていない。防火番も、あの一枚の約束を守るだけの、新しい見張り。控えに、緩く、結ぶだけ。それで、揮発性の品が乏しくなれば、防火番にも、ちゃんと報せが巡る。

先月、あれほど怖かった継ぎ足しが、糸を一本、掛けるだけで、済んだ。

「足すというのは……これだけ、だったのか」

ルークの仕組みが動いたあとも、隅のマグナスさまは、一度も顔を上げなかった。紐を緩い結びに掛け直す手を、止めもしない。答えは、とうとう言わずじまいだった。置いていったのは、あの問い一つと、緩い結び一本、それだけ。それでこの子は、自分で「大盤は要らない」まで、辿り着いた。私はもう、この子の"根を詰める"を、地味だとは思わなかった。

マグナスさまが、繕い終えた紐束を持ち上げ、緩い結びの一本を、ひと引きで外してみせて、ぽつりと言った。

「きつく締めた糸ほど、切るしかなくなる。ゆるく掛けた糸は、いつでも外せる。——束ねる強さより、解ける易しさだよ」

蔵へ帰った私は、さっそく防火番の糸を、控えに一本、緩く掛けた。心臓部は、開けていない。

ふと、気づいた。手が、震えていない。先月、蔵の奥を開けて宛先を書き損じた、あの夜の、指の震えが、もう、無い。

知らせ先が、これから増えても、奥はもう、開けなくていい。糸を、一本、掛けるだけだ。


📖 見習い魔術師ルークの魔導書メモ(Luke’s Spellbook Notes)

  • 修復した呪文(クラス/モジュール名): InventoryObserver(受け手の約束)、Warehouse(蔵=報せを発する側)、見張りの LedgerKeeper / ReorderBell(+あとから足した FireWard
  • 乱れの要因(アンチパターンの課題):
    • 在庫を減らす詠唱の本体が、受け手(出納帳・早鐘・店先札)を名指しで直に呼んでいた。受け手が増減するたびに、蔵の心臓部を開けて書き換える必要があった(密結合/変更の波及)。心臓部を開けるたびに書き損じの危険があり、宛先の一字違いが本番の事故を生んだ。
  • 過剰設計の反省(背伸びの過ち):
    • すべての報せを、中央の大盤(イベントバス)へ集約しようとした。宛名(文字列)で受け手を引く間接化は、受け手が増えるほど大盤を肥らせ、どの糸がどこへ繋がるか追えなくし、宛名の綴り違いを黙って握り潰した。文字列は魔導具(コンパイラ)が綴りを咎めないため、本番で報せが消えるまで気づけない。大盤は、報せ先が街じゅうに散らばる大規模なら定石だが、蔵ひとつには過剰だった(局所の要件に対する、過剰な一般化)。師匠の問いを手がかりに、自分でそう気づけた。
  • 魔術の定石(学んだ設計パターン):
    • Observer。状態の変化を発する側(Warehouse)と、受け取る側(見張り)を、共通の約束(interface)で切り離す。蔵は受け手が「誰か」を知らず、控え(MutableList)に順に声をかけるだけ。結ぶ(attach)・解く(detach)は蔵の"外"の組み立ての場で行い、在庫を減らす詠唱は変えない。宛名(文字列)でなく約束(型)で繋ぐため、綴りの書き損じ・探し損ないが起きない。繋ぐ場所と、声をかける場所を、別にしたのが肝。
  • 模擬詠唱の記録(検証のポイント):
    • 見張りに通知が届くこと、複数の見張りが各自に届くこと、見張りが一人もいなくても例外が出ないこと、糸を解いた後は届かないこと、残り1を使い切る境目(1→0)で正しく報され、それ以上は負にならないことを確認した。聞き役の写し(SpyObserver)も同じ約束を守るただの見張りなので、大掛かりな道具(モックライブラリ)を持ち出さずに検証できる。通知の最中に控えを変える場合に備え、toList() で写しを取ってから声をかけている。なお Kotlin には Delegates.observable という「変数に見張りを付ける委譲(by)」もあるが、これは同じ値を書き直しても発火するので、変化だけを拾いたいなら一手間要る。
  • 師匠マグナスの格言:
    • 「きつく締めた糸ほど、切るしかない。ゆるく掛けた糸は、いつでも外せる。」
comments powered by Disqus
Hugo で構築されています。
テーマ StackJimmy によって設計されています。