- 1. วัตถุประสงค์การเรียนรู้
- 2. ปัญหาก่อนใช้ Strategy Pattern
- 3. องค์ประกอบหลัก
- 4. กติกาของ Workshop
- 5. โปรแกรม Java แบบรันได้ในไฟล์เดียว
- 6. อธิบายจุดสำคัญของโค้ด
- 7. Strategy ด้วย Lambda Expression
- 8. ตัวอย่างแนวคิดเดียวกันใน Java API
- 9. เพิ่มกลยุทธ์ใหม่โดยไม่แก้ Context
- 10. วิธีตรวจสอบ Workshop
- 11. Strategy ต่างจาก Pattern อื่นอย่างไร
- 12. ประโยชน์และข้อควรพิจารณา
- 13. แบบฝึกหัดต่อยอด
- 14. สรุป
- แหล่งอ่านเพิ่มเติม
Strategy Pattern เป็นรูปแบบการออกแบบเชิงพฤติกรรม (Behavioral Design Pattern) ที่แยกอัลกอริทึมหลายแบบซึ่งทำงานเพื่อเป้าหมายเดียวกันไว้หลังอินเทอร์เฟซร่วม ทำให้ผู้ใช้งานเลือกหรือสลับวิธีทำงานได้ โดยคลาสที่เรียกใช้ไม่ต้องรู้รายละเอียดของแต่ละอัลกอริทึม

ตัวอย่างในบทความนี้คือระบบคำนวณค่าจัดส่งด้วย Java มีทั้งจัดส่งปกติ จัดส่งด่วน และรับสินค้าที่ร้าน อัตราค่าจัดส่งทั้งหมดเป็นข้อมูลสมมติสำหรับการเรียนรู้
#1. วัตถุประสงค์การเรียนรู้
- อธิบายบทบาทของ Strategy, Concrete Strategy, Context และ Client
- แยกกฎธุรกิจที่เปลี่ยนแปลงได้ออกจากคลาสที่เรียกใช้งาน
- เขียนตัวอย่างที่เปลี่ยน Strategy ระหว่างรันโปรแกรม
- ใช้ Lambda Expression แทนคลาสเมื่อกลยุทธ์มีขนาดเล็ก
- เลือกใช้ Pattern อย่างเหมาะสมและเข้าใจข้อจำกัด
สิ่งที่ต้องมี: JDK 8 ขึ้นไปและโปรแกรมแก้ไขโค้ด ตัวอย่างไม่มี dependency ภายนอก ตรวจสอบเครื่องด้วย java -version และ javac -version หากไม่มี javac ให้ติดตั้ง JDK แทน JRE
#2. ปัญหาก่อนใช้ Strategy Pattern
สมมติว่าระบบเริ่มต้นด้วยเมธอดคำนวณค่าจัดส่งที่เลือกสูตรจากข้อความ:
// ตัวอย่างแนวคิดก่อน refactor ยังไม่ใช่โปรแกรมเต็ม
if ("STANDARD".equals(type)) {
// สูตรค่าจัดส่งปกติ
} else if ("EXPRESS".equals(type)) {
// สูตรค่าจัดส่งด่วน
} else if ("PICKUP".equals(type)) {
// รับสินค้าที่ร้าน
} else {
throw new IllegalArgumentException("Unknown shipping type");
}
โค้ดลักษณะนี้เหมาะกับเงื่อนไขเล็ก ๆ ที่คงที่ แต่เมื่อสูตรแต่ละแบบซับซ้อนและเปลี่ยนบ่อย เมธอดจะมีหลายหน้าที่ การเพิ่มวิธีส่งสินค้าอาจต้องแก้คลาสเดิมและตรวจสอบผลกระทบต่อสูตรอื่น
Strategy Pattern ย้ายรายละเอียดของสูตรแต่ละแบบไปไว้ในคลาสของตัวเอง ส่วนคลาสคำนวณกลางรับ Strategy แล้วมอบหมายงานให้ผ่านอินเทอร์เฟซ
Strategy ไม่ได้กำจัดการตัดสินใจเลือกวิธีทั้งหมด ระบบยังต้องเลือกว่าจะใช้กลยุทธ์ใด แต่สามารถวางการเลือกไว้ที่ Client, Factory หรือจุดประกอบระบบ แทนการรวมไว้กับสูตรคำนวณ
#3. องค์ประกอบหลัก
| บทบาท | ชื่อใน Workshop | หน้าที่ |
|---|---|---|
| Strategy | ShippingStrategy |
กำหนดสัญญาว่าทุกวิธีต้องคำนวณจากน้ำหนักและคืนค่าจัดส่ง |
| Concrete Strategy | StandardShipping, ExpressShipping, PickupShipping |
เขียนสูตรคำนวณแต่ละแบบ |
| Context | ShippingCalculator |
เก็บ Strategy ตรวจสอบอินพุต และมอบหมายการคำนวณ |
| Client | main() |
สร้างและเลือก Strategy ที่ต้องการ |
Context เก็บออบเจ็กต์ Strategy เป็นสมาชิก จึงเป็นการประกอบออบเจ็กต์ (Composition) คลาส Context ไม่ต้องสืบทอดจากคลาสสูตรจัดส่ง
classDiagram
direction TB
class ShippingStrategy {
<<interface>>
+calculate(long weightGrams) BigDecimal
}
class ShippingCalculator {
-ShippingStrategy strategy
+setStrategy(ShippingStrategy strategy) void
+calculate(long weightGrams) BigDecimal
}
class StandardShipping
class ExpressShipping
class PickupShipping
ShippingCalculator o--> ShippingStrategy : holds
ShippingStrategy <|.. StandardShipping
ShippingStrategy <|.. ExpressShipping
ShippingStrategy <|.. PickupShipping
เส้น holds แสดงว่า Context เก็บ Strategy ส่วนเส้น realization แสดงว่าคลาสสูตรต่าง ๆ implement อินเทอร์เฟซเดียวกัน Context เรียกกลยุทธ์ที่ถูกเลือกเพียงตัวเดียวต่อการคำนวณ ไม่ได้เรียกทุกสูตรพร้อมกัน
#4. กติกาของ Workshop
รับน้ำหนักเป็นจำนวนเต็มหน่วยกรัม และปัดขึ้นเป็นกิโลกรัมสำหรับวิธีจัดส่งปกติและด่วน เช่น 1,001 กรัม คิดเป็น 2 กิโลกรัม
| วิธี | สูตรสมมติ | น้ำหนัก 1,500 กรัม |
|---|---|---|
| Standard | 40 + 10 × จำนวนกิโลกรัมที่ปัดขึ้น | 60 บาท |
| Express | 80 + 20 × จำนวนกิโลกรัมที่ปัดขึ้น | 120 บาท |
| Pickup | ไม่มีค่าจัดส่ง | 0 บาท |
| Flat Rate | ราคาเหมาจ่ายผ่าน Lambda | 75 บาท |
ใช้ BigDecimal สำหรับจำนวนเงิน และใช้ long สำหรับน้ำหนัก ตัวอย่างนี้คิดจำนวนกิโลกรัมด้วยการหารจำนวนเต็มและตรวจเศษ จึงไม่ต้องแปลงน้ำหนักเป็น floating point
#5. โปรแกรม Java แบบรันได้ในไฟล์เดียว
สร้างไฟล์ชื่อ StrategyDemo.java แล้วใส่โค้ดต่อไปนี้ ชื่อไฟล์ต้องตรงกับชื่อ public class
import java.math.BigDecimal;
import java.util.Objects;
public class StrategyDemo {
@FunctionalInterface
interface ShippingStrategy {
// Precondition: weightGrams > 0
// Result: non-null, non-negative shipping cost in THB
BigDecimal calculate(long weightGrams);
}
static long billableKilograms(long weightGrams) {
return weightGrams / 1000
+ (weightGrams % 1000 == 0 ? 0 : 1);
}
static class StandardShipping implements ShippingStrategy {
@Override
public BigDecimal calculate(long weightGrams) {
BigDecimal kilograms =
BigDecimal.valueOf(billableKilograms(weightGrams));
return new BigDecimal("40")
.add(new BigDecimal("10").multiply(kilograms));
}
}
static class ExpressShipping implements ShippingStrategy {
@Override
public BigDecimal calculate(long weightGrams) {
BigDecimal kilograms =
BigDecimal.valueOf(billableKilograms(weightGrams));
return new BigDecimal("80")
.add(new BigDecimal("20").multiply(kilograms));
}
}
static class PickupShipping implements ShippingStrategy {
@Override
public BigDecimal calculate(long weightGrams) {
return BigDecimal.ZERO;
}
}
static class ShippingCalculator {
private ShippingStrategy strategy;
ShippingCalculator(ShippingStrategy strategy) {
setStrategy(strategy);
}
public void setStrategy(ShippingStrategy strategy) {
this.strategy = Objects.requireNonNull(strategy, "strategy");
}
public BigDecimal calculate(long weightGrams) {
if (weightGrams <= 0) {
throw new IllegalArgumentException(
"weightGrams must be positive");
}
BigDecimal cost = Objects.requireNonNull(
strategy.calculate(weightGrams), "shipping cost");
if (cost.signum() < 0) {
throw new IllegalStateException(
"shipping cost must not be negative");
}
return cost;
}
}
public static void main(String[] args) {
long weightGrams = 1500;
ShippingCalculator calculator =
new ShippingCalculator(new StandardShipping());
System.out.println("Standard: "
+ calculator.calculate(weightGrams) + " THB");
calculator.setStrategy(new ExpressShipping());
System.out.println("Express: "
+ calculator.calculate(weightGrams) + " THB");
calculator.setStrategy(new PickupShipping());
System.out.println("Pickup: "
+ calculator.calculate(weightGrams) + " THB");
calculator.setStrategy(grams -> new BigDecimal("75"));
System.out.println("Flat rate: "
+ calculator.calculate(weightGrams) + " THB");
}
}
คอมไพล์และรันจากโฟลเดอร์ที่เก็บไฟล์:
javac StrategyDemo.java
java StrategyDemo
ผลลัพธ์ที่คาดหวัง:
Standard: 60 THB
Express: 120 THB
Pickup: 0 THB
Flat rate: 75 THB
โค้ดรวมคลาสไว้ภายใน StrategyDemo เพื่อให้คัดลอกไปทดลองได้ง่าย ในโครงการจริงสามารถแยกแต่ละคลาสเป็นไฟล์ตาม package ของโครงการ
#6. อธิบายจุดสำคัญของโค้ด
#อินเทอร์เฟซเป็นสัญญาร่วม
ทุกกลยุทธ์รับน้ำหนักหน่วยเดียวกัน และคืนจำนวนเงินในสกุลเดียวกัน การมี signature เหมือนกันอย่างเดียวยังไม่พอ ต้องกำหนดความหมายของอินพุตและผลลัพธ์ให้ตรงกันด้วย
#Context มอบหมายงาน
คำสั่ง strategy.calculate(weightGrams) เป็นจุดที่ Context เรียกพฤติกรรมผ่านอินเทอร์เฟซ Java จะเลือก implementation ตามออบเจ็กต์ที่ Context เก็บอยู่ จึงไม่ต้องตรวจว่าคลาสจริงเป็น Standard หรือ Express
#Client เลือกกลยุทธ์
main() เป็นผู้เลือกกลยุทธ์ และ setStrategy() ใช้เปลี่ยนระหว่างรัน การส่ง Strategy ผ่าน constructor เพียงอย่างเดียวก็เป็น Strategy Pattern ได้ ไม่จำเป็นต้องมี setter ในทุกระบบ
#ตรวจสอบอินพุตที่จุดเรียกหลัก
Context ปฏิเสธน้ำหนักที่ไม่เป็นบวก ป้องกัน Strategy ที่เป็น null และตรวจว่าผลลัพธ์ไม่เป็น null หรือค่าติดลบ Concrete Strategy ในตัวอย่างถือว่าได้รับน้ำหนักที่ผ่าน Context แล้ว หากเปิด Strategy ให้เรียกโดยตรงเป็น public API ควรพิจารณาตรวจเงื่อนไขที่ขอบเขตนั้นด้วย
#7. Strategy ด้วย Lambda Expression
ShippingStrategy มี abstract method เพียงหนึ่งเมธอด จึงใช้เป็น functional interface ได้ Java รองรับ Lambda Expression ตั้งแต่ Java 8 ตามเอกสาร Dev.java
// วางภายใน main() หลังสร้าง calculator แล้ว
ShippingStrategy flatRate = grams -> new BigDecimal("75");
calculator.setStrategy(flatRate);
System.out.println(calculator.calculate(1500));
Lambda เหมาะกับสูตรสั้นที่ไม่มี state หรือ dependency ซับซ้อน ส่วนคลาสที่ตั้งชื่อชัดเจนเหมาะเมื่อกลยุทธ์มีหลายขั้นตอน ต้องใช้ service อื่น หรือมีการตั้งค่าที่ควรแสดงให้เห็น
Lambda เป็นวิธีเขียน implementation ให้สั้นลง รูปแบบการออกแบบยังเกิดจากความสัมพันธ์ที่ Context รับและเรียกพฤติกรรมซึ่งเปลี่ยนแทนกันได้
#8. ตัวอย่างแนวคิดเดียวกันใน Java API
เอกสาร Java ระบุว่า Comparator เป็น functional interface ที่ส่งให้เมธอดเรียงลำดับได้ ผู้เรียกจึงกำหนดวิธีเปรียบเทียบแยกจากขั้นตอนการ sort จากมุมมองการออกแบบ เราสามารถมองความสัมพันธ์นี้เป็นตัวอย่างของแนวคิด Strategy
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Comparator;
import java.util.List;
// วางในเมธอด เช่น main()
List<String> names = new ArrayList<>(
Arrays.asList("Zoo", "Apple", "Banana"));
names.sort(Comparator.comparingInt(String::length));
System.out.println(names); // [Zoo, Apple, Banana]
names.sort(Comparator.naturalOrder());
System.out.println(names); // [Apple, Banana, Zoo]
names.sort(Comparator.reverseOrder());
System.out.println(names); // [Zoo, Banana, Apple]
การเปลี่ยน Comparator ทำให้ข้อมูลชุดเดิมเรียงต่างกัน โดยไม่ต้องเขียนขั้นตอนการ sort ใหม่
#9. เพิ่มกลยุทธ์ใหม่โดยไม่แก้ Context
สมมติว่ามีการจัดส่งแบบคิดราคาเหมาจ่ายตามพื้นที่ สร้างคลาสต่อไปนี้ภายใน StrategyDemo:
static class LocalShipping implements ShippingStrategy {
@Override
public BigDecimal calculate(long weightGrams) {
return new BigDecimal("35");
}
}
จากนั้นเลือกใช้ใน main():
calculator.setStrategy(new LocalShipping());
System.out.println(calculator.calculate(1500)); // 35
ShippingCalculator ไม่ต้องแก้สูตรคำนวณ วิธีนี้ช่วยสนับสนุน Open/Closed Principle สำหรับส่วน Context อย่างไรก็ตาม จุดลงทะเบียนหรือเลือกกลยุทธ์อาจยังต้องปรับเมื่อเพิ่มตัวเลือกใหม่
#10. วิธีตรวจสอบ Workshop
ทดลองเปลี่ยนน้ำหนักและกลยุทธ์ แล้วเทียบกับค่าที่คำนวณจากกติกา:
| กรณี | ผลลัพธ์ที่คาดหวัง |
|---|---|
| Standard, 1 กรัม | 50 บาท |
| Standard, 1,000 กรัม | 50 บาท |
| Standard, 1,001 กรัม | 60 บาท |
| Express, 1,500 กรัม | 120 บาท |
| Pickup, 1,500 กรัม | 0 บาท |
| น้ำหนัก 0 หรือค่าติดลบ | IllegalArgumentException |
ส่ง null ให้ constructor หรือ setter |
NullPointerException |
| Strategy คืนเงินติดลบ | IllegalStateException |
| สลับจาก Standard เป็น Express | การคำนวณครั้งต่อไปใช้สูตร Express |
เมื่อเขียน automated test ควรทดสอบทั้งขอบเขตน้ำหนัก เช่น 1,000 และ 1,001 กรัม และการที่ Context มอบหมายงานให้กลยุทธ์ที่เลือก สำหรับ BigDecimal ให้ใช้ compareTo() หากต้องการเทียบค่าเชิงตัวเลขโดยไม่สนใจ scale
หมายเหตุการตรวจสอบบทความ: ตรวจทานโค้ดและผลลัพธ์ตามสูตรแล้ว แต่สภาพแวดล้อมที่จัดทำบทความมี Java runtime โดยไม่มี javac จึงยังไม่ได้คอมไพล์และรันตัวอย่างในสภาพแวดล้อมนี้
#11. Strategy ต่างจาก Pattern อื่นอย่างไร
| Pattern | จุดประสงค์ | ตัวอย่าง |
|---|---|---|
| Strategy | เลือกอัลกอริทึมที่ใช้แทนกันได้ | เลือกสูตรค่าจัดส่ง |
| State | เปลี่ยนพฤติกรรมตามสถานะและการเปลี่ยนสถานะ | คำสั่งซื้อ Pending → Paid → Shipped |
| Template Method | กำหนดโครงขั้นตอนในคลาสฐานและให้ subclass เปลี่ยนบางขั้น | ขั้นตอนนำเข้าข้อมูลที่ต่างเฉพาะการอ่านไฟล์ |
| Factory | รับผิดชอบการสร้างหรือเลือกชนิดออบเจ็กต์ | สร้าง ShippingStrategy จากรหัสวิธีจัดส่ง |
Strategy กับ State อาจมีโครงสร้างคลาสคล้ายกัน แต่ต่างกันที่เจตนา State เน้นวงจรสถานะ ส่วน Strategy เน้นชุดวิธีทำงานทางเลือก Factory สามารถใช้ร่วมกับ Strategy เพื่อจัดการการสร้างออบเจ็กต์ได้
#12. ประโยชน์และข้อควรพิจารณา
ประโยชน์: สูตรแต่ละแบบมีหน้าที่ชัดเจน ทดสอบแยกได้ เลือกพฤติกรรมผ่านอินเทอร์เฟซ และเพิ่ม implementation โดยลดการแก้ Context
สิ่งที่ต้องพิจารณา: จำนวนคลาสอาจเพิ่มขึ้น Client ต้องเข้าใจว่าควรเลือกกลยุทธ์ใด และทุกกลยุทธ์ต้องรักษาสัญญาร่วม หากมีเงื่อนไขง่าย ๆ เพียงสองแบบที่ไม่เปลี่ยน การใช้ if อาจเพียงพอ
Context ใน Workshop มี setter และไม่ได้ออกแบบให้หลาย thread สลับ Strategy พร้อมกัน ในเว็บแอปที่รับหลายคำขอ ควรใช้ Context แยกต่อคำขอ หรือส่ง Strategy ให้เมธอดโดยตรง หรือใช้ Context ที่เก็บ Strategy เป็น final ผ่าน constructor ตามรูปแบบการใช้งาน
ในงานจริง หากกฎค่าจัดส่งต้องใช้พื้นที่ ระยะทาง หรือมูลค่าสินค้า ให้ปรับสัญญาเป็น calculate(ShippingRequest request) ซึ่งรวมข้อมูลที่จำเป็น โดยยังแยกสูตรแต่ละแบบไว้หลังอินเทอร์เฟซเดิมในเชิงแนวคิด
#13. แบบฝึกหัดต่อยอด
- เพิ่ม
FreeShippingซึ่งคืนค่าจัดส่ง 0 บาท และลองเลือกใช้จาก Client - เพิ่ม
InternationalShippingโดยกำหนดสูตรและหน่วยเงินให้ชัดเจน - สร้าง Factory สำหรับเลือก Strategy จาก
enumและจัดการค่าที่ไม่รองรับ - ปรับ Context ให้ Strategy เป็น
finalแล้วเปรียบเทียบวิธีใช้งานกับแบบ setter - ประยุกต์แนวคิดกับส่วนลดสินค้า วิธี export CSV/JSON หรืออัลกอริทึมจัดอันดับ
#14. สรุป
Strategy Pattern แยกส่วนที่เปลี่ยนได้เป็นชุดพฤติกรรมที่ใช้สัญญาเดียวกัน Context มอบหมายงานโดยไม่ผูกกับสูตรเฉพาะ และ Client เลือกกลยุทธ์ให้เหมาะกับสถานการณ์ ใน Java สามารถเขียน Strategy เป็นคลาสหรือ Lambda ตามขนาดและความซับซ้อนของงาน
#แหล่งอ่านเพิ่มเติม
- Gamma, E., Helm, R., Johnson, R., & Vlissides, J. (1994). Design Patterns: Elements of Reusable Object-Oriented Software. Addison-Wesley. หัวข้อ Strategy (แหล่งต้นฉบับของ GoF Patterns)
- Dev.java: Writing Your First Lambda Expression — functional interface และ Lambda
- Oracle Java API: Comparator — การกำหนดวิธีเปรียบเทียบสำหรับ sort
โค้ดและอัตราค่าจัดส่งในบทความนี้จัดทำขึ้นเพื่อสาธิตแนวคิด