Featured image of post コードシェフの仕込み帳【Composite】コースの中にコースがあっても〜単品とセットを if で分ける料金計算を、同じ顔で合計する〜

コードシェフの仕込み帳【Composite】コースの中にコースがあっても〜単品とセットを if で分ける料金計算を、同じ顔で合計する〜

単品とコースを if で分けて計算し、コースの中にコースが入ると合計が崩れるコードを、Compositeパターンで整理します。PerlとMooで単品もコースも同じ顔で扱い、合計を再帰的に求める設計へ。仕組みから丁寧に解説します。

夜の仕込みの時間は、昼とは匂いが違う。

営業前の厨房は、出汁の湯気が一日でいちばん濃くなる。私はその端で、シェフが宴会の予約用に盛り合わせを組むのを見ていた。小鉢をいくつか大皿に並べ、その大皿を、さらに大きな漆の盆に乗せていく。器の中に器、その盆をまた別の盆に重ねていく。両手で持つと、見た目より重い。

器の中に、器がある。

それを眺めていて、ふと前のことを思い出した。先日、ここで「変換役を一か所に」と教わった日の夜、私は自分の案件のコード——在庫管理ツール——を少しだけ覗いた。外部APIのレスポンスを取り出している箇所が、三か所に散らばっていた。直し方はまだわからない。あの日は、見つけただけで終わった。

そんなことを考えていると、引き戸が開いた。

本日の持ち込み

入ってきたのは、二十代後半くらいの男性だった。少し急いだ足取りで、入り口で一度立ち止まってから「すみません、ネットで見て——設計の相談に乗ってもらえると書いてあって」と早口で言った。声に、疲れと焦りのようなものが混じっている。

シェフは盆を静かに置いて、手を拭いた。「座れ。何を持ち込んだ」

彼はカウンターにノートPCを開きながら、堰を切ったように話し始めた。「飲食店の注文システムを作ってるんです。単品とコースの料金計算で——コースの中にコースが入ると、合計が合わなくなって。先週、お客さんに安く請求しちゃってたのが見つかって」

私は自分のPCを脇に避けて、彼のために場所を作った。画面に、こんなコードが映っている。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
package PriceCalculator;
use Moo;
use v5.36;

sub total {
    my ($self, $node) = @_;
    if ($node->{type} eq 'single') {
        return $node->{price};
    }
    elsif ($node->{type} eq 'course') {
        my $sum = 0;
        $sum += $_->{price} for $node->{items}->@*;   # 子は単品の前提
        return $sum;
    }
    die "unknown type: $node->{type}\n";
}

「注文の一品ずつを、こういうハッシュで表してて」と彼は続けて、データの例を見せた。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
# 単品
my $salad = { type => 'single', name => 'サラダ', price => 500 };

# コース(前菜・主菜・デザート)
my $lunch = {
    type  => 'course',
    name  => 'ランチコース',
    items => [
        { type => 'single', name => '前菜',     price => 400 },
        { type => 'single', name => '主菜',     price => 900 },
        { type => 'single', name => 'デザート', price => 300 },
    ],
};

「単品は値段をそのまま返す。コースは中の品を全部足す。最初はこれで十分だったんです。total($lunch) で、ちゃんと 1600 円になる」

私にはコードの細かいところは読めない。でも「単品」と「コース」という言葉は、さっきまでシェフが組んでいた盛り合わせと重なって見えた。小鉢が単品で、大皿がコース。そういうことだろうか。

「問題は、これなんです」と彼はもう一つのデータを開いた。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
# 宴会プラン(コースの中に「ドリンクセット」というコースが入る)
my $banquet = {
    type  => 'course',
    name  => '宴会プラン',
    items => [
        { type => 'single', name => '主菜', price => 1200 },
        {
            type  => 'course',
            name  => 'ドリンクセット',
            items => [
                { type => 'single', name => 'ビール', price => 600 },
                { type => 'single', name => 'ワイン', price => 800 },
            ],
        },
    ],
};

「宴会プランの中に、ドリンクセットっていう小さなコースを入れたくて。total($banquet) を呼ぶと——」

彼は実際にそれを動かして、画面に数字を出した。1200。

「本当は、主菜 1200 とビール 600 とワイン 800 で、2600 円のはずなんです。なのに 1200 円。ドリンクセットの 1400 円分が、まるごと消えてる」彼は頭を抱えた。「これで一週間、お客さんに安く請求してました」

子は単品とは限らない

シェフはしばらく画面を見てから、total のコースの部分を指でなぞった。

「ここだ。コースの合計、子を一つずつ足してる。だがこの足し算、『子は単品だ』って決めてかかってる」

1
$sum += $_->{price} for $node->{items}->@*;   # 子は単品の前提

「単品なら {price} に値段が入ってる。だが、子がコースだったら? コースのハッシュに {price} なんてキーはない。undef だ。undef を足しても、何も増えない」

彼が「あ……」と小さく声を出した。「だから、ドリンクセットの分が 0 円扱いに」

私には、なぜ 0 円になるのかまではわからなかった。でもシェフが盆を一つ持ち上げたとき、急にわかった気がした。

「この盆、いくらだ? って聞かれたら、お前さんどうする」とシェフが彼に聞いた。

「乗ってるものを、全部足します」

「乗ってるのが小鉢なら、値段が付いてる。だが、大皿が乗ってたら? その大皿だって、中身を足さなきゃ値段は出ないだろう」シェフは盆の上の大皿を指した。「お前さんのコードは、大皿が乗ってる場合を、見なかったことにしてる。大皿には値札が貼ってないから、0 円だと思ってる」

彼が黙ってうなずいた。

これが今回の問題の名前だ。

nested-conditionals(入れ子の場合分け): 個(単品)と集合(コース)を typeif 分岐し、集合の中に集合が入るたびに分岐をネストさせる必要が出る設計。種類や階層が増えるほど分岐が増殖し、ある深さで必ず破綻する。

「それで、僕、こう直したんです」と彼は別のコードを見せた。コースのループの中に、もう一段 if を足したものだった。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
elsif ($node->{type} eq 'course') {
    my $sum = 0;
    for my $child ($node->{items}->@*) {
        if ($child->{type} eq 'single') {
            $sum += $child->{price};
        }
        elsif ($child->{type} eq 'course') {
            $sum += $_->{price} for $child->{items}->@*;   # 2段目までしか対応できない
        }
    }
    return $sum;
}

「これで宴会プランは 2600 円になりました。直った、と思ったんです」彼の声がまた沈む。「でも今度は、宴会プランをさらに別のプランに入れたとき——コースの中のコースの中のコース——でまた合わなくなって。1700 円とか、変な数字が出るんです」

「そうだろうな」とシェフは言った。怒ってはいない。「一段深くなるたびに、if を一段足す。盆の中に盆、その中にまた盆——書ききれるか? 終わりがないぞ」

彼が「もう、何段ネストすればいいのか分からなくなって」と、自嘲するように笑った。

私は片付けの手を止めていた。「単品とコースを if で分けてる」——その言葉が、自分のコードの、別の場所を呼び起こしたからだ。

前に見つけた「散らばり」とは違う。あれは、同じ言葉が三か所にあった話。でも今、彼が言っているのは、「単品か、セットか」で処理を分けている話だ。私の在庫ツールにも、確か——セット商品の在庫金額を出すところで、単品商品かセット商品かを if で分けていた。そういえば、セットの中にセットを入れたとき、数が合わなかったことがあった。

形が違う。これは「場合分け」の問題だ。それが見分けられた自分に、少し驚いた。でも今は、彼の話に集中しよう。

同じ問いに答えられればいい

「単品もコースも、同じ問いに答えられるようにすればいい」とシェフが言って、コードを書き始めた。

「同じ問い、ですか」と彼が聞き返す。

「『お前さん、いくらだ』だ。単品だろうがコースだろうが、それに答えられること——それだけを約束させる」

シェフはまず、こう書いた。

1
2
3
4
5
package MenuComponent;
use Moo::Role;
use v5.36;

requires 'price';   # この Role を with するクラスは price を必ず持つ

「これが『同じ顔』だ」とシェフは言った。

Composite(コンポジット): 個(単品)と集合(コース)を同じインターフェースで扱い、集合の合計を子へ再帰的に委譲する技法。GoF の構造パターンのひとつで、部分と全体をひとまとめに扱う。その共通の顔を定義するのが Component(コンポーネント)——ここでは price を持つことだけを要求する MenuComponent という Role(役割)だ。

私はその requires 'price' という一行を見て、シェフに聞いてみた。「それは……『いくらか答えられないやつは、仲間に入れない』ということですか?」

「そんなとこだ」とシェフは短く返した。「Perl は、メソッドがあるかどうかを後から確かめる言葉だ。この requires は、MenuComponent を名乗るクラスを読み込んだその瞬間に、『お前 price 持ってるか?』と確かめる。持ってなきゃ、その場で止める」

次に、単品。

1
2
3
4
5
6
7
package SingleItem;
use Moo;
use v5.36;

has name  => (is => 'ro', required => 1);
has price => (is => 'ro', required => 1);
with 'MenuComponent';

「単品は、price っていう値をそのまま持ってる。それがそのまま答えになる」とシェフは言った。

これが Leaf(リーフ・葉)——子を持たない末端だ。

「一つ、気をつけることがある」とシェフが続けた。「単品のほうは、price を書く行を with より先に置け。with ってのは『price を持ってるか』をその場で確かめる処理だ。先に確かめると、まだ price が用意できてなくて、無いと言って怒られる。コースのほうは pricesub で書くから先に用意される。あっちは気にしなくていい」

そして、コース。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
package Course;
use Moo;
use v5.36;
with 'MenuComponent';

has name     => (is => 'ro', required => 1);
has children => (is => 'ro', default => sub { [] });

sub add {
    my ($self, $component) = @_;
    push $self->children->@*, $component;
    return $self;
}

sub price {
    my $self = shift;
    my $sum = 0;
    $sum += $_->price for $self->children->@*;   # 子に price を聞くだけ
    return $sum;
}

これが Composite(枝)——子を持ち、計算を子へ委ねる側だ。シェフは price の中の一行を指した。

1
$sum += $_->price for $self->children->@*;

「ここを見ろ。コースは、自分の中身に『お前いくらだ』と聞いて、足してるだけだ。中身が単品なら、値段を答える。中身がコースなら——そいつがまた、自分の中身に同じことを聞いて、足して、答える」

シェフは盆を持ち上げて、乗っている小鉢と大皿に順番に指を当てた。「この小鉢、いくらだ。この大皿、いくらだ——大皿は中の小鉢に聞いて、足して答える。同じ問いを、どの段にも投げる。盆は、乗ってるのが小鉢か大皿かを、気にしない。ただ『いくらだ』と聞くだけだ」

構造を絵にすると、こうなる。

	classDiagram
    class MenuComponent {
        <<Role>>
        +price()
    }
    class SingleItem {
        +name
        +price
    }
    class Course {
        +name
        +children
        +add(component)
        +price()
    }
    MenuComponent <|.. SingleItem : with
    MenuComponent <|.. Course : with
    Course o-- MenuComponent : children

Course が抱える childrenMenuComponent——つまり単品でも、別のコースでもいい。だから入れ子は、構造そのものに織り込まれている。

組み立て方は、こうなる。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
my $banquet = Course->new(name => '宴会プラン');
$banquet->add(SingleItem->new(name => '主菜', price => 1200));

my $drink_set = Course->new(name => 'ドリンクセット');
$drink_set->add(SingleItem->new(name => 'ビール', price => 600));
$drink_set->add(SingleItem->new(name => 'ワイン', price => 800));

$banquet->add($drink_set);   # コースの中にコースを入れる

say $banquet->price;   # 2600

呼び出し側は、ただ $banquet->price と書くだけ。単品でも、コースでも、入れ子の宴会プランでも、書き方は同じ。if は一つもない。

「2600、出ました」と彼が画面を見て言った。「if を、一つも書いてないのに」

ただ「いくらだ」と聞くだけ

彼はしばらく画面を見つめていた。それから、少し言いにくそうに口を開いた。

「……でも」と彼は言った。「これ、結局 Course の中で子を for で回して足してますよね。さっきの iffor に書き換えただけ、というか……正直に言うと、ハッシュのまま、こういう再帰関数を書いても同じことができる気がするんです」

彼は、こう打ち込んだ。

1
2
3
4
5
6
7
8
use List::Util qw(sum0);

sub total {
    my $node = shift;
    return $node->{type} eq 'single'
        ? $node->{price}
        : sum0 map { total($_) } $node->{items}->@*;
}

「これでも、入れ子は何段でも合うはずで。今までも、書き換えるたびに『直った』と思って、また壊れたんです。これ、本当に違うんですか?」

不信が、声に滲んでいた。私も、その気持ちは少しわかる気がした。見た目は、確かに似ている。

「いい疑いだ」とシェフは言った。手を止めて、彼のほうを向く。「入れ子の合計を出すだけなら、お前さんの言う通りだ。その再帰でも合う。そこは認める」

「じゃあ……」

「だが、新しいメニューを足してみろ」とシェフは遮った。「『割引セット』だ。中身を合計して、そこから一割引く。さあ——その再帰関数なら、どこを直す?」

彼は少し考えて、答えた。「total の中の if に、もう一本……『割引セットなら、合計して 0.9 を掛ける』っていう枝を足します」

「そうだ。料金だけならな」とシェフは言った。「だが、お品書きを画面に出す処理を作ったら、どうする? 品数を数える処理は? ツリーをたどる処理を作るたびに、その全部に同じ枝を足すことになる。種類が三つ、処理が三つあれば、if の枝は九本だ。種類が増えるたびに、全部の処理を開いて回ることになる」

彼の手が止まった。

「Composite なら——」シェフは新しいコードを一つだけ書いた。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
package DiscountedCourse;
use Moo;
use v5.36;
extends 'Course';

has discount_rate => (is => 'ro', default => sub { 0.1 });

around price => sub {
    my ($orig, $self) = @_;
    my $full = $self->$orig();                       # Course の合計
    return int($full * (1 - $self->discount_rate));  # そこから割引
};

「クラスを一枚足すだけだ。Course から、子を持つ仕組み(childrenadd)はそのまま受け継ぐ。price だけ『合計してから一割引く』に差し替える。around っていうのは、元の price を一回呼んで、その結果に手を加える書き方だ」

シェフは盆の上に、赤い札のついた新しい大皿を乗せた。「これは『合計してから一割引く皿』だ。だが——Courseprice も、呼び出し側の ->price も、一文字も変えてない。盆は、これが割引セットかどうかなんて聞かない。ただ『いくらだ』と聞くだけだからな」

私は、その赤い札の皿が盆に乗ったのを見て、思わず口に出していた。

「つまり……盆は、乗ってるのが単品か、盛り合わせか、割引セットか——気にしてないんですね。ただ『あんた、いくら?』って聞くだけ。だから新しい料理が増えても、盆は聞き方を変えなくていい?」

シェフがちらりとこちらを見た。「そういうことだ」。短い返事だったけれど、この前のときより、少し長く目が合った気がした。

私の中で、何かがつながった。「同じ顔」というのは、見た目が同じということじゃない。同じ問いに答えられる、ということだ。盆に乗れる資格は、ただ一つ——「『いくら?』に答えられること」。それさえあれば、単品でも、コースでも、割引セットでも、盆は同じように扱える。

新しい種類のメニューを足したいなら、MenuComponent を名乗って price を実装したクラスを、一枚足せばいい。盆の側(Course と呼び出し側)は、何も知らないまま、今まで通り動く。これが OCP(開放閉鎖原則)——機能を足すときに、既存のコードを変えずに、新しいクラスを足すだけで済むようにする、という設計の指針だ。

彼は画面を見つめたまま、ゆっくりと言葉を選んでいた。「……今までは、total が全部の種類を知ってなきゃいけなかった。種類が増えるたびに、total を開いて、if を足してた。これは逆ですね。種類のほうが、『自分の値段の出し方』を自分で持ってる。total は、もう知らなくていい」

「飲み込みが早いな」とシェフが言った。彼の硬かった表情が、少しほどけたように見えた。

そういえば、と私は思った。この前ここに来た人は、料理を一枚ずつ包んで、味を足していた(あれは Decorator というらしい)。あれは、一つの皿に一枚ずつ重ねる話だった。今回は、一つの器が、何品も抱える話だ。トッピングは縦に積む。コースは横に並べる。包む向きが違う。

試食合格

コードを書き直して、テストを走らせた。

1
2
3
4
5
6
7
# テスト結果
ok - 単品: price をそのまま返す
ok - フラットなコース: 1600400 + 900 + 300
ok - 2段の入れ子宴会プラン: 26001200 + 600 + 800
ok - 3段の入れ子: 3100if を一本も足さずに合う
ok - 割引セット新種: 1260Course も呼び出し側も無変更
ok - 呼び出し側は型判定なしで合算: 3360

コースの中のコースの中のコースでも、if を一本も足さずに、正しい合計が出た。

「これなら」と彼が言った。今度は自分から先を続けた。「次に『朝食セット付きプラン』とか言われても、total を触らずに済みますね。クラスを一枚足すだけで」

シェフは小さくうなずいてから、付け加えた。

「献立は、いくらでも深くしていい。器が中身に『いくらだ』と聞ければ、何段重ねても崩れやしない」

「同じ問いに答えられればいい、ですか」と彼は繰り返した。「……僕はずっと、total を賢くしようとしてました。全部の場合を、一か所で見分けようとして。逆だったんですね。料理のほうに、答えさせればよかった」

そう言って、彼は立ち上がった。来たときより、足取りが軽い。もう頭の中で書き直しているのだろう、礼の言葉は短かった。

引き戸が閉まる音がした。

片付けをしながら、シェフの言葉を反芻した。器が中身に「いくらだ」と聞く。中身がまた、自分の中身に聞く。同じ問いが、どこまでも下りていく。

それで——自分のコードのことが、戻ってきた。この前見つけた「散らばり」とは別の、あのセット商品の if

閉店後の静かな食堂で、私はノートPCを開いた。在庫管理ツールのコード。セット商品の在庫金額を出している箇所を探すと、あった。

1
if ($item->{type} eq 'set') {

単品商品とセット商品で、金額の出し方を if で分けている。セットの中にセットを入れたとき数が合わなかったのは、これだ。彼のコードと、同じ形をしている。

でも今日は、見つけただけでは終わらなかった。

単品もセットも、同じ顔にすればいい。「いくら?」に答えられるようにすれば、if で分けなくていい——直し方の方針が、自分の言葉で出てきた。

書けるかどうかは、まだ自信がない。でも、何を直せばいいかは見えた。この前の夜、ここで止まっていた場所より、一歩だけ前に来た。


シェフの仕込み工程表

問題(調理ミス)技法(パターン)効果(仕上がり)
単品とコースを typeif 分岐し、コースの中にコースで合計が壊れるComposite(同じ顔の MenuComponent Role)入れ子が何段でも、if を足さずに再帰的に合計できる
新しい種類を足すたびに、ツリーをたどる処理全部に if の枝が増える各種類が自分の price を自分のクラスに持つ新種は「Role を with したクラス」を一枚足すだけ。既存コードは無変更(OCP)
合計処理が全種類を見分ける責任を背負っている子に ->price を聞いて委譲する呼び出し側は型を知らない。「いくらだ」と聞くだけ

工程

Step 1: 共通の顔(Component)を Role で定義する

単品も集合も共通で持つべき操作を Moo::Role で宣言する。requires で「この Role を with するクラスは、必ずこのメソッドを持つこと」を強制する。

1
2
3
package MenuComponent;
use Moo::Role;
requires 'price';

Step 2: 末端(Leaf)を作る

子を持たない単品。price を属性で持ち、その値がそのまま答えになる。has pricewith より先に置くこと(アクセサが with の時点で必要なため)。

1
2
3
4
5
package SingleItem;
use Moo;
has name  => (is => 'ro', required => 1);
has price => (is => 'ro', required => 1);
with 'MenuComponent';

Step 3: 集合(Composite)を作る

子のリストを持ち、price は子の ->price を足すだけにする。ここで子の型を見ない——これが入れ子に強い理由だ。add のような子を管理するメソッドは、集合の側だけに置く(単品に add を持たせない)。

1
2
3
4
5
6
7
package Course;
use Moo;
with 'MenuComponent';
has name     => (is => 'ro', required => 1);
has children => (is => 'ro', default => sub { [] });
sub add   { my ($s, $c) = @_; push $s->children->@*, $c; return $s; }
sub price { my $s = shift; my $sum = 0; $sum += $_->price for $s->children->@*; return $sum; }

Step 4: 呼び出し側は ->price を呼ぶだけにする

単品か集合かを if で見分けない。同じ ->price で、単品でも入れ子でも合計が出る。

Step 5: 新しい種類はクラスを一枚足すだけで拡張する

割引セットのような新種は、MenuComponentwith(または Courseextends)した新しいクラスを足すだけ。Course も呼び出し側も変更しない。

シェフより

賢い total を一つ作って、全部の場合をそこで見分けようとすると、種類や入れ子が増えるたびに、その一か所がどんどん太っていく。どこかで必ず追いつかなくなる。

逆だ。料理のそれぞれに、「自分はいくらか」を自分で答えさせろ。器は、中身が何かを知らなくていい。ただ「いくらだ」と聞いて、返ってきた数を足す。中身がまた器なら、そいつが同じことをするだけだ。これなら、何段深くなっても、新しい料理が増えても、聞き方は変わらない。

一つ正直に言っておく。if がこの世から消えるわけじゃない。「今日はどの料理を作るか」を決めて器に盛り付けるとき——どのクラスを new するか——の判断は、組み立てる場所に残る。消えたのは、でき上がった料理をたどって合計する処理の中の、種類による場合分けだ。それが各料理の側に分かれて、たどる側は型を意識しなくなった。場合分けの居場所が変わった——それが、今日の仕込みだ。

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