「動いてはいるんです。でも、もう触るのが怖くて」
そう言って、ノートパソコンを抱えたお客さんがコード食堂の扉を開けました。私はこの店で見習いとして働く、駆け出しのエンジニアです。包丁の研ぎ方もコードの読み方も、まだ自信がありません。今日はシェフが、膨らみすぎた割引計算の「闇鍋」を、どう仕立て直すのかを見せてもらいます。
この記事で学ぶこと
この記事は、if/elsif が増えすぎて手がつけられなくなった処理を、Strategyパターンで整理する話です。題材はある定食屋さんの「割引計算」。Perlのコードを少しずつ仕立て直していきます。
| 学ぶこと | ひとことで言うと |
|---|---|
| Strategyパターン | 処理の手順を、後から差し替えられる部品にする技法 |
| ConcreteStrategy と Context | 個別の手順(小鍋)と、それを使う側(会計) |
| 開放閉鎖原則(OCP) | 既存をいじらず、追加だけで機能を増やせる状態 |
| Moo::Role での実装 | Perlで「この約束を必ず持て」という型紙を作る方法 |
| パターンの使いどき | 「手順」を持つなら有効、固定値だけなら過剰になる |
対象読者は、次のような人を想定しています。
- PerlとMooの基本(
has、new)がなんとなく分かる if/elsifが増えて、コードを直すのが怖くなった経験がある- デザインパターンという言葉は聞くが、まだ自分の道具になっていない
技術スタックは Perl / Moo / Types::Standard です。コードはすべて手元で動かし、テストが通ることを確認しています。なお本文中のモジュールは要点を抜き出して示しているため、実際にファイルへ保存するときは Perl の作法として末尾に 1; を加えてください。
食堂への来客
夕方の仕込みがひと段落して、私が換気扇の下で大根を洗っていたときでした。同僚に聞いて来た、というそのお客さんは、椅子に座るなり早口で事情を話し始めました。
聞けば、知り合いが営む定食屋「まる福」に頼まれて、会計を手伝う小さなPerlスクリプトを書いたのだそうです。最初は「ランチタイムは10%引き」というだけの、簡単なものでした。それが評判になって、「ゴールド会員にも割引を」「雨の日サービスを」「クーポンも」と頼まれるたびに、ひとつのメソッドに条件を足し続けた。そして先日、新しいクーポンを足したときに条件の順番を間違え、お会計を間違えてしまった——というのです。
「動いてはいるんです。でも、どこを触ると何が壊れるのか、もう自分でも分からなくて」
お客さんは申し訳なさそうに、画面を見せてくれました。私にはコードのことはよく分かりません。でも「ひとつの鍋に、頼まれるたびに調味料を足していった」と聞いて、まかないでうっかり作りすぎた、味のまとまらない寸胴を思い浮かべました。
シェフは手を拭きながら、黙って画面に目をやりました。
仕込みの失敗を嗅ぐ
シェフは画面を上から下まで、ゆっくり一度だけ読みました。料理人が、出された皿を一口だけ味見するときのような目つきでした。
問題のコードは、こういうものでした。
| |
「ああ、旨味はちゃんと出てる。動くんだろう、これで」
シェフは最初にそう言いました。否定から入らないのは、この人がいつもそうするところです。それから、画面の total という部分を指でとんとんと叩いて、続けました。
「だがな、これは足し算しかできん鍋だ。引き算ができん」
私には意味が分かりませんでした。お客さんも「引き算、ですか」と聞き返します。シェフはこう言いました。
「割引をひとつ増やすたびに、お前はこの一番大事な鍋——total ——を毎回かき混ぜてるだろう。先週味が崩れた(バグが出た)のは、新しい味を足したときに、前の味の順番が狂ったからだ」
これが、プログラミングでいう switch-statements(条件分岐の肥大化)という状態でした。ひとつのメソッドの中で if/elsif がどんどん育ち、別々の割引の計算が一か所に押し込められている。シェフの言葉を借りれば、「上限を計算する手順(ゴールド会員の『15%だが800円まで』)」と「ただの固定額(雨の日の300円)」が、同じ鍋の中で混ざっているのです。
「最初はこれで十分だと思っていたんです」とお客さんはうつむきました。
私は、なんとなく分かった気がして、こう口に出してみました。「ひとつの寸胴に全部入れちゃうと……新しい味を足すたびに、前の味まで変わっちゃう、ってことですか?」
シェフは「そういうことだ」とだけ言って、コンロの火を止めました。
包丁を入れ直す
ここからが、シェフの仕事でした。
シェフは大きな寸胴の前に、小さな鍋をいくつも並べました。そして、玉杓子で中身を一杯ずつ、別々の小鍋に移し替えていきます。「割引ひとつひとつを、それぞれの小鍋に移す」と言いながら。
コードの上では、こういう順番で進みました。
まず、「割引とはこういうものだ」という共通の約束を決めます。Perlでは Moo::Role という仕組みで、「この約束(メソッド)を必ず持っていろ」という型紙を作れます。
| |
この Discount::Strategy が、パターンの名前のもとになっている Strategy(戦略) です。Strategyパターンとは、ひとことで言えば「処理の手順を、後から差し替えられる部品にする技法」のこと。ここでは「割引の計算法」がその手順にあたります。
次に、割引ひとつひとつを、この約束を守る独立した部品にします。こうした個別の部品を ConcreteStrategy(具体的な戦略) と呼びます。小鍋たちです。
| |
| |
| |
シルバー会員(5%)や、割引なし(0円)も、同じように小さな部品にします。割引なしは「何もしない割引」として Discount::None を用意しておくと、あとで扱いが楽になります。
そして、お会計の本体です。これがパターンでいう Context(文脈・使う側) にあたります。
| |
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
(図の DiscountStrategy は Discount::Strategy、CafeBill は Cafe::Bill のことです。)
ここで、お客さんが顔を上げて、鋭い質問をしました。
「でも……結局、どの割引を使うか選ぶ if は、どこかに残りますよね? だったら、意味あるんですか?」
私はどきっとしました。たしかに、ランチタイムかどうか、ゴールド会員かどうかを判断する処理は、消えてなくなったわけではないはずです。実際、それは「選ぶ係」として一か所にまとめてあります。
| |
シェフは、包丁を置いてからお客さんに向き直りました。私はこの答えを、メモに書き留めました。
「残るさ。客がランチタイムに来たのか、会員なのか——それを見て割引を選ぶ仕事は、どうしたって要る。だがな、お前が今後増やしたいのは『割引の種類』だろう。種類が増えても、この選ぶ係に一行足すだけだ。味付け——つまり計算のしかたは、それぞれの小鍋の中に閉じてる」
そしてこう続けました。
「前は鍋がひとつだったから、新しい味を足すたびに鍋全体の味が変わって、どこが壊れるか分からなかった。先週のバグはそれだ。今は、会計の本体(total)はもう何があっても変わらん。割引の計算同士も、互いに混ざらん」
ここでシェフが言っていたのが、開放閉鎖原則(OCP) という考え方でした。「既存のコードをいじらず、追加だけで機能を増やせる状態」を指します。total には手を入れず、新しい小鍋を足すだけで割引を増やせる。これがその姿です。
私は、ようやく腑に落ちました。「大きな鍋だと、新しい調味料を入れたら前の味も変わっちゃう。でも小鍋なら……その小鍋だけ味見すればいい、ってことですね」
シェフは小さくうなずきました。
ひとつ、シェフが付け加えたことがあります。「雨の日の300円みたいに、ただの決まった額なら、正直こんな部品にしなくても、メモ書き(数表)で足りる。だが、ゴールド会員の『15%だが800円まで』みたいに“手順”があるものは、小鍋に閉じ込めとくのが効く」。割引が全部ただの固定額なら、このパターンはむしろ大げさになる。「手順を持つかどうか」が分かれ目なのだと、私は理解しました。
試食合格、そして次の一皿
仕立て直したコードが、ちゃんと前と同じお会計になるか。シェフは「味見だ」と言って、テストを走らせました。試食して、ちゃんと火が通っているかを確かめるのと同じです。
- ランチタイムに1,000円 → 100円引きで900円
- ゴールド会員が10,000円 → 15%は1,500円だが、上限の800円引きで9,200円
- 雨の日に1,000円 → 300円引きで700円
どれも、前のコードと寸分違わぬ金額でした。
それから、シェフはお客さんにこう言いました。「学割を足してみろ」。
お客さんは少し身構えてから、新しい小鍋をひとつ作りました。
| |
あとは、選ぶ係(DiscountSelector)に一行だけ足します。
| |
そして——Cafe::Bill には、指一本触れませんでした。会計の本体も、ほかの割引も、まったく変えていないのに、学割はちゃんと動きました。増えたのは「小鍋ひとつ」と「選ぶ係の一行」だけ。これが、さっきの「追加だけで増やせる(OCP)」が本当だったという証拠です。
お客さんは、ほっとしたように見えました。「これなら……また何か頼まれても、怖くないです」と、自分の言葉で確かめるように言いました。
シェフは盛り付けを終えた皿を下げるみたいに、静かにこう言いました。
「これで、何種類増えても会計は荒れん。味は、それぞれの小鍋が持つ」
シェフの仕込み工程表
今日の「料理」を振り返ります。あなたのコードに同じ匂いがしたら、同じ手順で仕立て直せます。
| 問題(調理ミス) | 技法(パターン) | 効果(仕上がり) |
|---|---|---|
ひとつの total に全割引の if/elsif が肥大化(switch-statements) | Strategyパターン | 割引の計算が小鍋(部品)に分かれ、互いに混ざらない |
| 割引を足すたびに会計の本体を編集して壊す | Context は割引の中身を知らず、約束(amount)だけ呼ぶ | 会計の本体(total)が、何が増えても不変になる |
| 「上限の手順」と「固定額」が同じ鍋で混在 | ConcreteStrategy ごとに手順を閉じ込める | ゴールドの上限ロジックが Gold の中だけに収まる |
| 新しい割引の追加が怖い | 開放閉鎖原則(OCP) | 小鍋を1つ足し、選ぶ係に1行足すだけ。既存は無傷 |
工程
- 共通の約束を決める:
Moo::Roleでrequires 'amount';を宣言し、「金額を受け取り割引額を返す」型紙を作る - 割引を小鍋に分ける:割引ごとに
with 'Discount::Strategy'した小さなクラスを作り、計算をamountに移す - 会計は受け取るだけにする:
Cafe::Billは割引をConsumerOf['Discount::Strategy']として受け取り、totalではamountを呼ぶだけにする - 選ぶ係を一か所に集める:「どの割引を使うか」の判断は
DiscountSelectorのような入口にまとめる。ここは残るが、計算とは切り離す - 足して確かめる:新しい割引は小鍋を1つ足すだけ。会計の本体を変えずに動くことをテストで確認する
シェフより
割引が固定額ばかりなら、こんな小鍋に分けるのはやりすぎだ。数表ひとつで足りる。だが「上限がある」「条件で計算が変わる」みたいに、割引が“手順”を持ち始めたら、小鍋に分けてやれ。手順は、手順ごとに閉じ込めるのが一番崩れにくい。
それと、ひとつ言っておく。今日のは「客が選ぶ割引」の話だ。「状況によって勝手に切り替わる」やつは、また別の技法(Stateパターン)の領分だ。それはまた別の日に仕込もう。
