Featured image of post コードシェフの仕込み帳【Bridge】交わる格子を分ける包丁〜掛け合わせで増え続けるサブクラスを、二つの軸に仕分ける〜

コードシェフの仕込み帳【Bridge】交わる格子を分ける包丁〜掛け合わせで増え続けるサブクラスを、二つの軸に仕分ける〜

メニュー追加のたびにクラスファイルが倍々で増える「継承爆発」問題。BridgeパターンをPerlとMooで実装し、調理法クラスが食材ロールを委譲で保持する設計に直す。

冷え込みが一段と厳しくなった冬の夕暮れ。 私とシェフは、大根や泥のついたネギを詰めた買い出し袋を両手に下げて、足早に店へと戻っていた。吐く息が白く揺れる。

「シェフ、今日は冷えますね。帰ったらまずストーブを——」

言葉の途中で、私は足を止めた。 「コード食堂」の古びた木製の引き戸の前で、一人の若い男性が背中を丸めてうずくまっていた。大事そうに、厚手のラップトップケースを胸に抱え込んでいる。

シェフが隣で足を止めず、すれ違いざまに言った。 「開いてるぞ。入れ」

若者は驚いたように顔を上げ、かじかんだ手で引き戸を引いた。

私たちは荷物を厨房に置き、ストーブに火を入れた。パチパチと薪がはぜる音が静かに響き始める。私が淹れた熱いほうじ茶を渡すと、若者は「すみません、ありがとうございます……」と、凍えた手で湯呑みを包み込んだ。

加藤さんと名乗った彼は、25歳。大手居酒屋チェーン「てんぐ亭」のメニュー・注文管理システムを担当するバックエンドエンジニアだという。丁寧で几帳面そうな佇まいだが、その目はひどく疲れていて、焦りからか少し早口だった。

「動くんです。動くんですけど、もう限界なんです」 加藤さんは湯呑みを置き、ラップトップを開いて画面をこちらに向けた。 「新しいメニューの組み合わせを追加するたびに、既存のコードをコピペして、新しいクラスを作らなきゃいけなくて。ファイルが倍々で増えていって、どれがどれだか分からなくなってしまったんです」

画面には、几帳面だが息苦しいほど複雑なクラス定義が並んでいた。

この記事で学ぶこと

この記事は、調理法と食材のすべての組み合わせを個別のサブクラスとして表現した結果、クラス数が掛け算($N \times M$)で増え続ける「継承爆発(クラス爆発)」の問題を、Bridgeパターンを使って整理する話です。調理法(Abstraction)と食材(Implementor)を2つの独立した階層に分離し、Mooを用いて委譲で組み立てる方法を解説します。

学ぶことひとことで言うと
Bridge パターン「抽象(全体の方向性を決める主軸)」と「実装(外から差し込む交換可能なパーツ)」を分離し、それぞれを独立して変化させられるようにする構造パターン。
継承爆発(クラス爆発)複数の直交する要素をすべて継承のサブクラスとして表現した結果、組み合わせ数(掛け算)の分だけクラスが激増する設計の不備。
Mooでの委譲実装抽象クラスが、Moo::Role で定義された実装者ロールを has による属性として保持し、処理を委譲する。
直交性のメリット構成要素を $N+M$ のクラス数に抑え、新しい調理法や食材を1つずつ追加するだけで、既存クラスを一切壊さず組み合わせ($N \times M$)を増やせる。

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

  • PerlとMooの基本的な使い方(has, extends, with)が分かる
  • 「継承」を使ってコードを共通化しようとしたが、ファイル数が増えすぎて困ったことがある
  • 委譲(Composition)の実践的な使い方を学びたい

焼きと蒸し、鶏と魚がガッチリ絡むコード

シェフはストーブの前で手を温めながら、加藤さんの画面を静かに見つめた。 そこに書かれていたのは、メニューごとのクラス設計だった。

 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
# package Dish (基底クラス)
package Dish;
use Moo;
use v5.36;
sub name { die "abstract method" }
sub description { die "abstract method" }

# package GrilledChicken (焼き鶏)
package GrilledChicken;
use Moo;
use v5.36;
extends 'Dish';
sub name { '焼き鶏' }
sub description { '直火で香ばしく焼き上げた鶏肉。焼き鳥の定番。' }

# package GrilledFish (焼き魚)
package GrilledFish;
use Moo;
use v5.36;
extends 'Dish';
sub name { '焼き魚' }
sub description { '強火の遠火でじっくり焼き上げた本日の魚。' }

# package SteamedChicken (蒸し鶏)
package SteamedChicken;
use Moo;
use v5.36;
extends 'Dish';
sub name { '蒸し鶏' }
sub description { 'じっくりと蒸し上げ、ふっくら仕上げた鶏肉。ヘルシー。' }

# package SteamedFish (蒸し魚)
package SteamedFish;
use Moo;
use v5.36;
extends 'Dish';
sub name { '蒸し魚' }
sub description { 'ネギとショウガの風味でふっくら蒸し上げた本日の魚。' }

加藤さんがぼやいた。 「最初はこれで良かったんです。『焼き鳥』と『蒸し魚』だけだったので。でも、店舗ごとの要望で『焼き魚』や『蒸し鶏』をマスタに登録することになってクラスを増やしました。さらに今度は、新しく『燻製(Smoked)』という調理法と、『豚肉(Pork)』という食材を導入しろと言われていて……」

加藤さんの指がキーボードの上で宙に浮く。 「そうすると、『燻製鶏』『燻製魚』『燻製豚』、さらに『焼き豚』『蒸し豚』と、一気に5つも新しいクラスファイルを作らなきゃいけないんです。調理法が3つ、食材が3つになれば、合計9つのファイルです。これから先、煮る(Boiled)とか牛肉(Beef)が追加されたら、一体どれだけのファイルを作ればいいのか……」

シェフは立ち上がり、厨房へ向かった。 「焼きと蒸し、鶏と魚がそれぞれガッチリくっついてるな。これじゃあ、包丁が入らない」

そして、私に独り言のように言った。 「**継承爆発(クラス爆発)**だ。機能を追加しようとするたびに、掛け算でサブクラスが増え続ける。仕込みの段階で、混ぜるべきじゃないものを混ぜちまった証拠だ」

私はその言葉を聞いた瞬間、心臓が跳ねた。 「……あっ」 「なんだ?」とシェフが振り返る。

私は自分の首をすくめながら、おそるおそる冷蔵庫を開けた。 「私の……冷蔵庫のタッパーと、まったく同じです」 「タッパー?」 「はい。まかないの仕込みで『醤油ダレの焼き鳥』と『塩ダレの蒸し魚』を、タレと具材を全部混ぜた状態でそれぞれタッパーに仕込んじゃって……。他の組み合わせを食べたいと思うたびに、別々のタッパーに具材とタレを混ぜて詰め直していたから、冷蔵庫がタッパーだらけでパンパンになってるんです」

加藤さんが目を見開いて私のタッパーを見た。 「まさにそれです! 僕のシステムも、組み合わせごとにクラスファイルが溢れかえって、サーバーのフォルダがタッパーだらけになっているんです!」


仕込みの軸をどう分けるか、お前が選べ

シェフは静かに笑い、厨房のカウンターにいくつかの小鉢を並べた。

「仕込みを最初からやり直すぞ」

シェフが小鉢に注いだのは、醤油をベースにした「焼きダレ」と、すっきりとした「ポン酢(蒸し用)」だった。そして、別の皿に生肉の「鶏肉」と、下処理をした「魚の切り身」を置く。 タレと肉は、完全に別の器に分かれている。

「タレ(調理法)と、肉(食材)は独立した別の軸だ。これを繋ぐのが『架け橋(ブリッジ)』だ」

シェフはフライパンに火を入れ、鶏肉を香ばしく焼き上げた。そして、それを焼きダレの小鉢に絡める。さらに、魚をセイロで蒸し上げ、ポン酢の小鉢に添えた。

「タレと具材を別々に仕込んでおけば、食べる直前に組み合わせるだけでいい」

そして、シェフは私をまっすぐに見つめた。使い込まれた包丁をまな板の上に静かに置き、問いかける。

「おい。お前なら、どちらを料理の『主軸(土台)』として仕込む? 火入れと味付け(調理法)か、それとも皿に載る具材(食材)か?」

私は息を呑んだ。 えっ、私が決めるの?

「ええと……」 頭の中で、加藤さんのコードと、厨房の景色が重なり合う。間違えたら、また冷蔵庫がパンパンになってしまう。手のひらにじっとりとした汗がにじむ。

私は毎日の修行を思い返した。 朝一番、厨房で最初に行うのは、コンロの火を起こし、大きな鍋に湯を沸かすことだ。「焼き」か「蒸し」かによって、火の管理も、使う調理器具も、厨房の動線も全く異なる。料理の全体の方向性を決定づけるのは、いつもその「調理のやり方」だった。一方、具材である鶏や魚は、その日の市場の仕入れによって柔軟に入れ替わるものだ。

「……調理法、です」 私は声が震えないよう気をつけて答えた。 「火入れや味付け(調理法)が料理全体の顔になるから、こっちを主軸にしたいです。食材は仕入れによって変わるから、外から差し込めるパーツにしたいです」

シェフが小さく頷いた。 「そうだ。全体の骨組みを決める『調理法』を主軸にし、交換可能な『食材』を外から差し込む。よし、その設計でコードを書け」


Bridgeパターンの仕込み(Afterコード)

私たちは、加藤さんの目の前でコードを仕込み直した。

今回採用した Bridgeパターン は、「抽象(全体の方向性を決める主軸)」と「実装(外から差し込む交換可能なパーツ)」を分離し、委譲によって結合する設計技法です。

まず、外から差し込む交換可能なパーツ(食材)を Moo::Role で定義します。

1
2
3
4
5
6
7
# 1. Implementor (実装者インターフェース)
package Ingredient;
use Moo::Role;
use v5.36;

requires 'name';    # 食材の名前
requires 'prepare'; # 食材の下ごしらえ

この Ingredient ロールを適用して、具体的な食材クラスを作成します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
# 2. ConcreteImplementor (具体的実装者)
package Chicken;
use Moo;
use v5.36;
with 'Ingredient';

sub name { '鶏' }
sub prepare { '一口大に切り分ける' }

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

sub name { '魚' }
sub prepare { 'ウロコを取り、塩を振る' }

次に、料理の全体の骨組みを決める主軸(調理法)を定義します。これが Abstraction(抽象) です。 このクラスは、ingredient(食材)を属性として保持し、Mooの isa チェックで Ingredient ロールを実装したオブジェクトのみを受け取ります。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
# 3. Abstraction (抽象)
package CookStyle;
use Moo;
use v5.36;
use Scalar::Util qw(blessed);

has ingredient => (
    is       => 'ro',
    isa      => sub {
        my $val = shift;
        die "Must be a blessed object" unless blessed($val);
        die "does constraint failed: does not consume 'Ingredient' role"
            unless $val->DOES('Ingredient');
    },
    required => 1,
);

sub name  { die "abstract method" }
sub serve { die "abstract method" }

ここで使われている blessedDOES は、Perlでの型安全性を高めるための道具です。

  • blessed: Scalar::Util モジュールが提供する関数で、渡された値が「クラスのインスタンス(blessされたオブジェクト)」であるかどうかを判定します
  • DOES: Perlのオブジェクト指向に組み込まれているメソッドで、そのオブジェクトが指定されたロール(ここでは Ingredient)を正しく実装(with)しているかを検証します

Perlは動的型付け言語であるため、このように isa の検証関数の中で明示的にチェックを走らせることで、想定外のオブジェクトが注入されるのを防ぐことができます。

そして、この CookStyle を継承して、具体的な調理法クラス(RefinedAbstraction)を作成します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# 4. RefinedAbstraction (洗練された抽象)
package Grilled;
use Moo;
use v5.36;
extends 'CookStyle';

sub name { my $self = shift; '焼き' . $self->ingredient->name }
sub serve {
    my $self = shift;
    my $prep = $self->ingredient->prepare;
    return "[$prep] -> 直火で香ばしく焼き上げる。焼きダレを添えて。";
}

package Steamed;
use Moo;
use v5.36;
extends 'CookStyle';

sub name { my $self = shift; '蒸し' . $self->ingredient->name }
sub serve {
    my $self = shift;
    my $prep = $self->ingredient->prepare;
    return "[$prep] -> 強火でふっくらと蒸し上げる。ポン酢を添えて。";
}

実際にどう呼び出して使うのか

この設計を使うと、利用側は調理法クラスを作成する際に、使いたい食材のインスタンスを外から渡す(依存注入する)だけになります。

1
2
3
4
# 焼き鶏を作る
my $dish = Grilled->new(ingredient => Chicken->new);
say $dish->name;  # 「焼き鶏」
say $dish->serve; # 「[一口大に切り分ける] -> 直火で香ばしく焼き上げる。焼きダレを添えて。」

クラスの関係を図に整理すると、このようになります。

Bridgeパターンのクラス構成図。CookStyle(Abstraction)がIngredientロール(Implementor)を保持(has)し、Grilled、Steamed(Refined Abstraction)がCookStyleを継承、Chicken、Fish(Concrete Implementor)がIngredientを実装する構成を示しています。


2軸が直交するということ

加藤さんはコードをじっと眺めていた。 「調理法と食材が、完全に分かれましたね……。でもシェフ、これだとクラス数は変わっていませんか? 調理法が2つ、食材が2つ、それにロールが1つ。結局、クラスとファイルの数は同じ5つですよ?」

私は以前学んだことを思い出しながら、加藤さんに言った。 「加藤さん、ここから新しいものを追加するときのことを考えてみてください」

私はシェフがこれから追加しようとしていた「燻製(Smoked)」と「豚肉(Pork)」のコードを追加した。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
# 新食材 (ConcreteImplementor)
package Pork;
use Moo;
use v5.36;
with 'Ingredient';

sub name { '豚' }
sub prepare { 'スジを切り、軽く叩く' }

# 新調理法 (RefinedAbstraction)
package Smoked;
use Moo;
use v5.36;
extends 'CookStyle';

sub name { my $self = shift; '燻製' . $self->ingredient->name }
sub serve {
    my $self = shift;
    my $prep = $self->ingredient->prepare;
    return "[$prep] -> サクラチップでじっくりと燻し上げる。";
}

「あっ」と加藤さんが息を呑んだ。

「既存のコードを1行も変えずに、新しく SmokedPork を1クラスずつ書いただけなのに……」

「そうです」と私は微笑んだ。 「SmokedPork を組み合わせれば『燻製豚』になりますし、既存の GrilledPork を渡せば『焼き豚』が作れます。新しく追加した調理法 Smoked に、既存の Chicken を渡して『燻製鶏』を作ることもできます」

調理法(Abstraction) / 食材(Implementor)Chicken (鶏)Fish (魚)Pork (豚) [NEW!]
Grilled (焼き)焼き鶏焼き魚焼き豚 (新組み合わせ)
Steamed (蒸し)蒸し鶏蒸し魚蒸し豚 (新組み合わせ)
Smoked (燻製) [NEW!]燻製鶏 (新組み合わせ)燻製魚 (新組み合わせ)燻製豚 (新組み合わせ)

Beforeの設計では、3調理法 × 3食材 = 9つのクラスファイルが必要だった。 しかしAfterの設計では、調理法3つ + 食材3つ = **6つのクラス(と1つのロール)**だけで済む。

「組み合わせが掛け算($N \times M$)から、足し算($N+M$)になった……!」 加藤さんの声に驚きと納得が混じる。 「これなら、次に『牛肉(Beef)』や『煮る(Boiled)』が来ても、それぞれ1つファイルを増やすだけで、すべての組み合わせに自動的に対応できる」

私は加藤さんが納得してくれたのが嬉しくて、一歩前に出た。 「以前やった Decoratorパターン も、トッピングを後から重ねて機能を増やすやり方でした。でも、あれは『1つの軸』を動的にどんどん重ねていくトポロジー(構造)なんです。今回の Bridge は、最初から『調理法』と『食材』という**『2つの独立した軸(並行2軸)』を直交させて分離する**構造なんです」

「なるほど……。直交する2つの軸を、独立して拡張できるようにする『架け橋(Bridge)』か」 加藤さんは何度も頷いた。


試食合格

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
ok 1 - Grilled + Chicken = 焼き鶏
ok 2 - Grilled + Chicken: serve
ok 3 - Steamed + Fish = 蒸し魚
ok 4 - Steamed + Fish: serve
ok 5 - Smoked + Pork = 燻製豚 (新組み合わせ)
ok 6 - Smoked + Pork: serve
ok 7 - Grilled + Pork = 焼き豚 (既存調理法 + 新食材)
ok 8 - Grilled + Pork: serve
ok 9 - Smoked + Chicken = 燻製鶏 (新調理法 + 既存食材)
ok 10 - Smoked + Chicken: serve
ok 11 - クラス数の足し算: N + M = 6クラス
ok 12 - does constraint: Invalid ingredient throws error
1..12

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

加藤さんは画面に表示された ok の文字を見つめ、深いため息をついた。 「これで、新メニューが出るたびにファイルの山と格闘しなくて済みます。本当にありがとうございました」

「仕込みを分ければ、あとは合わせるだけだ」 シェフが包丁を元の位置に静かに戻し、言った。 「配膳しろ。冷めないうちにな」

加藤さんは嬉しそうにラップトップをケースにしまい、熱いほうじ茶を飲み干した。 「明日、さっそくこの設計で書き換えます。燻製豚の追加も、すぐに終わりそうです」

暖簾をくぐり、夜の街へと帰っていく彼の足取りは、来たときよりもずっと軽そうに見えた。


シェフの仕込み工程表

問題(調理ミス)技法(パターン)効果(仕上がり)
調理法と食材の全組み合わせを個別のサブクラスで実装した結果、新しい要素を追加するたびにファイルが掛け算で増える「継承爆発」が起き、保守が困難になる。Bridgeパターン: 抽象(全体の方向性を決める主軸=調理法)と実装(交換可能なパーツ=食材)を分離し、委譲で結合する。構成要素が $N+M$ の足し算になり、既存コードを変更せず新しい調理法や食材を安全に追加できるようになる。

工程

  1. 直交する軸の特定: システムのなかで、独立して変化する2つの軸(例:調理法と食材)を見極める
  2. 実装者インターフェースの定義: 一方の軸(交換可能なパーツ側)を Moo::Role(例:Ingredient)として定義し、必要なメソッドを requires で強制する
  3. 具体的実装者の作成: ロールを with で実装した具象クラス(例:Chicken, Fish)を作成する
  4. 抽象クラスの作成: もう一方の軸(主軸側)を基底クラス(例:CookStyle)とし、has で実装者ロールを属性として保持する。このとき、isa チェックでロールの有無を保証する
  5. 洗練された抽象の作成: 基底クラスを継承した具象クラス(例:Grilled, Steamed)を作り、保持している実装者オブジェクトに処理を委譲する
  6. 新しい要素の追加: 既存のクラスに一切触れることなく、新しい調理法や食材のクラスを1つ書くだけで、組み合わせを動的に増やす

シェフより

「仕込みの段階で、タレと肉を全部混ぜて仕込んでいたら、そりゃ冷蔵庫はタッパーだらけになる。焼き鳥も、蒸し魚も、その日の注文が入ってからタレを合わせればいいんだ」

「設計も同じだ。変化する軸が2つあるなら、それぞれを別々に仕込んでおけ。最後に繋ぐ『橋』を1本渡しておけば、あとはどうとでも組み合わせられる。余計なファイルを作る暇があるなら、包丁を研いでおくんだな」


加藤さんを見送ったあと、私は自分の冷蔵庫を開けた。 タレと具材を別々にジップロックや小分け容器に入れ、冷蔵庫の棚を整理する。

「……すっきりした」

冷蔵庫のなかに、新しい空間が生まれたような気がした。 今まではシェフに言われた仕込みをただこなすだけだったけれど、今日は自分で「調理法を主軸にする」と選んだ。そして、そのおかげで加藤さんのシステムも、私の冷蔵庫も、新しく生まれ変わったのだ。

自分で選ぶということは、少し怖いけれど、うまく繋がったときの嬉しさは格別だ。

整理された冷蔵庫の棚を見つめながら、私は明日作るまかないのメニュー(新しい組み合わせ)を、楽しそうに考え始めていた。

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