Featured image of post コードシェフの仕込み帳【Template Method】仕込み帳を三冊書くな〜コピペした手順の修正漏れを、親クラスの骨格で一本化する〜

コードシェフの仕込み帳【Template Method】仕込み帳を三冊書くな〜コピペした手順の修正漏れを、親クラスの骨格で一本化する〜

日報生成クラス3種が同じ骨格をコピペで持ち、修正漏れが起きるコードを、Template Methodパターンで整理します。PerlとMooで骨格を親クラスに一本化し、変わる部分だけサブクラスに任せる設計へ。仕組みから丁寧に解説します。

三枚のカードが、ほぼ同じ内容で並んでいた。

私はこの「コード食堂」で見習いとして働く、駆け出しのエンジニアです。ランチの営業が終わり、静まり返った店内で、私は調理台の脇にある引き出しを開けて、翌日の仕込みに使うレシピカードを整理していました。

ファイルボックスから取り出したカードを順に並べていくと、三枚のカードがほぼ同じ工程で書かれていることに気づきました。「下ごしらえ→だし取り→煮込み→仕上げ」という手順が、大根の煮物・牛蒡の煮物・里芋の煮物、三種の料理でまったく同じだったのです。素材だけが違う。

私は少し迷ってから、まな板に向かうシェフの背中に声をかけました。

「あの……これ、同じ仕込み手順が三枚に書いてありますよね。一枚にまとめられないんですか?」

自分から口を開いたのは少し緊張しましたが、ずっと気になっていたことでした。

シェフは包丁の手を止めずに、こちらを見もせず答えました。

「そうしたいなら、骨格を一枚書いて、素材だけ別にしろ」

それだけです。説明はありません。私はよく分からないままメモ帳に「骨格を一枚に。素材を別に。」と書き留めました。どういう意味だろうと思いながら。そこへ、引き戸が静かに開く音がしました。

この記事で学ぶこと

この記事は、「日報生成クラスを三種類コピペで作ったら、修正漏れが起きた」という問題を、Template Methodパターンで整理する話です。骨格を親クラスに一本化し、変わる部分だけをサブクラスに任せることで、なぜ修正漏れが起きにくくなるのかを仕組みから解説します。

学ぶことひとことで言うと
Template Methodパターン手順の骨格を親クラスに1つ書き、変わるステップだけをサブクラスで差し替える技法
Primitive Operations「このメソッドは必ず実装してください」という約束(未実装なら実行時エラー)
Hook Methodデフォルトの実装があり、サブクラスが必要なら上書きできるメソッド
duplicated-code(コピペ重複)同じ処理が複数の場所に書かれ、修正が散在して漏れを生む状態
Template MethodとStrategyの違い継承で骨格を固定する(Template Method)vs 合成でアルゴリズムを差し替える(Strategy)

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

  • PerlとMooの基本(hasnewextends)がなんとなく分かる
  • 似たクラスをコピペで作って、修正するたびに全部直しているのが怖い、と感じている
  • 第1作のStrategyパターンを読んで、パターンで設計を整理する感覚に興味を持っている

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

仕込みメモが三枚あった

入ってきたのは、三十代前半の女性でした。「お時間いただけますか」と穏やかに一言言って、シェフに向かいました。

聞けば、食材の卸業者向けに日報を生成する小さなシステムを受託で作っているのだそうです。日次の売上レポート、週次の在庫レポート、月次のスタッフコストレポート——三種類。最初は日次だけ作って、頼まれるたびにコードをコピーして、必要な部分だけ書き換えた。動くのは動く。

ところが先月、軽減税率の適用変更で、出力の計算式を修正することになりました。三種類あるので三か所直す必要があって、日次と週次は直した。でも、月次だけ見落とした。発注者から「月次レポートの数字がおかしい」と連絡が来たのは、一ヶ月後のことでした。

「コピペで作ったのが問題だとは、わかってるんですけど。どう整理すればいいかが——」

彼女はそこで少し言葉を切って、ノートパソコンをカウンターに開きました。

コードを見せてもらいました。日次売上レポートのクラスから始まっています。

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

sub generate {
    my $self  = shift;
    my $data  = $self->_fetch_data();
    my $total = $self->_aggregate($data);
    return sprintf '%s: %d円(税率%d%%込み)', '日次売上', int($total * 1.10), 10;
}

sub _fetch_data  { ... }
sub _aggregate   {
    my ($self, $data) = @_;
    my $s = 0; $s += $_ for @$data; $s
}

続いて、週次在庫レポートです。

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

sub generate {    # ← 骨格をコピペ。税率計算が2か所目になった
    my $self  = shift;
    my $data  = $self->_fetch_data();
    my $total = $self->_aggregate($data);
    return sprintf '%s: %d円(税率%d%%込み)', '週次在庫', int($total * 1.10), 10;
}

sub _fetch_data  { ... }
sub _aggregate   {    # ← 同じ実装がまたここにある
    my ($self, $data) = @_;
    my $s = 0; $s += $_ for @$data; $s
}

月次スタッフコストレポートも、まったく同じ構造です。generate_fetch_data_aggregate の三つのメソッドが、ラベルの文字列だけを変えてそのままコピーされています。

三冊の仕込み帳

シェフは画面をしばらく眺めてから、紙を三枚取り出して、カウンターの上に並べました。

generateが三つある」

一文だけ言って、三枚の紙を横に並べ、同じ部分を鉛筆で丸で囲み始めました。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
# DailySalesReport::generate
my $data  = $self->_fetch_data();
my $total = $self->_aggregate($data);
return sprintf '...', int($total * 1.10), 10;

# WeeklyInventoryReport::generate
my $data  = $self->_fetch_data();
my $total = $self->_aggregate($data);
return sprintf '...', int($total * 1.10), 10;

# MonthlyStaffReport::generate
my $data  = $self->_fetch_data();
my $total = $self->_aggregate($data);
return sprintf '...', int($total * 1.10), 10;  # ↑ 税率を変えるなら、この3か所すべてを直す。1か所でも忘れれば、そこだけ古いまま残る

「骨格が三つある。int($total * 1.10) という税率計算も三か所にある。三か所直さないといけない仕込み帳を、三冊作ってしまっている」

コピペ重複(duplicated-code)——これが問題の名前です。同じ処理が複数の場所に書かれ、修正のたびに全部を直す必要がある状態のことです。一か所でも見落とせば、そこだけ古いコードが残り続けます。

彼女は少し間を置いてから言いました。「最初は一種類だけだったんです。頼まれるたびに複製して、違う部分だけ書き換えて……気づいたら三冊になってました」

私はカードを手に持ったまま聞いていました。コードを読む力は私にはまだありません。でも「三か所に同じものが書いてある」という言葉を聞いて、思わず言いました。

「あの……さっき、同じ仕込み手順が三枚のカードに書いてあって、まとめられないかって聞いたんですが——これって、同じことですよね?」

言ってしまってから、少し後悔しました。正しいかどうかわからない。シェフが一瞬だけこちらを見ました。彼女もカウンター越しに私を見た。

「まあ、そうだ」とシェフは短く返しました。

合っていたのかどうか、私はまだわかりませんでした。でも、その「まあ」に、少しだけほっとしました。

骨格は一枚で足りる

シェフはそこで手を動かし始めました。

「骨格を親クラスに一本書く。変わる部分だけをサブクラスに任せる」

まず、新しいクラスを書き始めました。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
package Report;
use Moo;
use v5.36;

# Template Method(骨格)
sub generate {
    my $self  = shift;
    my $data  = $self->fetch_data();
    my $total = $self->aggregate($data);
    return $self->output($total);
}

generate は骨格だけです。「何をするか」の順番——データを取って、集計して、出力する——それだけを書いています。中身の詳細は書いていない。

次に、各ステップを定義しました。

1
2
3
4
5
6
7
8
9
# Primitive Operations(必ず実装してください)
sub fetch_data {
    my $self = shift;
    die ref($self) . ': fetch_data を実装してください';
}
sub label {
    my $self = shift;
    die ref($self) . ': label を実装してください';
}

Primitive Operations——「このメソッドは必ず実装してください」という約束です。Perlには抽象メソッドという仕組みはありませんが、die を呼ぶことで「実装し忘れた場合に実行時エラーで知らせる」形にできます。

それから、こう続けました。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# 共通の集計処理(全クラスで同じだから、親クラスが持つ)
sub aggregate {
    my ($self, $data) = @_;
    my $s = 0; $s += $_ for @$data; $s
}

# Hook Method(デフォルト出力)← 税率はここ1か所
sub output {
    my ($self, $total) = @_;
    return sprintf '%s: %d円(税率%d%%込み)', $self->label, int($total * 1.10), 10;
}

Hook Method——デフォルトの実装があり、サブクラスが必要なら上書きできるメソッドのことです。output はデフォルトで「ラベル:合計円(税率10%込み)」という形式で出力します。三種類のレポートはどれもこの形式でよかったので、サブクラスでは実装不要です。

そして、aggregate(合計処理)も同様です。元のコードでは _aggregate という同じ実装が三つのクラスに書かれていました。どれも「リストの合計を返す」だけで実装がまったく同一だったので、親クラスに引き上げました。これも Template Method がもたらす恩恵の一つで、「骨格と同じ処理」もまとめて親に集められます。

親クラスはこれで完成です。子クラスはずっと短くなりました。

1
2
3
4
5
6
7
package DailySalesReport;
use Moo;
use v5.36;
extends 'Report';

sub label      { '日次売上' }
sub fetch_data { ... }  # 日次の売上データを返す実装
1
2
3
4
5
6
7
package WeeklyInventoryReport;
use Moo;
use v5.36;
extends 'Report';

sub label      { '週次在庫' }
sub fetch_data { ... }  # 週次の在庫データを返す実装
1
2
3
4
5
6
7
package MonthlyStaffReport;
use Moo;
use v5.36;
extends 'Report';

sub label      { '月次スタッフ' }
sub fetch_data { ... }  # 月次のスタッフコストデータを返す実装

Template Method——手順の骨格を親クラスに1つ書き、変わるステップだけをサブクラスで差し替える技法、と定義されています。三種類のレポートはすべて「データを取得して、集計して、出力する」という骨格が同じ。変わるのは「どのデータを取得するか」と「何というラベルで出すか」だけです。だから fetch_datalabel だけサブクラスに任せ、他はすべて親が持ちます。

変わる部分だけを別紙に

彼女はコードを眺めながら、静かに言いました。

「でも——骨格を親クラスにまとめても、子クラスは三つあるままですよね。fetch_datalabel も三か所にある。修正するときは結局、三クラス全部を確認しないといけないんじゃないですか? コピペと、何が違うんですか?」

核心を突いてきました。シェフは手を止めて、彼女の方を向きました。

「確かに、fetch_datalabel は三か所ある。それは変わらん——中身が本当に違うんだから当然だ。だが——税率の計算は今、どこにある?」

彼女は画面に視線を戻しました。

output……一か所、です」

「そうだ。以前は三か所にあった。int($total * 1.10) という行が、三クラスの generate にそれぞれ書かれていた。三か所直さなければならなかった。一か所でも見落とすと、そこだけ古い税率で動き続けた。骨格が一か所になったから——今は一か所だけ直せばいい。直し忘れる場所が、構造的に存在しなくなった」

私は、さっきシェフがカウンターに並べた三枚のレシピカードを思い出しました。同じ仕込み手順が三枚に書いてあった。「骨格を一枚書いて、素材だけ別にしろ」。シェフが言ったのは、そういうことだったのか。骨格は一枚——そこを直せば、全部に効く。変わる素材は、別の紙に書けばいい。


ここで、彼女がもう一つ聞きました。

「以前、割引計算をStrategyパターンで直してもらった方がいて、そのコードを読んだんですが——あれとは違うんですか?」

シェフは少し考えてから答えました。

「Strategyは、“どのアルゴリズムを使うか"をオブジェクトにして外から差し込む。合成だ。実行時に差し替えられる。割引計算は、会員ランクやクーポンに応じて"どの割引を選ぶか"が実行時に変わる——だからStrategyが合う」

「こっちは違う。日次レポートを作るとき、“今日は週次の方法で集計しよう"とはならないだろう。処理の骨格は変わらない。変わるのは、どのデータを取ってくるかだけだ。継承で骨格を親に固定して、変わる中身だけ子に任せる。実行時に差し替えるんじゃなく、最初から"このクラスはこう動く"と決まってる」

一言でまとめると——Strategyは"今日は何を使うか"を実行時に選ぶ。Template Methodは"手順の骨格は変わらない、中身だけ違う"を継承で表す、ということです。

私はそれを自分なりに翻訳してみました。

「Strategyは"今日はどの小鍋で煮るか"をその日に選ぶ感じで、Template Methodは"煮る手順はどの料理も同じで、素材だけ違う”——ということですか?」

シェフが短く「だいたいそんなとこだ」と返しました。

完全に正確かどうか、私には自信がありません。でも、輪郭は掴めた気がしました。

試食合格

「では、試してみましょう」と、彼女はキーボードを打ち始めました。三種類のレポートを呼び出して、それぞれの出力を確認します。

1
2
3
4
5
6
7
8
print DailySalesReport->new->generate, "\n";
# 日次売上: 4950円(税率10%込み)

print WeeklyInventoryReport->new->generate, "\n";
# 週次在庫: 8800円(税率10%込み)

print MonthlyStaffReport->new->generate, "\n";
# 月次スタッフ: 418000円(税率10%込み)

正しく動きました。

「では、もし来月また税率が変わったら」と、シェフが続けました。「Reportoutput を直してみろ」

彼女は Report::output1.101.08 に変えました。三クラスのどれも手を加えていません。それだけで——

1
2
3
4
5
6
7
8
print DailySalesReport->new->generate, "\n";
# 日次売上: 4860円(税率8%込み)

print WeeklyInventoryReport->new->generate, "\n";
# 週次在庫: 8640円(税率8%込み)

print MonthlyStaffReport->new->generate, "\n";
# 月次スタッフ: 410400円(税率8%込み)

三クラスすべて、同時に新しい税率で出力されました。一か所だけ直したのに。

それから、もう一つ試しました。新しいレポートの種類——四半期の総括レポート——を追加してみます。

1
2
3
4
5
6
7
8
package QuarterlyReport;
use Moo;
use v5.36;
extends 'Report';

sub label      { '四半期総括' }
sub fetch_data { ... }
# aggregate も output も、親クラスのものをそのまま使う

extends 'Report' して、labelfetch_data を書くだけです。Report クラスは一行も変えていません。それでも generate を呼べば、骨格通りに動きます。

これが開放閉鎖原則(OCP)——「既存のコードを変えずに、追加だけで機能を増やせる状態」です。

シェフが一言で締めました。

「骨格が一冊になった。次に税率が変わっても、直す場所は一か所だ」

彼女はしばらく画面を見てから、静かに言いました。

「……コピペが怖かったのは、直す場所が増えることだったんですね。骨格が一か所なら、増えない」

自分の言葉で整理しているようでした。腑に落ちた人の顔をしていました。

シェフは最後に付け加えました。

「ただし——骨格を変えたら、全サブクラスに影響する。それが継承の重さだ。骨格の設計を間違えると、今度は親クラスが爆弾になる。慎重に設計しろ」


シェフの仕込み工程表

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

問題(調理ミス)技法(パターン)効果(仕上がり)
同じ骨格が複数のクラスにコピペされ、修正箇所が散在する(duplicated-code)Template Methodパターン骨格が親クラスに一本化され、修正は一か所で全サブクラスに効く
税率変更を三か所に直す必要があり、一か所見落として誤出力骨格(generate)と共通処理(aggregate/output)を親クラスに持たせる直し忘れる場所が構造的に存在しなくなる
新しいレポート種の追加のたびに、コピペとセットで骨格が増えるextends で子クラスを追加。変わる部分(fetch_data・label)だけ実装親クラスを変えずに追加できる(OCP)

工程

  1. 親クラスにテンプレートメソッドを書く: generate メソッドで「データ取得→集計→出力」の骨格だけを定義する。中身はまだ書かない
  2. 変わらない処理を親クラスに引き上げる: 全サブクラスで同じ実装になっているメソッド(今回は aggregate)は、親クラスにまとめる
  3. Primitive Operationsを定義する: サブクラスで必ず実装が必要なメソッドに die を書いて、実装漏れを実行時エラーで知らせるようにする(Perlは動的型付けなので、エラーは呼び出し時に起きる)
  4. Hook Methodを用意する: デフォルトで動くメソッド(今回は output)は親クラスに実装しておく。サブクラスで変えたい場合だけ上書きする
  5. サブクラスは変わる部分だけ実装する: extends '親クラス名' して、Primitive Operationsだけを実装する。共通の骨格や処理には触れない

シェフより

仕込み帳を三冊書くな。同じ手順を三か所に書いたとき、その三か所はいつか必ず足並みが揃わなくなる。一か所だけ直して、残りを忘れる。それが「コピペ重複」という調理ミスの中身だ。

骨格を一枚にまとめれば、修正は一か所で済む。直し忘れる場所が、そもそも存在しなくなる——それだけのことだ。

ただし、正直に言っておく。骨格を変えたら全サブクラスに波及する。親クラスが「一か所直せば全部に効く」ということは、「一か所間違えたら全部に影響する」ということでもある。骨格は変えにくく、慎重に設計しなければならない。これが継承の重さだ。軽く見るな。


仕込み帳を閉じながら、私はさっきのシェフの言葉を思い返しました。「骨格を変えたら、全サブクラスに影響する」。

直す場所が一か所になった代わりに、一か所の影響が全クラスに及ぶようになった。骨格が爆弾になる、とシェフは言った。それって——逆に怖くないのか。骨格の設計を間違えたとき、どうやって直すんだろう。

その問いを口に出す前に、彼女はもう帰っていました。

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