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
การทำงาน
หัวข้อที่มีชื่อว่า “การทำงาน”- Client (code ที่ประกอบระบบ — เช่น
Program.cs, DI container, หรือ factory) เลือก concrete strategy ที่ต้องการ แล้วสร้าง instance ขึ้นมา - Client ส่ง instance นั้นเข้าไปใน Context ผ่าน constructor, property หรือ method parameter — โดยรับผ่าน type ของ interface ไม่ใช่ concrete class
- เมื่อ Context ต้องทำงานที่ผันแปรได้ มันเรียก method บน strategy ที่ถืออยู่ โดยไม่รู้และไม่ต้องรู้ว่าข้างในทำอะไร
- ถ้าต้องการเปลี่ยนพฤติกรรม ก็แค่ส่ง 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 เอง
ตัวอย่าง code
หัวข้อที่มีชื่อว่า “ตัวอย่าง code”// สัญญาของ "ครอบครัว 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 ไหนเลย รู้จักแค่ interfacepublic 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
แหล่งอ้างอิง
หัวข้อที่มีชื่อว่า “แหล่งอ้างอิง”- ที่มา · deviq.com/design-patterns/strategy-pattern
- Strategy — Refactoring.Guru
- Strategy pattern — Wikipedia
- Strategy in C# / Design Patterns — Refactoring.Guru
- Design Patterns: Elements of Reusable Object-Oriented Software — Gamma, Helm, Johnson, Vlissides (Gang of Four)