🐡

TypeScriptで理解するStrategy パターン

2023/11/06に公開

Strategyパターンとは

Strategyパターンとは、アルゴリズム(戦略)を表すインターフェースを定義して、
そのインターフェースを実装したクラスを作成し、
具体的な処理を簡単に切り替えることができる形で、
クラスを設計するデザインパターンです。

Strategy パターンの登場人物

  • Strategy(戦略)
    Strategyは、戦略を表すためのインターフェースです。
    このインターフェースに基づいて、具体的な戦略を実装することで、Strategyを呼び出すクライアント側では簡単に、異なるアルゴリズムを切り替えることができます。

  • ConcreteStrategy(具体的な戦略)
    ConcreteStrategyは、Strategyインターフェースを実装したクラスであり、具体的な戦略を表します。
    アルゴリズムの具体的な振る舞いを追加/変更したい場合は、新たなConcreteStrategyを追加/変更します。

  • Context(文脈)
    Contextは、Strategyインターフェースを利用して、具体的な戦略を利用するクラスです。
    Strategyインターフェースを通して、具体処理を実行するため、ConcreteStrategyの詳細を知る必要がありません。

Strategyパターンのクラス図とサンプルコード

interface Strategy {
    strategyMethod(): void;
}

class ConcreteStrategyA implements Strategy {
    strategyMethod(): void {
        console.log('ConcreteStrategyA');
    }
}

class ConcreteStrategyB implements Strategy {
    strategyMethod(): void {
        console.log('ConcreteStrategyB');
    }
}

class Context {
    private strategy: Strategy;

    constructor(strategy: Strategy) {
        this.strategy = strategy;
    }

    public contextMethod(): void {
        this.strategy.strategyMethod();
    }
}

実行例

const contextA = new Context(new ConcreteStrategyA());
contextA.contextMethod();
// => 'ConcreteStrategyA'

const contextB = new Context(new ConcreteStrategyB());
contextB.contextMethod();
// => 'ConcreteStrategyB'

Strategyパターンの使用例

ECサイトの料金計算の例を考えてみます。
ECサイトでは、会員の種類によって、料金の割引が存在したり、送料が異なるケースがあります。
今回は次のような料金計算の例を考えてみます。

  • 通常会員
    • 送料: 買い物の合計金額が10,000円未満の場合、500円、10,000円以上の場合、無料
    • 割引: なし
  • プレミアム会員
    • 送料: 無料
    • 割引: 買い物料金の5%

まずは、Strategyパターンを利用しない場合の実装を見てみます。

type Plan = "Regular" | "Premium";

class Member {
    private SHIPPING_FEE_FOR_REGULAR = 500;
    private SHIPPING_FREE_THRESHOLD_FOR_REGULAR = 10000;

    private DISCOUNT_RATE_FOR_PREMIUM = 0.05;
    private plan: Plan

    constructor(plan: Plan) {
        this.plan = plan; 
    }

    calculateFee(basePrice: number): number {
        switch (this.plan) {
            case "Regular":
                return this.calculateRegularFee(basePrice);
            case "Premium":
                return this.calculatePremiumFee(basePrice);
        }
    }

    private calculateRegularFee(basePrice: number): number {
        const priceAfterDiscount = this.applyDiscountForRegular(basePrice);
        const finalPrice = priceAfterDiscount + this.calculateShippingFeeForRegular(priceAfterDiscount);
        return finalPrice;
    }

    private applyDiscountForRegular(basePrice: number): number {
        return basePrice;
    }

    private calculateShippingFeeForRegular(basePrice: number): number {
        return basePrice < this.SHIPPING_FREE_THRESHOLD_FOR_REGULAR ? this.SHIPPING_FEE_FOR_REGULAR : 0;
    }

    private calculatePremiumFee(basePrice: number): number {
        const priceAfterDiscount = this.applyDiscountForPremium(basePrice);
        const finalPrice = priceAfterDiscount + this.calculateShippingFeeForPremium();
        return finalPrice;
    }

    private applyDiscountForPremium(basePrice: number): number {
        return basePrice * (1 - this.DISCOUNT_RATE_FOR_PREMIUM);
    }

    private calculateShippingFeeForPremium(): number {
        return 0;
    }
}

// 使用例
// 通常会員が9000円の買い物をした時
const regularMember = new Member("Regular");
console.log(regularMember.calculateFee(9000));
// => 9500

// プレミアム会員が9000円の買い物をした時
const premiumMember = new Member("Premium");
console.log(premiumMember.calculateFee(9000));
// => 8550

calculateFee()メソッドの中で、会員の種類に応じて、料金計算の処理を分岐しています。
今回はその中身を別メソッドとして切り出しています。
このような形だと、新しい会員が追加されるたびにswitch~case文を修正する必要があります。
今はプランが2つだけですが、それだけでも冗長なコードになっています。

上記はかなり極端な例ですが、今度は、プランごとにクラスを分けてみます。

class RegularMember {
    private SHIPPING_FEE = 500;
    private SHIPPING_FREE_THRESHOLD = 10000;

    calculateFee(basePrice: number): number {
      const priceAfterDiscount = this.applyDiscount(basePrice);
      const finalPrice = priceAfterDiscount + this.calculateShippingFee(priceAfterDiscount);
      return finalPrice;
    }

    private calculateShippingFee(basePrice: number): number {
      return basePrice < this.SHIPPING_FREE_THRESHOLD ? this.SHIPPING_FEE : 0;
    }
  
    private applyDiscount(basePrice: number): number {
      return basePrice;
    }
}

class PremiumMember {
    private DISCOUNT_RATE = 0.05;

    calculateFee(basePrice: number): number {
      const priceAfterDiscount = this.applyDiscount(basePrice);
      const finalPrice = priceAfterDiscount + this.calculateShippingFee(priceAfterDiscount);
      return finalPrice;
    }

    private calculateShippingFee(basePrice: number): number {
        return 0;
    }
  
    private applyDiscount(basePrice: number): number {
      return basePrice * (1 - this.DISCOUNT_RATE);
    }
}

// 使用例
// 通常会員が9000円の買い物をした時
const regularMember = new RegularMember();
console.log(regularMember.calculateFee(9000));
// => 9500

// プレミアム会員が9000円の買い物をした時
const premiumMember = new PremiumMember();
console.log(premiumMember.calculateFee(9000));
// => 8550

通常会員用の料金計算を行うRegularMemberクラスと、プレミアム会員用の料金計算を行うPremiumMemberクラスを実装しています。
calculateFee()メソッドの中身はどちらのクラスも同じように実装しています。

先ほどの例よりはだいぶ整理されたと思いますが、
新たな会員プランが追加された場合や、料金計算の仕様が変更された場合、実装や修正のコストが高くなりますし、
最悪対応漏れが発生する可能性もあります。

具体的に、下記のような用件が追加されるとしましょう。

  • プレミアムプラス会員であれば、送料無料で、かつ、買い物料金の10%の割引を適用する。
  • 期間限定で、プランに関わらず割引後料金にさらに20%の割引を適用する。

このような場合先ほどのコードを下記のように修正することになります。

class RegularMember {
    private SHIPPING_FEE = 500;
    private SHIPPING_FREE_THRESHOLD = 10000;
    private CAMPAIGN_DISCOUNT_RATE = 0.1;

    calculateFee(basePrice: number): number {
      let priceAfterDiscount = this.applyDiscount(basePrice);

      if (this.isCampaign()) priceAfterDiscount = this.campaignDiscount(priceAfterDiscount);
      const finalPrice = priceAfterDiscount + this.calculateShippingFee(priceAfterDiscount);
      return finalPrice;
    }

    private calculateShippingFee(basePrice: number): number {
        return basePrice < this.SHIPPING_FREE_THRESHOLD ? this.SHIPPING_FEE : 0;
    }
  
    private applyDiscount(basePrice: number): number {
        return basePrice;
    }

    private isCampaign(): boolean {
        return Date.now() >= new Date("2023-10-01 00:00:00").getTime() && Date.now() <= new Date("2023-11-30 23:59:59").getTime()
    }

    private campaignDiscount(basePrice: number): number {
        return basePrice * (1 - this.CAMPAIGN_DISCOUNT_RATE);
    }
}

class PremiumMember {
    private DISCOUNT_RATE = 0.05;
    private CAMPAIGN_DISCOUNT_RATE = 0.1;

    calculateFee(basePrice: number): number {
        let priceAfterDiscount = this.applyDiscount(basePrice);

        if (this.isCampaign()) priceAfterDiscount = this.campaignDiscount(priceAfterDiscount);
        const finalPrice = priceAfterDiscount + this.calculateShippingFee(priceAfterDiscount);
        return finalPrice;
    }

    private calculateShippingFee(basePrice: number): number {
        return 0;
    }
  
    private applyDiscount(basePrice: number): number {
        return basePrice * (1 - this.DISCOUNT_RATE);
    }

    private isCampaign(): boolean {
        return Date.now() >= new Date("2023-10-01 00:00:00").getTime() && Date.now() <= new Date("2023-11-30 23:59:59").getTime()
    }

    private campaignDiscount(basePrice: number): number {
        return basePrice * (1 - this.CAMPAIGN_DISCOUNT_RATE);
    }
}

class PremiumPlusMember {
    private DISCOUNT_RATE = 0.1;
    private CAMPAIGN_DISCOUNT_RATE = 0.1;

    calculateFee(basePrice: number): number {
        let priceAfterDiscount = this.applyDiscount(basePrice);

        if (this.isCampaign()) priceAfterDiscount = this.campaignDiscount(priceAfterDiscount);
        const finalPrice = priceAfterDiscount + this.calculateShippingFee(priceAfterDiscount);
        return finalPrice;
    }

    private calculateShippingFee(basePrice: number): number {
        return 0;
    }

    private isCampaign(): boolean {
        return Date.now() >= new Date("2023-10-01 00:00:00").getTime() && Date.now() <= new Date("2023-11-30 23:59:59").getTime()
    }
  
    private applyDiscount(basePrice: number): number {
        return basePrice * (1 - this.DISCOUNT_RATE);
    }

    private campaignDiscount(basePrice: number): number {
        return basePrice * (1 - this.CAMPAIGN_DISCOUNT_RATE);
    }
}

// 使用例
// 通常会員が9000円の買い物をした時
const regularMember = new RegularMember();
console.log(regularMember.calculateFee(9000));
// => 8600

// プレミアム会員が9000円の買い物をした時
const premiumMember = new PremiumMember();
console.log(premiumMember.calculateFee(9000));
// => 7695

// プレミアムプラス会員が9000円の買い物をした時
const premiumPlusMember = new PremiumPlusMember();
console.log(premiumPlusMember.calculateFee(9000));
// => 7290

一気に冗長さが増したと思います。
料金算出フローに、キャンペーン期間中だった場合に、割引を適用する処理を追加しています。
クラス数が増えるほど、料金フローの変更も困難になります。
複数キャンペーン適用時の料金計算を実装する場合は、さらに複雑になります。

このような料金計算をStrategyパターンを用いて実装してみます。
まず、最初の使用を満たしたコードは下記のようになります。

interface MembershipFeeStrategy {
    calculateShippingFee: (basePrice: number) => number;
    applyDiscount: (basePrice: number) => number;
}
  
class RegularMemberFee implements MembershipFeeStrategy {
    private SHIPPING_FEE = 500;
    private SHIPPING_FREE_THRESHOLD = 10000;

    calculateShippingFee(basePrice: number): number {
      return basePrice < this.SHIPPING_FREE_THRESHOLD ? this.SHIPPING_FEE : 0;
    }
  
    applyDiscount(basePrice: number): number {
      return basePrice;
    }
}
  
class PremiumMemberFee implements MembershipFeeStrategy {
    private DISCOUNT_RATE = 0.05;

    calculateShippingFee(basePrice: number): number {
        return 0;
    }
  
    applyDiscount(basePrice: number): number {
      return basePrice * (1 - this.DISCOUNT_RATE);
    }
}
  
class FeeContext {
    private strategy: MembershipFeeStrategy;
  
    constructor(strategy: MembershipFeeStrategy) {
      this.strategy = strategy;
    }
  
    calculateFee(basePrice: number): number {
      const priceAfterDiscount = this.strategy.applyDiscount(basePrice);
      const finalPrice = priceAfterDiscount + this.strategy.calculateShippingFee(priceAfterDiscount);
      return finalPrice;
    }
}

// 使用例
// 通常会員が9000円の買い物をした時
const regularContext = new FeeContext(new RegularMemberFee());
console.log(regularContext.calculateFee(9000));
// => 9500

// プレミアム会員が9000円の買い物をした時
const premiumContext = new FeeContext(new PremiumMemberFee());
console.log(premiumContext.calculateFee(9000));
// => 8550

MembershipFeeStrategyは、会員の種類によって異なる料金計算の戦略を表すインターフェースです。
これがStrategyパターンのStrategyに相当します。

このインターフェースをもとに、具体的な戦略を実装しているのがRegularMemberFeeとPremiumMemberFeeです。
これらがStrategyパターンのConcreteStrategyに相当します。
処理の中身は複雑なものではないので説明が不要だと思いますが、前述の仕様を満たすように実装しています。

料金計算の戦略を利用するクラスがFeeContextです。
これがStrategyパターンのContextに相当します。
このクラスの中では、初期化時に料金計算の戦略を受け取り、calculateFee()メソッドを呼び出すことで、料金計算を行います。
calculateFee()メソッドの中では、料金計算の戦略に従って、割引を適用した後、送料を計算しています。

使用例では、FeeContextの初期化時に、対応する会員の種類に応じた料金計算の戦略を渡しています。
こうすることで、会員の種類に応じて、異なる料金計算の戦略を利用することができます。

さらに、プレミアムプラス会員の追加やキャンペーン期間の料金計算を実装してみます。

class PremiumPlusMemberFee implements MembershipFeeStrategy {
    private DISCOUNT_RATE = 0.1;

    calculateShippingFee(basePrice: number): number {
        return 0;
    }
  
    applyDiscount(basePrice: number): number {
      return basePrice * (1 - this.DISCOUNT_RATE);
    }
}

class CampaignFeeContext {
    private strategy: MembershipFeeStrategy;
    private CAMPAIGN_DISCOUNT_RATE = 0.1;

    constructor(strategy: MembershipFeeStrategy) {
      this.strategy = strategy;
    }

    calculateFee(basePrice: number): number {
      let priceAfterDiscount = this.strategy.applyDiscount(basePrice);
      if (this.isCampaign()) priceAfterDiscount = this.campaignDiscount(priceAfterDiscount);
      const finalPrice = priceAfterDiscount + this.strategy.calculateShippingFee(priceAfterDiscount);
      return finalPrice;
    }

    private campaignDiscount(basePrice: number): number {
        return basePrice * (1 - this.CAMPAIGN_DISCOUNT_RATE);
    }

    private isCampaign(): boolean {
        return Date.now() >= new Date("2023-10-01 00:00:00").getTime() && Date.now() <= new Date("2023-11-30 23:59:59").getTime()
    }
}

// 使用例
const campaignRegularContext = new CampaignFeeContext(new RegularMemberFee());
console.log(campaignRegularContext.calculateFee(9000));
// => 8600

const campaignPremiumContext = new CampaignFeeContext(new PremiumMemberFee());
console.log(campaignPremiumContext.calculateFee(9000));
// => 7695

const campaignPremiumPlusContext = new CampaignFeeContext(new PremiumPlusMemberFee());
console.log(campaignPremiumPlusContext.calculateFee(9000));
// => 7290

このようにStrategyはインターフェースを通じて実装することで簡単に追加することが可能であり、
Contextに関しても柔軟に対応することができます。
何より、既存の処理を変更することなく、新たな処理を追加することができるため、たとえばキャンペーンが複数重なった場合でも、
既存の処理を変更することなく、新たな処理を追加することができますし、
完了して不要になれば、追加したContextを削除さえすれば既存の処理には影響を与えません。

Strategyパターンが有効な場合/有効でない場合

Strategyパターンは、以下のような場合に有効です。

  • 複数のアルゴリズムを用意しておき、必要に応じて切り替えたい場合
  • アルゴリズムの切り替えを、クライアント側ではなく、Context側で行いたい場合
  • アルゴリズムの切り替えを、実行時に行いたい場合

逆に下記のような場合には、Strategyパターンは有効ではない場合があります。

  • 共通の関数やアルゴリズムを持っており、その具体的な振る舞いのみを変えたい場合

このようなケースでは前回記事で紹介したTemplate Methodパターンが有効です。

参考

GitHubで編集を提案

Discussion