Featured image of post コードシェフの仕込み帳【Visitor】検査員は厨房に入れない〜食材の定義を汚さずに新しい操作を巡回させる〜

コードシェフの仕込み帳【Visitor】検査員は厨房に入れない〜食材の定義を汚さずに新しい操作を巡回させる〜

新しい操作を追加するたびにすべての食材クラスを改修する「OCP違反」問題。VisitorパターンをPerlとMooで実装し、既存クラスを変更せずに操作を追加できる設計に直す。

しんしんと冷える冬の午後。 静まり返った「コード食堂」の厨房で、やかんがシュンシュンと温かい湯気を上げ、ストーブの薪が静かに爆ぜていた。

私は調理台に向かい、眉間にしわを寄せて作業に没頭していた。 まな板の上には、丸ごとの鶏肉や新鮮な魚、いくつかの野菜が並んでいる。その食材たちの身に、私は「カロリー測定用の針」や「アレルギー検査用の金属ピン」を、ブスブスと直接刺し込んでいた。

「おい。食材を穴だらけにして、何が楽しいんだ」

突然のシェフの声に、私は肩を跳ね上げて振り返った。 シェフは腕組みをして、あきれたような顔で立っていた。その横には、いつの間にか席に座って熱いほうじ茶を飲んでいる青年がいる。

「あ、すみません! 没頭していて、お客様に気づきませんでした……」 慌ててお辞儀をする私を見て、その青年——荒木さん(28歳)は、苦笑しながら湯呑みを置いた。 「いえ、構いませんよ。むしろ、その姿を見て、僕のシステムと同じだなと思って安心したくらいです」

荒木さんは、フィットネス・健康管理アプリ「マイフィット」の食事データ分析エンジンを開発するバックエンドエンジニアだ。几帳面そうな眼鏡の奥の目は、ひどく疲れているように見えた。

「僕のコードも、その食材たちと同じなんです。新しい測定項目が増えるたびに、針を突き刺すようにファイルを書き換えて、傷だらけにしているんですよ」

荒木さんが開いたラップトップの画面には、ぎっしりとメソッドが書き込まれた食材クラスのコード(Before)が表示されていた。

この記事で学ぶこと

この記事は、食材クラスに新しい操作(カロリー計算、アレルゲン判定など)を追加するたびに、すべての食材クラスに直接手を入れてコードを改修する「Open/Closed原則(OCP)違反」の問題を、Visitorパターンを使って解決する話です。操作を要素クラスから完全に分離し、二重ディスパッチ(Double Dispatch)を用いて、既存クラスを汚さずに操作を追加できる設計をPerlとMooで実装する方法を解説します。

学ぶことひとことで言うと
Visitor パターンオブジェクト構造の要素を変更せずに、新しい操作を後から追加できるようにするデザインパターン。
ダブルディスパッチ要素側とVisitor側の双方の実行時型に基づいて適切なメソッドを呼び出す仕組み。Perlでは、要素が accept を経由して自己紹介し、Visitorの固有メソッドを呼ぶことで実現する。
OCP(開放閉鎖原則)既存のソースコードを変更せず(閉じており)、新しい機能を追加できる(開いている)状態にする設計原則。
パターンの適用限界Visitorは「要素(食材)の種類がほぼ固定で、操作(計算・判定)が頻繁に追加される」非対称な状況で真価を発揮する。

対象読者は、次のような人を想定しています。

  • PerlとMooでのクラスやロールの基本的な扱い方が分かる
  • 新機能を追加するたびに、既存の多くのファイルをgrepして書き直す作業にうんざりしている
  • ダブルディスパッチ(二重ディスパッチ)の具体的な動きを学びたい

針を刺すたびにボロボロになる食材

荒木さんの見せてくれたBeforeコードは、まさに私がまな板の上でやっていた「食材を穴だらけにする作業」そのものだった。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# package Chicken (鶏肉)
package Chicken;
use Moo;
use v5.36;

sub calorie { 150 }
sub allergen { 'なし' }

# package Fish (魚)
package Fish;
use Moo;
use v5.36;

sub calorie { 100 }
sub allergen { '魚アレルギー' }

# package Vegetable (野菜)
package Vegetable;
use Moo;
use v5.36;

sub calorie { 30 }
sub allergen { 'なし' }

「最初は『カロリー計算(calorie)』だけだったんです」と荒木さんはため息をついた。 「でも、健康アプリなので『アレルゲン判定(allergen)』が必要になり、全食材クラスにメソッドを書き足しました。今度は『塩分濃度』や『価格計算』を追加しろと言われていて……。新しい項目が増えるたびに、数千もの全食材クラスファイルをgrepして、手作業でメソッドを書き足しているんです。もう二度とこのファイルを触りたくありません」

シェフがBeforeコードを指先でトントンと叩いた。 「食材そのものの定義に、検査のやり方や計算式をブスブスと直接書き込むから、定義自体が汚れる。検査員は厨房に入れないのが基本だ」

「検査員は厨房に入れない……?」 荒木さんが首を傾げる。

「そうだ」 シェフは調理台のまな板を指し示した。 「食材はただ、まな板の上に静かに置いておく。食材そのものに検査用の針を刺して傷つけるんじゃない。専門の『検査員(Visitor)』を厨房の外から呼び、食材に測定器を外からそっと当てるだけでいい。食材側に必要なのは、検査員が測定器を当てるための『スリット(窓口)』を1つだけ用意しておくことだ」

私は自分の手元にある、穴だらけになった鶏肉を見た。 「あ……。検査のたびに食材に針を直接刺し込んでいたら、食材がボロボロになってしまいます。でも、測定器を当てるだけの窓口があれば、食材は傷つかずに済みますね」


二重ディスパッチという仕込み

荒木さんが疑問を口にした。 「でもシェフ、それなら検査員(Visitor)をただ呼び出して、検査員に直接『この食材を検査してください』と丸投げすればいいのでは? なぜわざわざ食材側に窓口が必要なんですか?」

「それでは、検査員が鶏肉や魚、野菜のすべての細かい内部構造や調理法を熟知しなければならなくなる。結果として、検査員と食材の結合度が上がって、どっちも動かせなくなる」 シェフは包丁の背をまな板に当てて説明した。

「だから、二重にディスパッチ(Double Dispatch)する。食材側が、やってきた検査員に対して**『私は鶏肉です。鶏肉用の検査をしてください』**と自己紹介するんだ」

この「自己紹介と委譲」のやり取りが、Visitorパターンの核心となる。

Perlでのダブルディスパッチの流れ

PerlにはJavaのような「同名で引数の型が異なるメソッド(オーバーロード)」の仕組みがありません。そのため、Visitor側で visit(Chicken)visit(Fish) のように書き分けることができません。 この言語的な制約があるからこそ、PerlではMoo::Roleを用いて、明確にメソッド名を分けたVisitorインターフェースを定義する必要があります。

まず、Visitorの設計図(ロール)を定義します。

1
2
3
4
5
6
7
# 1. Visitor Interface (訪問者インターフェース)
package Visitor;
use Moo::Role;
use v5.36;

# 各食材タイプに対する具体的な検査メソッドを強制する
requires qw(visit_chicken visit_fish visit_vegetable);

そして、すべての食材クラス(要素)に、検査員を受け入れるスリット(accept)を1つだけ実装させます。

 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
# 2. Element Interface (要素インターフェース)
package Element;
use Moo::Role;
use v5.36;

requires 'accept';

# 3. 具象食材クラス
package Chicken;
use Moo;
use v5.36;
with 'Element';

sub accept ($self, $visitor) {
    # ダブルディスパッチの第2段階:
    # 「私は鶏肉です。鶏肉用の検査(visit_chicken)をしてください」と呼び出す
    $visitor->visit_chicken($self);
}

package Fish;
use Moo;
use v5.36;
with 'Element';

sub accept ($self, $visitor) {
    $visitor->visit_fish($self);
}

package Vegetable;
use Moo;
use v5.36;
with 'Element';

sub accept ($self, $visitor) {
    $visitor->visit_vegetable($self);
}

この流れをシーケンス図で表すと、以下のようになります。

Visitorパターンのダブルディスパッチシーケンス図。ClientがChickenオブジェクトのaccept(visitor)を呼び出し、Chickenが自己の型を把握してVisitorのvisit_chicken(self)を呼び出すことで、型に応じた処理を動的実行する流れを示しています。

  1. 第1ディスパッチ (accept の呼び出し): 呼び出し側(Client)は、食材オブジェクトの具体的な型(Chickenなど)を意識せず、抽象的な Element インターフェースを介して accept を呼び出します。このとき、オブジェクトの実際の型に応じて適切な accept メソッドが動的に選択・実行されます。
  2. 第2ディスパッチ (visit_xxx の呼び出し): 呼び出し先の accept メソッド(例:Chicken内)は、自分自身の型を正確に把握しているため、Visitorに対して型固有のメソッド(例:visit_chicken($self))を呼び出し、自己紹介を兼ねて処理を委譲します。

この二重の呼び出しにより、Visitor側では if ($element->isa('Chicken')) のような泥臭い型判定の条件分岐を一切書く必要がなくなります。


どちらの仕込み方を選ぶ?

シェフはまな板の上の食材を片付け、布巾で調理台を拭いた。そして、私に問いかけた。

「おい、見習い。お前ならどちらの仕込み方を選ぶ? すべての食材の身に直接手を入れて定義を汚し続けるか(Before)、それとも食材にはスリット(accept)だけを空け、検査実務はすべて検査員(Visitor)に一任するか(After)。どちらの仕込み方を選ぶ?」

私はもう迷わなかった。 「これ以上、食材の身を傷つけたくありません! スリットだけを空けて、検査員を招き入れる設計(After)を選びます!」

シェフが小さく頷いた。 「よし。では、その設計で検査員(Visitor)を仕込もう」

私たちは、具体的な検査員(Visitor)クラスを実装しました。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# 4. カロリー計算検査員 (CalorieCalculator)
package CalorieCalculator;
use Moo;
use v5.36;
with 'Visitor';

has total => (is => 'rw', default => 0);

sub visit_chicken ($self, $el)   { $self->total($self->total + 150) }
sub visit_fish ($self, $el)      { $self->total($self->total + 100) }
sub visit_vegetable ($self, $el) { $self->total($self->total + 30) }

# 5. アレルゲン検査員 (AllergenChecker)
package AllergenChecker;
use Moo;
use v5.36;
with 'Visitor';

has allergens => (is => 'rw', default => sub { [] });

sub visit_chicken ($self, $el)   {}
sub visit_fish ($self, $el)      { push @{$self->allergens}, '魚アレルギー' }
sub visit_vegetable ($self, $el) {}

既存クラスを変更しないという魔法

荒木さんは After コードを静かに見つめていた。 「きれいな分離ですが、結局 Visitor クラスが増えただけでは……?」

「荒木さん、試してみましょう」 私は悪戯っぽく笑いながら、エディタを開いた。

「今から、元の食材クラス(Chicken, Fish, Vegetable)には一切触れずに、新しい操作である『価格計算(PriceCalculator)』を追加してみせます」

私は新しく以下のファイルを1つ作成した。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# 6. 新規追加: 価格計算検査員 (PriceCalculator)
package PriceCalculator;
use Moo;
use v5.36;
with 'Visitor';

has total => (is => 'rw', default => 0);

sub visit_chicken ($self, $el)   { $self->total($self->total + 300) }
sub visit_fish ($self, $el)      { $self->total($self->total + 400) }
sub visit_vegetable ($self, $el) { $self->total($self->total + 100) }

そして、利用側のスクリプトでこれを実行した。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
use v5.36;

my @menu = ( Chicken->new, Fish->new, Vegetable->new );

# 新しい検査員(価格計算)を巡回させる
my $price_visitor = PriceCalculator->new;
for my $item (@menu) {
    $item->accept($price_visitor);
}
say "合計金額: " . $price_visitor->total . "円"; # 「合計金額: 800円」

「本当に……」 荒木さんが目を見開いた。 「新機能を追加したのに、元の ChickenFish のクラスファイルはエディタで開きすらしていない。git diff を見ても、新しい Visitor ファイルが1つ追加されただけです」

「これが Open/Closed原則(開放閉鎖原則) の力だ」とシェフが言った。 「既存のコードを変更に対して閉じ、拡張に対して開く。食材に直接針を刺さず、外から検査員を当てるスリット(accept)だけを空けておけば、明日にでも『塩分計算』だろうが『糖質チェック』だろうが、元のクラスを一切壊さずに追加できる」

「すごい……!」荒木さんの声に熱がこもる。

「ただし、弱点もある」とシェフは釘を刺した。 「新しい食材(要素:Pork など)が追加された場合、Visitor ロール自体に visit_pork の定義が必要になり、すべての既存の Visitor クラスが修正を余儀なくされる。だから、このパターンは**『要素の種類はほぼ固定で、操作が頻繁に追加される』**という状況でこそ真価を発揮するんだ」

前回の Bridge パターンが「調理法も食材も両方を独立して拡張できる」双方向の設計だったのに対し、今回の Visitor は「要素は固定し、操作だけを無限に拡張する」という非対称な設計なのだと、私は理解した。


試食合格

私たちはテストコード(after.t)を走らせた。

1
2
3
4
5
ok 1 - Visitor: Total calorie is 280
ok 2 - Visitor: Allergens contains 魚アレルギー
ok 3 - OCP: PriceCalculator works without modifying elements (Total price is 800)
ok 4 - Type safety: Invalid visitor throws method missing error
1..4

テストはすべて正常に通過し、警告もコンパイルエラーも出なかった。

荒木さんは画面を見つめて、長く、深い息を吐き出した。 「僕がやっていた泥臭いgrep作業は、仕込みの方向性が違っていただけだったんですね。これで全ファイルを傷だらけにせずに済みます」

「仕込みに手を触れず、検査員を巡らせればいい。配膳しろ」 シェフが静かに言った。

荒木さんは晴れやかな顔でラップトップをしまい、ほうじ茶を飲み干して「ありがとうございました!」と頭を下げた。暖簾をくぐって去っていく彼の背中は、重荷をすっかり降ろしたように軽やかだった。


シェフの仕込み工程表

問題(調理ミス)技法(パターン)効果(仕上がり)
食材クラスに操作メソッドを追加するたびに、すべての既存クラスを直接改修することになり、コードが汚れるとともにデバッグや保守のコストが激増する。Visitorパターン: 操作を独立したクラスに切り出し、食材クラスには accept という受け入れ窓口(スリット)だけを提供する。既存の食材クラスを変更することなく(OCP遵守)、新しい操作(カロリー計算、アレルゲン判定、価格計算など)を安全に後から追加できる。

工程

  1. 要素階層の安定性の確認: 対象となるオブジェクト構造(要素)が頻繁に変更されないことを確認する
  2. Visitorロールの定義: Moo::Role(例:Visitor)を作成し、requires で各具象要素に対応する visit_xxx メソッドを強制する
  3. Elementロールの定義: accept メソッドを requires する Element ロールを定義する
  4. 具象要素での accept 実装: 各具象要素クラス(例:Chicken)で accept メソッドを実装し、$visitor->visit_xxx($self) と自己紹介させて処理を委譲する(ダブルディスパッチ)
  5. 具象訪問者の実装: with 'Visitor' を適用した操作クラス(例:CalorieCalculator)を作成し、各要素に対する具体的な処理を記述する
  6. 新しい操作の追加: 既存要素クラスを一切開かず、新しい Visitor クラスを1つ作成して、要素群に巡回(accept)させる

シェフより

「食材の身に直接温度計や検査針を何本もブスブス刺したら、出す前に料理が台無しになる。食材はまな板に綺麗に置いておき、検査員を回せばいい」

「コードも同じだ。クラスの定義は、そのオブジェクトの『本質』だけに留めておけ。外からあれこれ操作を足したくなったら、そいつは Visitor の仕事だ。厨房のなかに検査員を入れない。それが、素材の味を守るということだ」


荒木さんが帰ったあと、私はまな板をきれいに洗い、食材たちを優しくペーパータオルで拭いて冷蔵庫の整理をした。 身が崩れなかった鶏肉や魚は、すっきりと整頓された棚に収まっている。

私は、自分の仕込みメモ帳を開いた。 今日の『まかない食材リスト』の横に、新しい Visitor を書き足してみる。 既存のカードは1行も汚れていない。ただ、新しい Visitor カードを横に並べただけ。

「……次は、塩分濃度をチェックする Visitor を作ってみよう」

自分で設計方針を選び、それが正しく動いた実感を噛みしめながら、私は嬉しくなってペンを動かした。 仕込みの道具をそっと当てるだけで、元の食材は無傷のまま。その仕上がりの美しさに、私は少しだけ誇らしさを感じていた。

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