Featured image of post コードシェフの仕込み帳【Strategy】闇鍋の割引計算〜注文ごとにレシピを差し替える〜

コードシェフの仕込み帳【Strategy】闇鍋の割引計算〜注文ごとにレシピを差し替える〜

巨大なif/elsifに膨らんだ割引計算を、Strategyパターンで『割引ごとの小鍋』に分けます。PerlとMooで、会計の本体を変えずに割引を追加できる設計へ。なぜ条件分岐が整理されるのかを仕組みから丁寧に解説します。

「動いてはいるんです。でも、もう触るのが怖くて」

そう言って、ノートパソコンを抱えたお客さんがコード食堂の扉を開けました。私はこの店で見習いとして働く、駆け出しのエンジニアです。包丁の研ぎ方もコードの読み方も、まだ自信がありません。今日はシェフが、膨らみすぎた割引計算の「闇鍋」を、どう仕立て直すのかを見せてもらいます。

この記事で学ぶこと

この記事は、if/elsif が増えすぎて手がつけられなくなった処理を、Strategyパターンで整理する話です。題材はある定食屋さんの「割引計算」。Perlのコードを少しずつ仕立て直していきます。

学ぶことひとことで言うと
Strategyパターン処理の手順を、後から差し替えられる部品にする技法
ConcreteStrategy と Context個別の手順(小鍋)と、それを使う側(会計)
開放閉鎖原則(OCP)既存をいじらず、追加だけで機能を増やせる状態
Moo::Role での実装Perlで「この約束を必ず持て」という型紙を作る方法
パターンの使いどき「手順」を持つなら有効、固定値だけなら過剰になる

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

  • PerlとMooの基本(hasnew)がなんとなく分かる
  • if/elsif が増えて、コードを直すのが怖くなった経験がある
  • デザインパターンという言葉は聞くが、まだ自分の道具になっていない

技術スタックは Perl / Moo / Types::Standard です。コードはすべて手元で動かし、テストが通ることを確認しています。なお本文中のモジュールは要点を抜き出して示しているため、実際にファイルへ保存するときは Perl の作法として末尾に 1; を加えてください。

食堂への来客

夕方の仕込みがひと段落して、私が換気扇の下で大根を洗っていたときでした。同僚に聞いて来た、というそのお客さんは、椅子に座るなり早口で事情を話し始めました。

聞けば、知り合いが営む定食屋「まる福」に頼まれて、会計を手伝う小さなPerlスクリプトを書いたのだそうです。最初は「ランチタイムは10%引き」というだけの、簡単なものでした。それが評判になって、「ゴールド会員にも割引を」「雨の日サービスを」「クーポンも」と頼まれるたびに、ひとつのメソッドに条件を足し続けた。そして先日、新しいクーポンを足したときに条件の順番を間違え、お会計を間違えてしまった——というのです。

「動いてはいるんです。でも、どこを触ると何が壊れるのか、もう自分でも分からなくて」

お客さんは申し訳なさそうに、画面を見せてくれました。私にはコードのことはよく分かりません。でも「ひとつの鍋に、頼まれるたびに調味料を足していった」と聞いて、まかないでうっかり作りすぎた、味のまとまらない寸胴を思い浮かべました。

シェフは手を拭きながら、黙って画面に目をやりました。

仕込みの失敗を嗅ぐ

シェフは画面を上から下まで、ゆっくり一度だけ読みました。料理人が、出された皿を一口だけ味見するときのような目つきでした。

問題のコードは、こういうものでした。

 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
package Cafe::Bill;
use Moo;
use Types::Standard qw(Num Str);

has subtotal      => (is => 'ro', isa => Num, required => 1);
has hour          => (is => 'ro', isa => Num, default => sub { 15 });
has customer_rank => (is => 'ro', isa => Str, default => sub { 'none' });
has coupon        => (is => 'ro', isa => Str, default => sub { '' });

sub total {
    my $self = shift;
    my $sub  = $self->subtotal;
    my $discount = 0;

    if ($self->hour >= 11 && $self->hour < 14) {   # ランチ割引 10%
        $discount = $sub * 0.10;
    }
    elsif ($self->customer_rank eq 'gold') {        # ゴールド 15%(ただし上限800円)
        $discount = $sub * 0.15;
        $discount = 800 if $discount > 800;
    }
    elsif ($self->customer_rank eq 'silver') {      # シルバー 5%
        $discount = $sub * 0.05;
    }
    elsif ($self->coupon eq 'RAINY') {              # 雨の日 300円引き
        $discount = 300;
    }

    return $sub - $discount;
}

「ああ、旨味はちゃんと出てる。動くんだろう、これで」

シェフは最初にそう言いました。否定から入らないのは、この人がいつもそうするところです。それから、画面の total という部分を指でとんとんと叩いて、続けました。

「だがな、これは足し算しかできん鍋だ。引き算ができん」

私には意味が分かりませんでした。お客さんも「引き算、ですか」と聞き返します。シェフはこう言いました。

「割引をひとつ増やすたびに、お前はこの一番大事な鍋——total ——を毎回かき混ぜてるだろう。先週味が崩れた(バグが出た)のは、新しい味を足したときに、前の味の順番が狂ったからだ」

これが、プログラミングでいう switch-statements(条件分岐の肥大化)という状態でした。ひとつのメソッドの中で if/elsif がどんどん育ち、別々の割引の計算が一か所に押し込められている。シェフの言葉を借りれば、「上限を計算する手順(ゴールド会員の『15%だが800円まで』)」と「ただの固定額(雨の日の300円)」が、同じ鍋の中で混ざっているのです。

「最初はこれで十分だと思っていたんです」とお客さんはうつむきました。

私は、なんとなく分かった気がして、こう口に出してみました。「ひとつの寸胴に全部入れちゃうと……新しい味を足すたびに、前の味まで変わっちゃう、ってことですか?」

シェフは「そういうことだ」とだけ言って、コンロの火を止めました。

包丁を入れ直す

ここからが、シェフの仕事でした。

シェフは大きな寸胴の前に、小さな鍋をいくつも並べました。そして、玉杓子で中身を一杯ずつ、別々の小鍋に移し替えていきます。「割引ひとつひとつを、それぞれの小鍋に移す」と言いながら。

コードの上では、こういう順番で進みました。

まず、「割引とはこういうものだ」という共通の約束を決めます。Perlでは Moo::Role という仕組みで、「この約束(メソッド)を必ず持っていろ」という型紙を作れます。

1
2
3
4
5
# Discount/Strategy.pm
package Discount::Strategy;
use Moo::Role;

requires 'amount';   # 「金額を受け取って、割引額を返す」メソッドを必ず持つこと

この Discount::Strategy が、パターンの名前のもとになっている Strategy(戦略) です。Strategyパターンとは、ひとことで言えば「処理の手順を、後から差し替えられる部品にする技法」のこと。ここでは「割引の計算法」がその手順にあたります。

次に、割引ひとつひとつを、この約束を守る独立した部品にします。こうした個別の部品を ConcreteStrategy(具体的な戦略) と呼びます。小鍋たちです。

1
2
3
4
5
6
7
8
9
# Discount/Lunch.pm
package Discount::Lunch;
use Moo;
with 'Discount::Strategy';     # 共通の約束を守る

sub amount {
    my ($self, $subtotal) = @_;
    return $subtotal * 0.10;
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# Discount/Gold.pm
package Discount::Gold;         # 15%だが、上限は800円
use Moo;
with 'Discount::Strategy';

sub amount {
    my ($self, $subtotal) = @_;
    my $d = $subtotal * 0.15;
    return $d > 800 ? 800 : $d;   # 上限の「手順」が、この小鍋の中だけに収まる
}
1
2
3
4
5
6
7
8
9
# Discount/Rainy.pm
package Discount::Rainy;        # 雨の日は300円固定
use Moo;
with 'Discount::Strategy';

sub amount {
    my ($self, $subtotal) = @_;
    return 300;
}

シルバー会員(5%)や、割引なし(0円)も、同じように小さな部品にします。割引なしは「何もしない割引」として Discount::None を用意しておくと、あとで扱いが楽になります。

そして、お会計の本体です。これがパターンでいう Context(文脈・使う側) にあたります。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
# Cafe/Bill.pm
package Cafe::Bill;
use Moo;
use Types::Standard qw(Num ConsumerOf);

has subtotal => (is => 'ro', isa => Num, required => 1);
has discount => (is => 'ro', isa => ConsumerOf['Discount::Strategy'], required => 1);

sub total {
    my $self = shift;
    return $self->subtotal - $self->discount->amount($self->subtotal);
}

total を見てください。さっきまで if/elsif でぎゅうぎゅうだった会計の本体が、たった一行になりました。会計は「渡された割引(小鍋)に、金額を渡して、割引額を聞く」だけ。どの割引がどう計算されるかを、もう知りません。

discount の型に書いた ConsumerOf['Discount::Strategy'] は、「Discount::Strategy の約束を守った部品しか受け取らない」という意味です。うっかり関係ないものを渡すと、その場でエラーになって教えてくれます。

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

	classDiagram
    class DiscountStrategy {
        <<role>>
        +amount(subtotal)
    }
    class CafeBill {
        +subtotal
        +discount
        +total()
    }
    class DiscountLunch {
        +amount(subtotal)
    }
    class DiscountGold {
        +amount(subtotal)
    }
    class DiscountRainy {
        +amount(subtotal)
    }
    DiscountStrategy <|.. DiscountLunch
    DiscountStrategy <|.. DiscountGold
    DiscountStrategy <|.. DiscountRainy
    CafeBill --> DiscountStrategy

(図の DiscountStrategyDiscount::StrategyCafeBillCafe::Bill のことです。)

ここで、お客さんが顔を上げて、鋭い質問をしました。

「でも……結局、どの割引を使うか選ぶ if は、どこかに残りますよね? だったら、意味あるんですか?」

私はどきっとしました。たしかに、ランチタイムかどうか、ゴールド会員かどうかを判断する処理は、消えてなくなったわけではないはずです。実際、それは「選ぶ係」として一か所にまとめてあります。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
# Cafe/DiscountSelector.pm
package Cafe::DiscountSelector;
use Discount::Lunch;
use Discount::Gold;
use Discount::Silver;
use Discount::Rainy;
use Discount::None;

sub choose {
    my ($class, %order) = @_;
    return Discount::Lunch->new
        if defined $order{hour} && $order{hour} >= 11 && $order{hour} < 14;
    return Discount::Gold->new   if ($order{rank}   // '') eq 'gold';
    return Discount::Silver->new if ($order{rank}   // '') eq 'silver';
    return Discount::Rainy->new  if ($order{coupon} // '') eq 'RAINY';
    return Discount::None->new;
}

シェフは、包丁を置いてからお客さんに向き直りました。私はこの答えを、メモに書き留めました。

「残るさ。客がランチタイムに来たのか、会員なのか——それを見て割引を選ぶ仕事は、どうしたって要る。だがな、お前が今後増やしたいのは『割引の種類』だろう。種類が増えても、この選ぶ係に一行足すだけだ。味付け——つまり計算のしかたは、それぞれの小鍋の中に閉じてる」

そしてこう続けました。

「前は鍋がひとつだったから、新しい味を足すたびに鍋全体の味が変わって、どこが壊れるか分からなかった。先週のバグはそれだ。今は、会計の本体(total)はもう何があっても変わらん。割引の計算同士も、互いに混ざらん」

ここでシェフが言っていたのが、開放閉鎖原則(OCP) という考え方でした。「既存のコードをいじらず、追加だけで機能を増やせる状態」を指します。total には手を入れず、新しい小鍋を足すだけで割引を増やせる。これがその姿です。

私は、ようやく腑に落ちました。「大きな鍋だと、新しい調味料を入れたら前の味も変わっちゃう。でも小鍋なら……その小鍋だけ味見すればいい、ってことですね」

シェフは小さくうなずきました。

ひとつ、シェフが付け加えたことがあります。「雨の日の300円みたいに、ただの決まった額なら、正直こんな部品にしなくても、メモ書き(数表)で足りる。だが、ゴールド会員の『15%だが800円まで』みたいに“手順”があるものは、小鍋に閉じ込めとくのが効く」。割引が全部ただの固定額なら、このパターンはむしろ大げさになる。「手順を持つかどうか」が分かれ目なのだと、私は理解しました。

試食合格、そして次の一皿

仕立て直したコードが、ちゃんと前と同じお会計になるか。シェフは「味見だ」と言って、テストを走らせました。試食して、ちゃんと火が通っているかを確かめるのと同じです。

  • ランチタイムに1,000円 → 100円引きで900円
  • ゴールド会員が10,000円 → 15%は1,500円だが、上限の800円引きで9,200円
  • 雨の日に1,000円 → 300円引きで700円

どれも、前のコードと寸分違わぬ金額でした。

それから、シェフはお客さんにこう言いました。「学割を足してみろ」。

お客さんは少し身構えてから、新しい小鍋をひとつ作りました。

1
2
3
4
5
6
7
8
9
# Discount/Student.pm (新しく足す小鍋)
package Discount::Student;
use Moo;
with 'Discount::Strategy';

sub amount {
    my ($self, $subtotal) = @_;
    return $subtotal * 0.12;
}

あとは、選ぶ係(DiscountSelector)に一行だけ足します。

1
2
# Cafe/DiscountSelector.pm の choose に、この一行を加えるだけ
    return Discount::Student->new if ($order{coupon} // '') eq 'STUDENT';

そして——Cafe::Bill には、指一本触れませんでした。会計の本体も、ほかの割引も、まったく変えていないのに、学割はちゃんと動きました。増えたのは「小鍋ひとつ」と「選ぶ係の一行」だけ。これが、さっきの「追加だけで増やせる(OCP)」が本当だったという証拠です。

お客さんは、ほっとしたように見えました。「これなら……また何か頼まれても、怖くないです」と、自分の言葉で確かめるように言いました。

シェフは盛り付けを終えた皿を下げるみたいに、静かにこう言いました。

「これで、何種類増えても会計は荒れん。味は、それぞれの小鍋が持つ」


シェフの仕込み工程表

今日の「料理」を振り返ります。あなたのコードに同じ匂いがしたら、同じ手順で仕立て直せます。

問題(調理ミス)技法(パターン)効果(仕上がり)
ひとつの total に全割引の if/elsif が肥大化(switch-statements)Strategyパターン割引の計算が小鍋(部品)に分かれ、互いに混ざらない
割引を足すたびに会計の本体を編集して壊すContext は割引の中身を知らず、約束(amount)だけ呼ぶ会計の本体(total)が、何が増えても不変になる
「上限の手順」と「固定額」が同じ鍋で混在ConcreteStrategy ごとに手順を閉じ込めるゴールドの上限ロジックが Gold の中だけに収まる
新しい割引の追加が怖い開放閉鎖原則(OCP)小鍋を1つ足し、選ぶ係に1行足すだけ。既存は無傷

工程

  1. 共通の約束を決めるMoo::Rolerequires 'amount'; を宣言し、「金額を受け取り割引額を返す」型紙を作る
  2. 割引を小鍋に分ける:割引ごとに with 'Discount::Strategy' した小さなクラスを作り、計算を amount に移す
  3. 会計は受け取るだけにするCafe::Bill は割引を ConsumerOf['Discount::Strategy'] として受け取り、total では amount を呼ぶだけにする
  4. 選ぶ係を一か所に集める:「どの割引を使うか」の判断は DiscountSelector のような入口にまとめる。ここは残るが、計算とは切り離す
  5. 足して確かめる:新しい割引は小鍋を1つ足すだけ。会計の本体を変えずに動くことをテストで確認する

シェフより

割引が固定額ばかりなら、こんな小鍋に分けるのはやりすぎだ。数表ひとつで足りる。だが「上限がある」「条件で計算が変わる」みたいに、割引が“手順”を持ち始めたら、小鍋に分けてやれ。手順は、手順ごとに閉じ込めるのが一番崩れにくい。

それと、ひとつ言っておく。今日のは「客が選ぶ割引」の話だ。「状況によって勝手に切り替わる」やつは、また別の技法(Stateパターン)の領分だ。それはまた別の日に仕込もう。

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