iT邦幫忙

2026 iThome 鐵人賽

DAY 22
0
自我挑戰組

一杯咖啡的設計課:30 天 Design Pattern 的自我修煉系列 第 22

Day 22|Strategy (策略模式) 把演算法當外掛隨插即用

  • 分享至 

  • xImage
  •  

結構型 Pattern 我們到 Day21 的 Flyweight 告一段落

接下來我們繼續進入行為型 Pattern(Behavioral)(灑花)

柴咖啡的故事,也陸續走到連鎖店規模化的階段,那我們第一個要來聊的是 Strategy 策略模式

原文的定義,來自 Design Patterns: Elements of Reusable Object-Oriented Software

Define a family of algorithms, encapsulate each one, and make them interchangeable. Strategy lets the algorithm vary independently from clients that use it.

用中文理解可以是

定義一系列演算法,將每一個演算法封裝起來,並使它們可以互相替換。策略模式讓演算法的變動獨立於使用它的客戶端

核心角色

  1. Strategy(共同介面):定義這系列演算法都要遵守的操作規格
  2. ConcreteStrategy(具體演算法):實作 Strategy 介面,各自是一種完整、獨立的演算法
  3. Context(使用者):內部持有一個 Strategy 參考,自己不管演算法怎麼實作,只負責呼叫;想換演算法,直接抽換手上的參考就好

我們來看看 UML 圖
https://ithelp.ithome.com.tw/upload/images/20260919/20183470uh793qixzR.jpg

舉個生活化的例子,我是路痴,出門吃個飯幾乎都要導航,所以最常用導航 App

從我家當起點到餐廳終點,App 可以幫你規劃開車路線、大眾運輸路線、或步行路線,這三種規劃邏輯完全不一樣

但你使用 App 的方式(輸入起點終點、按下規劃)從頭到尾沒變過

// 1. Strategy(共同介面)
public interface IRouteStrategy
{
    void BuildRoute(string from, string to);
}

// 2. ConcreteStrategy:各自是一種路線規劃的演算法
public class DrivingStrategy : IRouteStrategy // 開車演算法
{
    public void BuildRoute(string from, string to) => Console.WriteLine($"規劃開車路線:{from} -> {to},預估 20 分鐘");
}

public class TransitStrategy : IRouteStrategy // 大眾運輸演算法
{
    public void BuildRoute(string from, string to) => Console.WriteLine($"規劃大眾運輸路線:{from} -> {to},預估 35 分鐘(含轉乘)");
}

public class WalkingStrategy : IRouteStrategy // 步行演算法
{
    public void BuildRoute(string from, string to) => Console.WriteLine($"規劃步行路線:{from} -> {to},預估 50 分鐘");
}
// 3. Context:只認識 IRouteStrategy,不管背後實際是哪一種演算法
public class NavigatorContext
{
    private IRouteStrategy _strategy;

    public NavigatorContext(IRouteStrategy strategy)
    {
        _strategy = strategy;
    }

    public void SetStrategy(IRouteStrategy strategy) => _strategy = strategy; // 使用者隨時可以切換交通方式

    public void Navigate(string from, string to) => _strategy.BuildRoute(from, to);
}
var navigator = new NavigatorContext(new DrivingStrategy());
navigator.Navigate("台北車站", "松山機場");
// 規劃開車路線:台北車站 -> 松山機場,預估 20 分鐘

navigator.SetStrategy(new TransitStrategy()); // 使用者改變主意,切成大眾運輸
navigator.Navigate("台北車站", "松山機場");
// 規劃大眾運輸路線:台北車站 -> 松山機場,預估 35 分鐘(含轉乘)

NavigatorContext 沒有使用 if-else 去判斷「現在是哪一種交通方式」,它只管呼叫手上這個 IRouteStrategy,實際要開車還是搭車,是外部決定、隨時可以換的

回到柴咖啡

柴咖啡展店到第二家分店(台北、台中),小黑開始玩不同的促銷手法搶市佔

台北店主打折扣戰(結帳打九折),台中店主打買一送一

問題是,這兩家店共用同一套結帳系統,同一筆訂單、同一套流程,卻要能吃得下兩種不同的促銷算法

先看看阿柴的版本

離展店時間很近,阿柴為了搶時間,直接在結帳服務裡,用 storeCode 判斷這張訂單屬於哪家店,再各自算一次

public class StoreCheckoutService
{
    public int CalculateFinalPrice(Order order, string storeCode)
    {
        if (storeCode == "TPE01") // 台北店:折扣戰,打九折
        {
            return (int)(order.TotalPrice * 0.9);
        }
        else if (storeCode == "RMQ01") // 台中店:買一送一
        {
            int totalQuantity = order.Items.Sum(i => i.Quantity);
            int freeCount = totalQuantity / 2;
            int avgPrice = totalQuantity == 0 ? 0 : order.TotalPrice / totalQuantity;
            return order.TotalPrice - freeCount * avgPrice;
        }
        else
        {
            throw new Exception("未知的分店代碼");
        }
    }
}

上線一陣子後,小黑決定開第三家店,高雄店,這次想玩集點折抵,阿柴得回頭補一個 else if

而且集點折抵需要知道「訂單用掉幾點」,所以方法簽章都得跟著多加一個參數,即使台北、台中兩家店用不到這個參數

public class StoreCheckoutService
{
    public int CalculateFinalPrice(Order order, string storeCode, int usedPoints) // 多了只有高雄店會用到的參數
    {
        if (storeCode == "TPE01") // 台北店:折扣戰,打九折
        {
            return (int)(order.TotalPrice * 0.9);
        }
        else if (storeCode == "RMQ01") // 台中店:買一送一
        {
            int totalQuantity = order.Items.Sum(i => i.Quantity);
            int freeCount = totalQuantity / 2;
            int avgPrice = totalQuantity == 0 ? 0 : order.TotalPrice / totalQuantity;
            return order.TotalPrice - freeCount * avgPrice;
        }
        else if (storeCode == "KHH01") // 高雄店:集點折抵,usedPoints 是這筆訂單客人選擇折抵的點數
        {
            return Math.Max(0, order.TotalPrice - usedPoints / 10);
        }
        else
        {
            throw new Exception("未知的分店代碼");
        }
    }
}

而小黑想抬高台中店的業績,推出上午時段買一送一、下午時段打折扣戰(台北店維持原本的折扣戰不變),但這支方法把促銷算法整組寫死綁在 storeCode 上,沒辦法在系統跑著的當下,臨時幫同一家店切換成另一種折扣算法

阿柴發現,促銷算法本身要用哪一種,跟結帳流程要怎麼跑,是兩件事,卻被這支方法綁在一起

Strategy 上場

阿柴把三種促銷算法(折扣戰、買一送一、集點折抵),各自抽成一個 IPromotionStrategy 的具體實作,結帳流程改成只認識這個介面

public interface IPromotionStrategy
{
    int Apply(Order order);
}

public class PercentageOffStrategy : IPromotionStrategy
{
    private readonly double _percentage;

    public PercentageOffStrategy(double percentage)
    {
        _percentage = percentage;
    }

    public int Apply(Order order) => (int)(order.TotalPrice * _percentage);
}

public class BuyOneGetOneStrategy : IPromotionStrategy
{
    public int Apply(Order order)
    {
        int totalQuantity = order.Items.Sum(i => i.Quantity);
        int freeCount = totalQuantity / 2;
        int avgPrice = totalQuantity == 0 ? 0 : order.TotalPrice / totalQuantity;
        return order.TotalPrice - freeCount * avgPrice;
    }
}

public class PointsRedemptionStrategy : IPromotionStrategy
{
    private readonly int _points;

    public PointsRedemptionStrategy(int points)
    {
        _points = points;
    }

    public int Apply(Order order) => Math.Max(0, order.TotalPrice - _points / 10);
}

結帳的 Context 只認識 IPromotionStrategy,不知道背後實際跑的是哪一種算法

public class PromotionContext
{
    private IPromotionStrategy _strategy;

    public PromotionContext(IPromotionStrategy strategy)
    {
        _strategy = strategy;
    }

    public void SetStrategy(IPromotionStrategy strategy) => _strategy = strategy;

    public int CalculateFinalPrice(Order order) => _strategy.Apply(order);
}
// 台北店:維持折扣戰不變
var taipeiCheckout = new PromotionContext(new PercentageOffStrategy(0.9));
Console.WriteLine(taipeiCheckout.CalculateFinalPrice(order));

// 台中店:上午時段先套用買一送一
var taichungCheckout = new PromotionContext(new BuyOneGetOneStrategy());
Console.WriteLine(taichungCheckout.CalculateFinalPrice(order));

// 到了下午,小黑想試試看改用折扣戰,把台中店切換成折扣戰
taichungCheckout.SetStrategy(new PercentageOffStrategy(0.9));
Console.WriteLine(taichungCheckout.CalculateFinalPrice(order));

// 高雄店開幕,主打集點折抵,直接注入對應的策略
var kaohsiungCheckout = new PromotionContext(new PointsRedemptionStrategy(500));
Console.WriteLine(kaohsiungCheckout.CalculateFinalPrice(order));

以後小黑想主打新的促銷手法,阿柴只要新增一個 ConcreteStrategy 類別,就可以在 SetStrategy()做抽換使用

我們來看看 Mermaid 的類別架構圖(Class Diagram)會怎麼呈現
https://ithelp.ithome.com.tw/upload/images/20260919/20183470AJ7mfWZOpV.png
朋友如果你第一次看 Mermaid 架構圖,這是小指南

方框內部三層結構:
上層:類別或介面名稱(標註 <<interface>> 代表介面合約)
中層:屬性與私有欄位(- / +  代表 private / public)
下層:對外提供的方法 (- / +  代表 private / public)

箭頭符號的意義:
◄--(虛線箭頭):實作介面,代表類別遵守合約規定
—►(實線箭頭):依賴/呼叫,箭頭指向被使用的一方
⟣—►(實線菱形):菱形持有/組合箭頭方的實例,代表內部包含該物件的參考

Strategy 的取捨

演算法不多、也不常變動時,不一定划算:如果柴咖啡永遠只有「打九折」這一種促銷,方式固定不會再變,一整組介面加上一個具體實作,會比原本一行 order.TotalPrice * 0.9 更繞路

今天學到的事

Strategy 要解決的問題:

一個行為存在多種可互相替換的演算法變體,且未來可能新增變體、或需要在執行期動態切換,若把這些演算法寫死在呼叫端內部(if-else/switch),會讓呼叫端難以維護,也無法在執行期彈性替換

優點

  • 每個演算法各自封裝成一個類別,新增或修改不會影響其他演算法與呼叫端本身,符合開放封閉原則 OCP
  • 可以在執行期動態抽換要使用的策略,不需要重新編譯或修改呼叫端邏輯
  • 消除不必要的條件判斷,演算法都能單獨測試,不用連著其他演算法一起測

缺點

  • 呼叫端(或另一個工廠、設定檔)必須知道有哪些策略可以選、並自己決定該注入哪一個,「選擇」的責任交給了呼叫端
  • 演算法變體不多、也不常變動時,導入一整組介面加多個實作類別,會再更繁瑣

明天,柴咖啡的庫存跟訂單狀態,開始需要在變化發生的當下,同步通知好幾個不同的對象——庫存不足要通知進貨窗口,訂單完成要通知客人跟外送平台,這種「一個事件發生,多個對象都要知道」的情境,換 Observer 觀察者模式上場

參考資料

Refactoring Guru - Strategy

Design Pattern | 從復仇者看策略模式( Strategy Pattern ) feat. TypeScript


上一篇
Day 21|Flyweight (享元模式) 區分「內在」與「外在」狀態:不再重複建立微小物件
下一篇
Day 23|Observer (觀察者模式 )解耦狀態與反應:如何讓各部門同時接收事件
系列文
一杯咖啡的設計課:30 天 Design Pattern 的自我修煉26
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言