Featured image of post コードガーデナー【Observer】害虫を見つけるたび、知らせる相手をコードに書き足す〜検知は、誰に知らせるかまで背負わなくていい〜

コードガーデナー【Observer】害虫を見つけるたび、知らせる相手をコードに書き足す〜検知は、誰に知らせるかまで背負わなくていい〜

害虫を見つけるたび、通知先を検知コードに直接書き足してきた養蜂・果樹園経営者。庭師が教えるのは、検知する側が誰に知らせるかを知らなくていいObserverパターンだ。

訪れ ── スクーターに残る、燻煙器の匂い

祖父の代から続く桃とプラムの果樹園を継いで、もう十五年になる。六年前、敷地の隅で養蜂も始めた。花粉を運んでもらう代わりに蜜を分けてもらう、そんな関係のつもりだったが、今では蜜そのものも立派な収入の柱になっている。果樹と蜜蜂、両方を一人で見ているというと驚かれることが多いが、季節の巡りさえ覚えてしまえば、そう無理な組み合わせでもない。花が咲く時期に蜂が働き、実がなる時期に蜂は休む。うまくできているものだと、毎年この季節になると思う。

夏の午後は、害虫との追いかけっこの季節だ。桃の葉裏に着くアブラムシ、プラムの実をつつくカメムシ。放っておけば数日で被害が広がる。数年前からRustで簡易な検知記録アプリを書いていて、見回りのたびにトラップの様子を打ち込むと、決まった相手に知らせが飛ぶようになっている。コードは我流だが、動いてくれさえすればそれでいいと思ってやってきた。

今朝、地域の防除業者と新しく契約を結んだ。ここ数年、被害が広がる速度が年々早くなっている気がして、自分だけの見回りでは追いつかない年も出てきた。「検知したらすぐ連絡してほしい」と担当者に言われて、二つ返事で了承したものの、契約書にサインしながら、頭の片隅でもう自分のコードのことを考えていた。帰りの軽トラの中でふと思い出して、ハンドルを握る手が重くなった。また、あそこに一行足すのか。三回目だ。

養蜂箱の定期点検へ向かう道すがら、庭師さんの工房に寄ることにした。防護服の上着だけ脱いだラフな格好で、燻煙器で燻した独特の匂いがまだ服に染みついている。夏の日差しが容赦なく照りつけていて、スクーターのシートは触ると熱いくらいだった。エンジンを切ると、蝉の声が急に大きくなった。汗が首筋を伝う。

工房の入口には、いつもの古木がある。今日もいつも通り、特に気にも留めずに横を通り過ぎた。急いでいるわけでもないのに、この木の前で足を止めたことは一度もない。木陰だけが、この暑さの中で少し涼しく見えた気がしたが、それだけだった。

着くなり、庭師さんは短く目礼を返しただけで、あとはすぐに私のノートPCの画面へ視線を移した。開いていたのは、検知記録アプリのソースコードだ。日差しを避けて作業台の下に置いていたノートPCは、それでも少し熱を持っていた。

「防除業者を、新しく通知先に増やしたいだけなんですけど」

そう切り出しながら、画面をスクロールして見せた。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
struct PestAlert {
    insect: String,
    location: String,
}

fn notify_self(alert: &PestAlert) {
    println!("[記録] {}{} で確認", alert.insect, alert.location);
}

fn notify_nephew(alert: &PestAlert) {
    println!("[甥へ連絡] {}{} で発生", alert.insect, alert.location);
}

fn notify_neighbor_field(alert: &PestAlert) {
    println!("[隣の畑へ連絡] {}{} で発生", alert.insect, alert.location);
}

// 検知処理の最後に呼ばれる、通知の起点
fn report_pest(alert: &PestAlert) {
    notify_self(alert);
    notify_nephew(alert);
    notify_neighbor_field(alert);
    // 防除業者を増やしたいなら、ここへもう1行足すことになる
}

「また同じところを触ることになりそうで」

最初は自分への記録用に始めたアプリだった。見回りの結果を自分でメモするだけの、ごく単純なものだった。半年後、甥が養蜂箱の世話を手伝ってくれるようになって、甥にも知らせたくて一行足した。去年、隣の畑の方から「うちにも先に教えてほしい」と言われて、また一行足した。畑を隣り合わせにしていると、こういう頼まれごとは断りにくい。その都度、それでちゃんと間に合っていた——今回、四つ目を足そうとするまでは。

「関数、一個作って、最後の呼び出しに足せばいいだけですよね」

自分に言い聞かせるように、そう続けた。実際、これまでの二回はそうやって済ませてきた。今回もきっと同じだ、そう思っていた。

症状観察 ── 眉間に寄った、わずかな皺

庭師さんはコードをスクロールする指だけを動かし、report_pestの中身を順に目で追っていった。notify_selfnotify_nephewnotify_neighbor_field——三行の並びの上で、指がしばらく止まる。工房の中は日陰のおかげでいくらか涼しく、外の蝉の声だけが遠く聞こえていた。

「一応、ちゃんと動いてはいるんです」

念のため、そう付け加えた。庭師さんは何も言わない。ただ、その三行を見つめたまま、眉間にわずかな皺が寄った。私が知る限り、これまで見たことのない表情だった。作業台の上では、切り分けられたばかりの挿し穂が数本、水に浸けられていた。

「何か、まずいところありました?」

「この三人を、検知する側が知ってる」

それだけだった。もちろん知っている。自分でそう書いたのだから。当たり前のことを言われた気がして、でもその一言がなぜか頭から離れなかった。指がまだ、三行の上に置かれたままだった。続きを聞きたい気持ちを抑えて、ただ待った。

見立てと翻訳 ── 「知りたい人が、自分で手を挙げる」

庭師さんの視線が三行の並びで止まった瞬間、ヒバリさんが先に口を開いた。

「あ、たぶん師匠が見てるのは、この三行です。report_pestが、甥さんも隣の畑さんも、名前で直接呼び出してますよね」

視線の先を言い当てられて、少し驚いた。ヒバリさんは画面を指差しながら続ける。

「じゃあ、業者を増やすたびに、また似たような関数を一個ずつ足していけばいいってことですか」

まだ表面をなぞっただけの理解で、そう聞き返した。ヒバリさんが首を横に振る。

「近いですけど、それだとreport_pest自体は毎回書き換わり続けますよね。増える側と、増やされる側、両方が変わることになる」

言われて、初めて引っかかった。増える側、というのは新しい通知先のことだ。増やされる側、というのはreport_pestのことだ。両方が変わる——そう言われると、確かにおかしい気がしてきた。

「……待ってください。知らせてほしい人が増えるだけなら、検知する側は何も知らなくていいはずですよね。知りたい人が、自分から『聞いてます』って登録しておけばいい」

自分の口から出た言葉に、自分で少し驚いた。ヒバリさんが小さく頷く。

「まさにそれです」ヒバリさんが目を輝かせる。「Observerという定石には、そういう名前が付いています。知らせたい側——今回で言うとPestDetector——は、登録されている全員に呼びかけるだけでいい。誰が登録されているかを、具体的な名前で知る必要はありません」

「じゃあ、業者を四つ目として増やすときも、PestDetector側は何も変えなくていいってことですか」

「そうなります。増える側が『聞いてます』と登録するだけで、検知する側のコードは一切変わりません」

「最初は自分への記録だけだったんです。甥が手伝ってくれるようになって一行、隣の畑さんに言われてまた一行……。増えるたびそれで足りていたので、深く考えたことがなかったです」

経緯を話しながら、これまでの自分のコードが、増えるたびに少しずつ窮屈になっていたことに気づいた。三回目の今日になって、ようやくその窮屈さに名前が付いた気がした。

手入れ ── 名指しをやめて、窓口をひとつに

話が一区切りついたところで、庭師さんの指がようやくキーボードに触れた。まず、通知先ごとの処理を、独立した型として書き直していく。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
trait PestAlertListener {
    fn notify(&self, alert: &PestAlert);
}

struct SelfRecordListener;
impl PestAlertListener for SelfRecordListener {
    fn notify(&self, alert: &PestAlert) {
        println!("[記録] {}{} で確認", alert.insect, alert.location);
    }
}

struct NephewListener;
impl PestAlertListener for NephewListener {
    fn notify(&self, alert: &PestAlert) {
        println!("[甥へ連絡] {}{} で発生", alert.insect, alert.location);
    }
}

struct NeighborFieldListener;
impl PestAlertListener for NeighborFieldListener {
    fn notify(&self, alert: &PestAlert) {
        println!("[隣の畑へ連絡] {}{} で発生", alert.insect, alert.location);
    }
}

PestAlertListenerは、以前この記事でも触れたトレイト——複数の型に共通のふるまいを約束させる仕組み——だ。「知らせを受け取りたい」という一点さえ約束すれば、中身は自分の都合で自由に書ける。

「これ、notifyの中身って、前のnotify_selfとかと同じ文言ですよね」

「はい」ヒバリさんが頷く。「変えたのは、誰が誰を呼び出すか、という構造の方です。中の処理は一字一句変えていません」

言われてコードを見比べてみると、確かにprintln!の中身は一つも変わっていなかった。変わったのは、この処理がどこに書かれているか、という置き場所だけだった。

続けて、検知の起点そのものが書き直される。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
// 検知の起点。誰が聞いているかを、この構造体自身は知らない
struct PestDetector {
    listeners: Vec<Box<dyn PestAlertListener>>,
}

impl PestDetector {
    fn new() -> Self {
        PestDetector { listeners: Vec::new() }
    }

    fn register(&mut self, listener: Box<dyn PestAlertListener>) {
        self.listeners.push(listener);
    }

    fn report(&self, alert: &PestAlert) {
        for listener in &self.listeners {
            listener.notify(alert);
        }
    }
}

二つを並べて見比べると、形の違いは思っていたよりはっきりしていた。

Beforeはreport_pest()から3つの通知関数notify_self()・notify_nephew()・notify_neighbor_field()へ直接呼び出しの矢が伸びる名指しの列挙構造、Afterはreport()からlistenersという単一の箱への矢1本だけで、SelfRecordListener・NephewListener・NeighborFieldListener・PestControlServiceListenerの4つがそこに登録されている構造を示す上下対比図

Vec<Box<dyn PestAlertListener>>は、以前も出てきたトレイトオブジェクトの入れ物だ。PestDetectorlistenersという箱を持っているだけで、中に何が入っているか——誰が甥で、誰が隣の畑か——を一切知らない。reportがやっているのは「登録されている全員にnotifyを呼ぶ」、それだけだ。

「でも、registerを呼ぶところは、結局誰が誰かを知ってますよね。最初にアプリを起動するところで、SelfRecordListenerとかNephewListenerとか、名前を書いて登録するわけですし」

「そうです」ヒバリさんが頷く。「起動時に一箇所、配線をする場所は残ります。ただ、そこはreport_pestとは別の場所です。既存の3つがどう登録されているかを変えずに、新しい行を一つ足すだけで済みます」

1
2
3
4
let mut detector = PestDetector::new();
detector.register(Box::new(SelfRecordListener));
detector.register(Box::new(NephewListener));
detector.register(Box::new(NeighborFieldListener));

「検知のロジックそのもの——reportの中身——は、この配線が何行になっても一切変わりません。変わるのは、起動時にここへ一行足すかどうかだけです」

言われて、腑に落ちた。名前を知っている場所自体が消えたわけじゃない。ただ、その場所が「検知する処理」から「起動時に一度だけ組み立てる処理」へ切り離された、ということらしい。

言われてみれば、名前を知っている場所と知らない場所は、こんなふうに分かれているらしい。

左は起動時の配線(名前を知っている場所)で起動処理からSelfRecordListener・NephewListener・NeighborFieldListener・PestControlServiceListener(新規追加)へregisterの矢が4本伸びる。右は検知ロジック本体(名前を知らない場所)でPestDetector::report()からlistenersへforループで一律notifyの矢が1本だけ。両者は組み立て済みのdetectorを渡す、の矢でつながる左右対比図

「じゃあ、防除業者を増やしたいときは……」

言いかけたところで、自分で気づいた。新しい型を一つ書けばいい。

1
2
3
4
5
6
7
// 防除業者を追加したいとき。PestDetector本体には一切手を入れない
struct PestControlServiceListener;
impl PestAlertListener for PestControlServiceListener {
    fn notify(&self, alert: &PestAlert) {
        println!("[防除業者へ連絡] {}{} で発生", alert.insect, alert.location);
    }
}

自分でその場で書いてみて、detector.register(Box::new(PestControlServiceListener));と一行足した。

「これだけでいいんですか」

半信半疑で聞くと、庭師さんが短く頷いた。試しに動かしてみると、PestDetector本体のコードには一切触れないまま、四件目の通知先が動き出した。画面に流れる出力を、何度も見返した。[記録][甥へ連絡][隣の畑へ連絡]、そして[防除業者へ連絡]——四行が並んで表示されている。report_pestという関数の中身を書き換えていた頃には、この光景は想像もしていなかった。

「拍子抜けするくらい、あっさりですね」

思わずそう漏らすと、ヒバリさんが小さく笑った。

「増やす側の作業と、増やされる側の作業が、ちゃんと分かれたからです。今までは両方が一緒くたになってました」

「養蜂箱担当と隣の畑、どっちが先に知っても、業務上は困らないですよね」

ヒバリさんが付け加える。

「はい。reportはただ登録順に呼んでいくだけなので、順番はこの実装のたまたまの詳細です。誰かの順番を保証する仕組みではありません。もし順番そのものに意味を持たせたい場面が出てきたら、それはこのパターンの外側で考えることになります」

「一つだけ、気をつけておいてほしいことがあります」ヒバリさんが少し声のトーンを落とす。「もし登録した誰かのnotifyの中で予期しないエラー(panic)が起きると、Rustはその場で処理を巻き戻して呼び出し元まで伝えます。つまりreportのforループもそこで止まって、後ろに登録されている人まで知らせが届かなくなります」

「それ、今日直してもらえたりします?」

「今日はそこまでは触れません」庭師さんが答える。今日の手入れの範囲ではない、という顔だった。次の課題として、頭の片隅に置いておくことにした。

「つまり、増えるのは"知らせを受け取る人"だけで、“知らせる側"はずっと同じままでいいってことですね」

自分の言葉に置き換えて、そうつぶやいた。ようやく腑に落ちた感触があった。

見送り ── しゃがみ込む背中を、初めて見た

試しに何度か通知先を足したり減らしたりしてみた。検知の起点のコードは一度も触らずに済んだ。これなら、来年また誰かを増やしたいと言われても怖くない。防除業者だけでなく、そのうち収穫時期の応援に来てくれる季節労働者にも知らせたくなるかもしれない。そう考えても、もう気が重くならなかった。

「業者が増えても、また来月人が増えても、PestDetector側はもう触らなくていいんですよね」

自分の言葉で確かめると、ヒバリさんが頷いた。

礼を言って工房を出る。日はだいぶ傾いていたが、まだ暑さは残っていた。スクーターに跨る前に、ふと振り返った。見送りに出てきた庭師さんが、入口の古木の根元にしゃがみ込んでいる。何をするでもなく、指先で幹の同じ箇所に軽く触れていた。

これまで何度もこの工房を訪れているが、庭師さんがこの木に何かしているところを見たのは、今日が初めてだった。世話をしているようにも、確かめているようにも見える。何をしているのかはわからないが、迷いのない、日課のような手つきだった。声をかけようかとも思ったが、なんとなく、そのままにしておきたい気がした。

特に何も聞かずに、スクーターのエンジンをかけた。養蜂箱の点検にはまだ時間がある。走り出しながら、さっきの手つきのことを、少しだけ考えていた。誰に見せるためでもない所作というのは、案外そういうものなのだろう。傾きかけた日の中を、点検先へ向かって走った。


手入れ記録

こんな症状が出たら入れるべき手入れ(パターン)まだ様子見でいい
通知先が増えるたびに、検知・イベント発生元の関数を書き換えている✓ Observer(リスナー登録による疎結合)
検知する側が、通知先の具体的な名前や実装を直接呼び出している✓ Observer(リスナー登録による疎結合)
通知先が最初から固定で、今後も増減する見込みがない✓ 現状のまま様子見

手入れの手順

  1. 通知を受け取りたい処理を、共通のトレイト(例: notify(&self, alert: &Alert))を実装する型として独立させる
  2. 検知・イベント発生元の構造体に、リスナーをVec<Box<dyn Trait>>として保持させる
  3. 登録用のメソッド(register)を用意し、検知・イベント発生元の本体コードを変更せずにリスナーを追加できるようにする
  4. 通知処理(reportなど)は、登録されている全員へ機械的に呼びかけるだけにし、個別の名前を持ち出さない
  5. 新しい通知先を増やす変更が、発生元のコードに触れずに完結することをテストで確認する

庭師の一言

花は、蜂の名前を知らなくても実る。

見習いの手控え

検知する側が全員の名前を覚えていなくても、知らせを受け取りたい側が自分で手を挙げておけばいい——今回、その一点だけで検知コードがずいぶん身軽になりました。次に誰かが増えても、もうPestDetectorは触らなくて大丈夫です。

comments powered by Disqus
システム開発・AIワークフローのご相談は Meetsource
Hugo で構築されています。
テーマ StackJimmy によって設計されています。