ข้าม​ไป​ยัง​เนื้อหา

Strategy

นิยาม​ครอบครัว​ของ algorithm ที่​สลับ​กัน​ได้​ผ่าน interface

ลอง​นึก​ภาพ class Checkout ที่​ต้อง​คำนวณ​ค่า​ส่ง​ของ แล้ว​มี logic ประมาณ​นี้​อยู่​ข้าง​ใน

if (method == "flat") { /* คิดราคาคงที่ */ }
else if (method == "byWeight") { /* คิดตามน้ำหนัก */ }
else if (method == "express") { /* คิดแบบด่วน บวกค่าธรรมเนียม */ }

ทุก​ครั้ง​ที่​ธุรกิจ​อยาก​เพิ่ม​วิธี​คิด​ค่า​ส่ง​แบบ​ใหม่ ต้อง​กลับ​มา​แก้ class Checkout เพิ่ม else if อีก​ก้อน ทดสอบ​ทั้ง file ใหม่ และ​เสี่ยง​ชน merge conflict กับ​คน​อื่น​ที่​แก้ logic อื่น​ใน class เดียวกัน — นี่​คือ​กลิ่น​ของ​การ​ละเมิด Open-Closed Principle: class ต้อง “เปิด​สำหรับ​การ​ขยาย แต่​ปิด​สำหรับ​การ​แก้ไข”

Strategy แก้​ปัญหา​นี้​ด้วย​แนวคิด​ง่ายๆ คือ ดึง algorithm แต่ละ​แบบ​ออก​มา​เป็น class ของ​ตัวเอง ให้​ทุก class ใช้ interface เดียวกัน แล้ว​ให้ context ถือ reference ไป​ยัง interface นั้น​แทนที่​จะ implement เองตรงๆ ตาม Gang of Four นิยาม​ไว้​ว่า Strategy คือ​การ “นิยาม​ครอบครัว​ของ algorithm ห่อ​หุ้ม​แต่ละ​ตัว และ​ทำให้​สลับ​กัน​ได้ (interchangeable)”

ผล​คือ Checkout ไม่รู้จัก​และ​ไม่​สนใจ​ว่า​ค่า​ส่ง​คิด​ยังไง มัน​แค่​เรียก _shipping.For(order) แล้ว​ปล่อย​ให้ object ที่​ถูก​ส่ง​เข้า​มา (strategy) ตัดสิน​ใจ​เอง จะ​เพิ่ม​วิธี​คิด​ค่า​ส่ง​ใหม่​กี่​แบบ​ก็​เพิ่ม class ใหม่​ได้​เรื่อยๆ โดย​ไม่​ต้อง​แตะ Checkout เลย

Strategy ถูก​ใช้​อย่าง​กว้าง​ขวาง​เพื่อ​บรรลุ SRP, Explicit Dependencies และ DIP และ​เป็น​หัวใจ​ของ Dependency Injection และ IoC container — ทุก​ครั้ง​ที่​เรา inject IEmailSender, ILogger หรือ IPaymentGateway เข้าไป​ใน constructor นั่น​คือ Strategy pattern ใน​คราบ​ของ DI

Strategy มี​ผู้​ร่วม​แสดง 3 ฝ่าย: Context ที่​ถือ reference ไป​ยัง strategy, Strategy interface ที่​นิยาม​สัญญา​ของ algorithm, และ Concrete Strategy หนึ่ง​ตัว​หรือ​มากกว่าที่ implement algorithm แต่ละ​แบบ

classDiagram
    class Checkout {
      -IShippingCost shipping
      +GetTotal(Order order) decimal
    }
    class IShippingCost {
      +For(Order order) decimal
    }
    class FlatRateShipping {
      +For(Order order) decimal
    }
    class WeightBasedShipping {
      +For(Order order) decimal
    }
    class ExpressShipping {
      +For(Order order) decimal
    }
    Checkout o-- IShippingCost
    IShippingCost <|.. FlatRateShipping
    IShippingCost <|.. WeightBasedShipping
    IShippingCost <|.. ExpressShipping

สังเกต​ว่า Checkout (context) ไม่มี dependency ไป​ยัง concrete strategy class ไหน​เลย มัน​รู้จัก​แค่ IShippingCost เท่านั้น — ความ​สัมพันธ์​แบบ composition (o--) นี่แหละ​ที่​ทำให้ swap algorithm ได้​ตอน runtime

  1. Client (code ที่​ประกอบ​ระบบ — เช่น Program.cs, DI container, หรือ factory) เลือก concrete strategy ที่​ต้องการ แล้ว​สร้าง instance ขึ้น​มา
  2. Client ส่ง instance นั้น​เข้าไป​ใน Context ผ่าน constructor, property หรือ method parameter — โดย​รับ​ผ่าน type ของ interface ไม่ใช่ concrete class
  3. เมื่อ Context ต้อง​ทำงาน​ที่​ผันแปร​ได้ มัน​เรียก method บน strategy ที่​ถือ​อยู่ โดย​ไม่รู้​และ​ไม่​ต้อง​รู้​ว่า​ข้าง​ใน​ทำ​อะไร
  4. ถ้า​ต้องการ​เปลี่ยน​พฤติกรรม ก็​แค่​ส่ง concrete strategy ตัว​อื่น​เข้า​มา​แทน — ไม่​ต้อง​แก้ code ใน Context แม้แต่​บรรทัด​เดียว
sequenceDiagram
    participant Client
    participant Context as Checkout
    participant Strategy as IShippingCost

    Client->>Strategy: new WeightBasedShipping()
    Client->>Context: new Checkout(strategy)
    Client->>Context: GetTotal(order)
    Context->>Strategy: For(order)
    Strategy-->>Context: cost
    Context-->>Client: total

จุด​สำคัญ​คือ Context ไม่​ทำ if (strategyType == ...) เพื่อ​เลือก branch เอง — การ​ตัดสิน​ใจ​ว่า​จะ​ใช้ algorithm ไหน​เกิด​ขึ้น “ข้าง​นอก” Context ทั้งหมด (มัก​อยู่​ที่​จุด composition root หรือ DI container) ซึ่ง​ต่าง​จาก State ที่​ตัว context/state object มัก​เป็น​คน​ตัดสิน​ใจ​เปลี่ยน state เอง

// สัญญาของ "ครอบครัว algorithm" — ทุกวิธีคิดค่าส่งต้องทำตามนี้
public interface IShippingCost
{
decimal For(Order order);
}
// concrete strategy แบบที่ 1: ราคาคงที่
public class FlatRateShipping : IShippingCost
{
private const decimal Rate = 50m;
public decimal For(Order order) => Rate;
}
// concrete strategy แบบที่ 2: คิดตามน้ำหนักรวม
public class WeightBasedShipping : IShippingCost
{
private const decimal RatePerKg = 20m;
public decimal For(Order order) => order.TotalWeightKg * RatePerKg;
}
// concrete strategy แบบที่ 3: ด่วนพิเศษ บวกค่าธรรมเนียมคงที่ทับของแบบน้ำหนัก
public class ExpressShipping : IShippingCost
{
private readonly IShippingCost _inner;
private const decimal ExpressFee = 100m;
public ExpressShipping(IShippingCost inner) => _inner = inner;
public decimal For(Order order) => _inner.For(order) + ExpressFee;
}
// context — ไม่รู้จัก concrete strategy class ไหนเลย รู้จักแค่ interface
public class Checkout
{
private readonly IShippingCost _shipping; // strategy ที่ถูกส่งเข้ามา
public Checkout(IShippingCost shipping) => _shipping = shipping;
public decimal GetTotal(Order order)
=> order.Subtotal + _shipping.For(order);
}
// จุดประกอบ (composition root) — ตัดสินใจว่าจะใช้ strategy ไหน
var order = new Order { Subtotal = 500m, TotalWeightKg = 3.5m };
var checkout = new Checkout(new WeightBasedShipping());
Console.WriteLine(checkout.GetTotal(order)); // ใช้วิธีคิดตามน้ำหนัก
var expressCheckout = new Checkout(new ExpressShipping(new FlatRateShipping()));
Console.WriteLine(expressCheckout.GetTotal(order)); // สลับ algorithm ได้โดยไม่แก้ Checkout เลย

ใน project จริง มัก​ไม่ new strategy เองตรงๆ แบบ​ด้าน​บน แต่​ให้ DI container resolve IShippingCost ตาม configuration หรือ feature flag แทน — นั่น​ทำให้ swap algorithm ได้​แม้​กระทั่ง​ตอน runtime โดย​ไม่​ต้อง compile ใหม่

  • มี algorithm หลาย​แบบ​สำหรับ​งาน​เดียวกัน (เช่น วิธี​คิด​ค่า​ส่ง, วิธี​จัด​เรียง, วิธี validate) และ​คาด​ว่า​จะ​เพิ่ม​แบบ​ใหม่​ใน​อนาคต
  • ต้องการ​เลี่ยง conditional ยาวๆ (if/else หรือ switch) ที่​เลือก algorithm ตาม type หรือ flag
  • ต้องการ​สลับ algorithm ตอน runtime ตาม configuration, feature flag, หรือ context ของ​ผู้​ใช้
  • ต้องการ​ทดสอบ business logic ของ context แยก​จาก algorithm โดย mock strategy interface ใน unit test
  • กำลัง​ทำ Dependency Injection อยู่​แล้ว — Strategy คือ​รูปแบบ​ธรรมชาติ​ของ​สิ่ง​ที่​ถูก inject
  • มี algorithm แบบ​เดียว​และ​ไม่มี​แนวโน้ม​จะ​เปลี่ยน​หรือ​เพิ่ม — การ​สร้าง interface และ class แยก​จะ​เพิ่ม complexity โดย​ไม่​ได้​อะไร​กลับ​มา
  • Algorithm ต่าง​กัน​แค่​เล็กน้อย (เช่น ค่า​คงที่​ตัว​เดียว) — ส่ง​เป็น parameter หรือ delegate/Func<T> ธรรมดา​ก็​พอ ไม่​จำเป็น​ต้อง​มี interface เต็ม​รูปแบบ
  • ทีม​ไม่​คุ้น​กับ DI/polymorphism และ​ความ​ซับซ้อน​ที่​เพิ่ม​ขึ้น​จะ​ทำให้ code “ตาม​ยาก” มากกว่า​จะ​ช่วย​ให้​อ่าน​ง่าย​ขึ้น
  • ถ้า​สิ่ง​ที่​ต้อง​การจริงๆ คือ​ให้ object เปลี่ยน​พฤติกรรม​ตาม “สถานะ​ภายใน” ของ​มัน​เอง (ไม่ใช่ algorithm ที่ client เลือก) นั่น​คือ​ปัญหา​ของ State ไม่ใช่ Strategy
ด้านรายละเอียด
ข้อดีสลับ algorithm ได้​ตอน runtime โดย​ไม่​แก้ context
ข้อดีแยก​การ​ทดสอบ: mock strategy แล้ว​ทดสอบ context ได้​อิสระ​จาก algorithm จริง
ข้อดีเพิ่ม algorithm ใหม่​โดย​ไม่​แตะ code เดิม — สอดคล้อง​กับ Open-Closed Principle
ข้อดีแทนที่​การ​ใช้ inheritance หลาย​ชั้น​ด้วย composition ทำให้​ยืดหยุ่น​กว่า
ข้อ​เสียเพิ่ม​จำนวน class/interface ใน​ระบบ — overkill ถ้า​มี algorithm เดียว
ข้อ​เสียclient ต้อง​รู้ความ​แตก​ต่าง​ระหว่าง concrete strategy แต่ละ​ตัว​เพื่อ​เลือก​ให้​ถูก
ข้อ​เสียถ้า strategy ต้อง​ใช้ state ภายใน​ของ context เยอะ อาจ​ต้อง​ส่ง​ข้อมูล​เข้า-ออก​เยอะ​จน interface รก
ข้อ​เสียใน​ภาษา​ที่​รองรับ first-class function (เช่น Func<T> ใน C#, lambda) บาง case ใช้ delegate ธรรมดา​แทน​ได้​โดย​ไม่​ต้อง​สร้าง interface เต็ม​รูป
  • Single Responsibility
  • Open-Closed
  • Dependency Inversion
  • Dependency Injection
  • State — คล้าย​กัน​ใน​โครงสร้าง แต่ State ให้​ตัว object เปลี่ยน behavior ตาม​สถานะ​ภายใน​เอง ส่วน Strategy ให้ client เป็น​คน​เลือก algorithm จาก​ภายนอก
  • Null Object — มัก​ใช้​เป็น “strategy เปล่า” แบบ​หนึ่ง​เพื่อ​เลี่ยง​การ​เช็ค null ก่อน​เรียก strategy