Strategy Pattern เป็นรูปแบบการออกแบบเชิงพฤติกรรม (Behavioral Design Pattern) ที่แยกอัลกอริทึมหลายแบบซึ่งทำงานเพื่อเป้าหมายเดียวกันไว้หลังอินเทอร์เฟซร่วม ทำให้ผู้ใช้งานเลือกหรือสลับวิธีทำงานได้ โดยคลาสที่เรียกใช้ไม่ต้องรู้รายละเอียดของแต่ละอัลกอริทึม

info-strategy-pattern-with-java

ตัวอย่างในบทความนี้คือระบบคำนวณค่าจัดส่งด้วย 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. แบบฝึกหัดต่อยอด

  1. เพิ่ม FreeShipping ซึ่งคืนค่าจัดส่ง 0 บาท และลองเลือกใช้จาก Client
  2. เพิ่ม InternationalShipping โดยกำหนดสูตรและหน่วยเงินให้ชัดเจน
  3. สร้าง Factory สำหรับเลือก Strategy จาก enum และจัดการค่าที่ไม่รองรับ
  4. ปรับ Context ให้ Strategy เป็น final แล้วเปรียบเทียบวิธีใช้งานกับแบบ setter
  5. ประยุกต์แนวคิดกับส่วนลดสินค้า วิธี 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

โค้ดและอัตราค่าจัดส่งในบทความนี้จัดทำขึ้นเพื่อสาธิตแนวคิด