Featured image of post コードシェフの仕込み帳【Decorator】包んで一味足す〜トッピングのたびにフラグが増えるコードを、積み重ね可能な部品で整理する〜

コードシェフの仕込み帳【Decorator】包んで一味足す〜トッピングのたびにフラグが増えるコードを、積み重ね可能な部品で整理する〜

トッピング追加のたびにcostとdescribeの2か所修正が必要になるコードを、Decoratorパターンで整理します。PerlとMooで修正箇所を1クラスに同居させ、既存コードを変えずに追加できる設計へ。仕組みから丁寧に解説します。

「包んで重ねれば、中身は変えなくていい」

それはシェフの独り言だった。

私はこの「コード食堂」で見習いとして働く、駆け出しのエンジニアです。ランチ営業が終わって、私がカウンターの端で洗い物を片付けていたとき、厨房の奥でシェフが試作のラーメンと向き合っていました。

何をしているのだろうと横目で見ると、チャーシューを一枚——メンマをひとつまみ——海苔を一枚、と丁寧にトッピングを重ねています。一枚乗るたびに、一杯の見た目が変わっていく。「包んで、また包んで——」という言葉が頭に浮かびました。料理の所作としては当たり前のことでしょうが、今日のシェフの手つきは何かをたしかめるような、ゆっくりした動作でした。

「包んで重ねれば、中身は変えなくていい」

シェフが小さな声で言いました。何に向かって言っているのかわかりません。私はメモ帳を探す仕草をしましたが、手が塞がっていたので、そのまま頭の中に入れておくことにしました。

そこへ、引き戸が開きました。

この記事で学ぶこと

この記事は、「トッピングを追加するたびに costdescribe の2か所を修正しなければならず、片方を直し忘れるバグが起きた」という問題を、Decoratorパターンで整理する話です。修正箇所を1つのクラスに同居させ、既存コードを変えずに追加できる構造にすることで、なぜ直し忘れが起きにくくなるのかを仕組みから解説します。

学ぶことひとことで言うと
Decoratorパターン基本オブジェクトを同じインターフェースの部品で「包み」、動的に振る舞いを積み増す技法
Component(Moo::Role)デコレータと基本オブジェクトが共通して持つインターフェース(今回は costdescribe
ConcreteComponent装飾される基本オブジェクト(今回は BasicRamen
ConcreteDecorator包む側の部品(ChashuTopping など)。inner に対象を持ち、委譲しながら上乗せする
combinatorial-explosion(組合せ爆発)機能の組合せごとにフラグやサブクラスが増殖し、修正箇所が散在して漏れを生む状態

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

  • PerlとMooの基本(hasnewextendswith)がなんとなく分かる
  • フラグを使って機能のオン/オフを管理していて、追加のたびに複数の場所を直すのが不安に感じている
  • 第4作のTemplate Methodパターン、第5作のFactory Methodパターンを読んで、パターンで設計を整理する感覚に触れている

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

トッピングを一枚ずつ

入ってきたのは、さばさばした様子の若い男性でした。

「飲み会で聞きまして。コードの相談ができるって。ちょっと整理したいことがあって来ました」と言いながら、躊躇なくカウンター席に座りました。料理屋でコードの相談という不思議さには最初からあまり引っかかっていないようで、ノートパソコンをすでに開いています。

シェフが手を拭きながら出てきました。「何持ち込んだ」

「注文システムのトッピング管理です」と彼は言いました。「飲食店向けのシステムを作っていて——ラーメンのトッピングをフラグで管理してるんですけど、先週ちょっとやらかしまして」

聞けば、彼は飲食店向け注文管理システムのバックエンドを担当しているエンジニアで、メニューのカスタマイズ機能を作っているのだそうです。ラーメンに「チャーシュー追加」「メンマ追加」「大盛」などのトッピングを選べる仕組みを、has でフラグを持たせる形で実装したと言いました。

彼はコードを見せてくれました。

 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
package RamenOrder;
use Moo;
use v5.36;

has has_chashu => (is => 'ro', default => 0);
has has_menma  => (is => 'ro', default => 0);
has is_large   => (is => 'ro', default => 0);  # ← 先週追加

sub cost {
    my $self = shift;
    my $c = 800;
    $c += 200 if $self->has_chashu;
    $c += 100 if $self->has_menma;
    $c += 150 if $self->is_large;   # ← 先週追加した
    return $c;
}

sub describe {
    my $self = shift;
    my $d = "基本ラーメン";
    $d .= "・チャーシュー追加" if $self->has_chashu;
    $d .= "・メンマ追加"       if $self->has_menma;
    # ← is_large の describe を直し忘れた。「・大盛」が出ない
    return $d;
}

「先週、大盛を追加したんです。cost$c += 150 if $self->is_large を書いて、動作確認もして、リリースした。そしたら2日後に、お客様サポートに問い合わせが来て——料金に大盛分が含まれているのに、注文確認メールに「大盛」の記載がないって」

describe の方に $d .= "・大盛" if $self->is_large を書くのを忘れてた。cost を直した時点で"追加できた"って思ってしまって」

彼は苦笑しながら言いました。怒っているわけでも、落ち込んでいるわけでもない。「次回から気をつけろって話ではあるんですけど——なんかやり方がある気がして来ました」と、整理したい気持ちで来ている感じでした。

私はタオルを畳みながら聞いていました。コードの中身はよくわかりません。でも「先週追加した部分を2か所直すのを忘れた」という言葉を聞いて、内心でざわっとするものがありました。

第4作のあの女性——税率の計算を3か所に書いていて、1か所直し忘れた。第5作の彼——通知の処理を3つのサービスクラスに書いていて、1か所追加し忘れた。今回は costdescribe の2か所。また直し忘れの話だ。違うコード、違う人、でも問題の形が同じに見える。

そのことは口に出さずに、聞き続けました。

二か所ある修正場所

シェフは RamenOrder のコードをしばらく眺めてから、cost のメソッドと describe のメソッドを縦に並べました。

cost にフラグが3つ。describe にフラグが2つ」

それだけ言いました。それから続けました。

「トッピングを追加するとき、costdescribe を両方直さないといけない。1か所でも忘れると、価格と説明が食い違う」

combinatorial-explosion(組合せ爆発)——機能の組合せごとにクラスやフラグが増殖し、修正箇所が散在して漏れを生む状態のことです。

シェフが続けました。

「フラグをやめてサブクラスで全部表現するとどうなる——チャーシューラーメン、メンマラーメン、大盛ラーメン、チャーシュー大盛ラーメン、メンマ大盛ラーメン……。3種類のトッピングなら、組合せは2の3乗で8クラスだ。4種類になれば16、5種類で32。トッピングが1つ増えるたびにクラスが倍になる」

「フラグのまま増やしていけば——クラスは増えないが、今みたいに costdescribe を別々のメソッドで管理する形のまま膨らんでいく。直す場所が、トッピングが増えるたびに増え続ける」

サブクラスにすれば数が爆発し、フラグのままでは修正箇所が散在し続ける——どちらの道も、今のままでは逃げ場がない。

「最初はチャーシューとメンマだけで、ちゃんと2か所ずつ書いてたんです」と彼は言いました。「大盛を追加したとき、cost を書いた時点で"終わった"って思ってしまって。describe の存在を忘れてた」

シェフが聞きました。「次に海苔を追加するとき、同じことをやる?」

「……また2か所直します」と彼は答えました。少し間があって「そのたびに忘れるリスクがある、ということですね」と、自分で言って少し苦笑いしました。

包んで一味足す

シェフが「構造を変える」と言って、コードを書き始めました。

厨房の奥に置いてある、さっきトッピングを重ねていたラーメンの試作品を指差しながら言いました。

「これ、どうやってチャーシューを足したと思う? チャーシューを追加した新しいラーメンを一から作ったわけじゃない。元のラーメンの上に、チャーシューを載せた——それだけだ。ラーメン本体は変えていない」


まず、共通のインターフェースを定義します。

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

requires 'cost', 'describe';

Component(Moo::Role)——デコレータと基本オブジェクトが共通して持つインターフェースです。requires 'cost', 'describe' と書くことで、このRoleを with したクラスはかならず costdescribe を実装しなければなりません。with の時点——クラスが読み込まれるときに未実装が検出されてエラーになります(ep4の die とは異なり、呼び出し時ではなくクラスロード時に検出されます)。


次に、基本の一皿を作ります。

1
2
3
4
5
6
7
package BasicRamen;
use Moo;
use v5.36;
with 'Dish';

sub cost     { 800 }
sub describe { "基本ラーメン" }

ConcreteComponent——装飾される基本オブジェクトです。素の一杯。価格は800円、説明は「基本ラーメン」だけです。


そして、包む側の土台を作ります。

1
2
3
4
5
6
7
8
9
package Topping;
use Moo;
use v5.36;
with 'Dish';

has inner => (is => 'ro', required => 1);  # 内側のオブジェクトを保持

sub cost     { $_[0]->inner->cost }
sub describe { $_[0]->inner->describe }

Topping(基底 Decorator)——inner に包む対象を持ち、costdescribe を内側に委譲するだけです。このクラス単体では「何も足さない」。足す動作は、これを継承した各トッピングクラスが担います。


各トッピングクラスを作ります。

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

sub cost     { $_[0]->inner->cost + 200 }
sub describe { $_[0]->inner->describe . "・チャーシュー追加" }
1
2
3
4
5
6
7
package MenmaTopping;
use Moo;
use v5.36;
extends 'Topping';

sub cost     { $_[0]->inner->cost + 100 }
sub describe { $_[0]->inner->describe . "・メンマ追加" }
1
2
3
4
5
6
7
package LargeSize;
use Moo;
use v5.36;
extends 'Topping';

sub cost     { $_[0]->inner->cost + 150 }
sub describe { $_[0]->inner->describe . "・大盛" }

ConcreteDecorator——extends 'Topping' して、costdescribe を上書きします。cost は内側の価格に自分の加算を足して返し、describe は内側の説明に自分の文字列を繋げて返します。costdescribe が同じクラスの中に一緒に書かれています

重ね掛けするときは、new をネストするだけです。

1
2
3
4
5
6
7
8
# チャーシュー追加・大盛
my $order = LargeSize->new(
    inner => ChashuTopping->new(
        inner => BasicRamen->new
    )
);
say $order->describe;  # 基本ラーメン・チャーシュー追加・大盛
say $order->cost;      # 1150(800 + 200 + 150)

	classDiagram
    class Dish {
        <<Moo::Role>>
        +cost()
        +describe()
    }
    class BasicRamen {
        +cost() 800
        +describe() "基本ラーメン"
    }
    class Topping {
        +inner
        +cost()
        +describe()
    }
    class ChashuTopping {
        +cost() inner.cost + 200
        +describe() inner.describe + "・チャーシュー追加"
    }
    class MenmaTopping {
        +cost() inner.cost + 100
        +describe() inner.describe + "・メンマ追加"
    }
    class LargeSize {
        +cost() inner.cost + 150
        +describe() inner.describe + "・大盛"
    }
    Dish <|.. BasicRamen : with
    Dish <|.. Topping : with
    Topping <|-- ChashuTopping : extends
    Topping <|-- MenmaTopping : extends
    Topping <|-- LargeSize : extends
    Topping o-- Dish : inner

Decorator——基本オブジェクトを同じインターフェースの部品で「包み」、動的に振る舞いを積み増す技法、と定義されています。BasicRamenChashuToppingLargeSize も、すべて Dish Role を持っています。「包んでも、インターフェースは変わらない」——これがDecoratorの核心です。

なぜ良くなったんですか?

彼は After コードをしばらく眺めてから、言いました。

「なるほど——costdescribeLargeSize の中に一緒に入ってる、というのはわかります。でも——これって、フラグがクラスに変わっただけじゃないですか? むしろファイルが増えましたよね。何が良くなったんですか?」

真剣な問いでした。批判ではなく、本当にわからない、という顔です。

私も同じことを考えていました。今回は、少し考えてから口を開くことにしました。

「あの……確認なんですが、Before だと、大盛を追加したとき costdescribe 両方に is_large の行を書きますよね。After だと——LargeSize クラスに costdescribe が両方書いてあります。それって、“2か所に分けて書く"のが"1か所に一緒に書く"になった、ということですか?」

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

彼が「あ」と言いました。

シェフが続けました。

「Before では——大盛を追加するとき、cost に1行書いて、describe に1行書く。別のメソッドに、別々に。1か所でも忘れると、今回みたいなことになる。After では——LargeSize クラスを1つ書けばいい。その中に costdescribe が一緒に入っている。書き忘れる場所が、2か所から1か所になった。構造的に」

「次に海苔を追加するなら——NoriTopping クラスを1つ書く。BasicRamenChashuTopping も変えない。新しいクラスの中に costdescribe を書く。書き忘れる場所が1か所しかない。1クラスを書いたか、書いていないか——それだけだ」

「ファイルは増えた。でも、修正漏れが起きる構造が消えた」

彼はしばらく考えてから「……なるほど。Before では、大盛のための情報が costdescribe という2つのメソッドに散っていた。After では、LargeSize というクラスに集まっている」と自分の言葉でまとめました。


シェフが「ただし」と続けました。正直なところも話す口調でした。

「包む順番で結果が変わるケースがある。今回は価格の単純な足し算だから順番は関係ない。でも——“大盛のときはチャーシューが100円引き"みたいな依存があれば、どちらを外側にするかで計算が変わる。包む順番の設計には気をつけろ」

それから、もう一つ。

「ラップすると、元の型が見えにくくなる」

1
2
3
my $order = LargeSize->new(inner => BasicRamen->new);
say $order->isa('BasicRamen');    # false ← これはラーメンの注文か?が素直に確認できない
say $order->does('Dish');         # true  ← Dish Role を持つことは確認できる

$order->isa('BasicRamen') は false になる。外側から見ると LargeSize だ。“これはラーメンの注文か?“という確認が、継承関係で素直にはできない。Roleレベル——does('Dish') は true なので、インターフェースの確認には使える。ただし、型の情報が包まれた内側に隠れることは覚えておけ」

「それと——誰かがどのトッピングをどう重ねるかを決めなければならない。注文を受け付けるときに"チャーシュー追加・大盛"という選択を組み立てる処理は、どこかに残る。Decoratorは"組み立てる処理"を消すわけじゃない。“トッピングを追加するたびに既存クラスを変える"という構造を変える技法だ」


ここで彼が少し首を傾けて言いました。

「前の2回——Template Method と Factory Method は、extends で継承してましたよね。今回も extends 'Topping' してますけど、なんか違う気がして」

シェフが「そうだ」と答えました。

「Template Method は骨格を継承で固定して、変わる中身だけサブクラスに任せた。Factory Method は生成の判断を継承で委譲した。今回は違う——extends 'Topping' はしているが、本質は"包む"ことだ。inner に持って、委譲して、上乗せする。継承は便宜上使っているが、核は合成だ」

一言でまとめると——Template Method と Factory Method は"何かを継ぐ"技法。Decorator は"包んで足す"技法、ということです。

試食合格

「では試してみましょう」と彼は言いながらキーボードを打ちました。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# 基本ラーメン単体
my $basic = BasicRamen->new;
say $basic->describe;   # 基本ラーメン
say $basic->cost;       # 800

# チャーシュー追加・大盛
my $order = LargeSize->new(
    inner => ChashuTopping->new(inner => BasicRamen->new)
);
say $order->describe;   # 基本ラーメン・チャーシュー追加・大盛
say $order->cost;       # 1150

正しく動きました。describe にも「大盛」が出ています。

それから、新しいトッピングを追加してみました。

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

sub cost     { $_[0]->inner->cost + 100 }
sub describe { $_[0]->inner->describe . "・味玉追加" }

TamagoTopping クラスを1つ書きました。それだけです。BasicRamenChashuToppingLargeSize も変えていません。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
my $tamago = TamagoTopping->new(inner => BasicRamen->new);
say $tamago->describe;   # 基本ラーメン・味玉追加
say $tamago->cost;       # 900

# チャーシュー + 味玉
my $combo = TamagoTopping->new(
    inner => ChashuTopping->new(inner => BasicRamen->new)
);
say $combo->describe;    # 基本ラーメン・チャーシュー追加・味玉追加
say $combo->cost;        # 1100

動きました。既存のクラスに1行も追加していません。

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

「包んで重ねれば、中身は変えなくていい」

I幕の独り言と、同じ言葉でした。

彼は少し考えてから、「なるほど——海苔と辛味と替え玉も追加する予定があるんで、これ使います」と言いました。さばさばした、決めた人の顔でした。


シェフの仕込み工程表

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

問題(調理ミス)技法(パターン)効果(仕上がり)
costdescribe が別メソッドに分かれており、トッピング追加のたびに2か所を修正しなければならない(combinatorial-explosion)Decoratorパターン各トッピングクラスに costdescribe が同居し、修正箇所が1か所になる
1か所修正し忘れると価格と説明が食い違うinner に持って委譲しながら上乗せ書き忘れる場所が1クラスに集約される。クラスを書いたかどうかだけが問われる
新しいトッピングを追加するたびに既存クラスを変更しなければならないComponent(Moo::Role)で共通インターフェースを定義新しいトッピングは新しいクラスを追加するだけ。既存コードは変えない(OCP)

工程

  1. Component Role を定義する: Moo::Rolerequires で、すべてのクラスが持つべきメソッド(costdescribe など)を宣言する
  2. ConcreteComponent を実装する: with 'Component' して、基本の動作を実装する(今回は BasicRamen の価格と説明)
  3. 基底 Decorator を作る: with 'Component' し、innerhas で持つ。costdescribeinner に委譲するだけにする
  4. ConcreteDecorator を実装する: extends '基底Decorator' して、costdescribe を上書きする。各メソッドで inner に委譲してから自分の追加分を足す
  5. 重ね掛けは new のネストで表現する: Outer->new(inner => Inner->new(inner => Base->new)) の形で組み立てる。誰がどう組み立てるかは呼び出し側が決める

シェフより

包んで重ねる——これだけのことだ。基本の一皿を変えずに、その上に何かを重ねる。重ねた側が「自分の仕事だけ」を持って、下に委ねる。それがDecoratorという調理技法の核心だ。

正直に言っておく。この技法にも正直な留保がある。包む順番が重要になるケースがある。包んだ後は外から元の型が見えにくくなる。そして「何を包むか」を決める処理——注文を組み立てる場所——はどこかに残る。Decoratorが消すのは「修正箇所が複数メソッドに散在すること」だ。追加のたびに既存コードを変えなくて済む構造を作る。それ以上ではない。


彼が帰ってから、私は洗い物の続きをしながら今日のことを思い返しました。

「包んで重ねれば、中身は変えなくていい」——I幕でシェフが言った言葉が、IV幕でそのまま返ってきた。あのとき何の話をしていたのか、最初はわからなかった。でも今は少しだけわかる気がします。

それからふと、先週自分が書いたコードのことが頭に浮かびました。配送料の計算を担当しているやつ。確か、label という表示文字列と fee という金額を別々に計算する if 文が2か所あって——

もしかして、同じかも。

確認するのは今日ではありません。洗い物が残っているし、もう夜です。でも、その問いが浮かんだことに、私は少し驚いていました。

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