การจัดระเบียบข้อมูลไม่ได้หยุดอยู่ที่การเลือกชนิดตัวแปร แต่รวมถึงการตัดสินใจว่าใครเป็นเจ้าของข้อมูล ใครแก้ไขได้ ข้อมูลมีเอกลักษณ์ของตัวเองหรือเป็นเพียงค่า และพฤติกรรมที่เกี่ยวข้องควรอยู่ตรงไหน

info-refactoring-organizing-data-java-15

บทความนี้อธิบายเทคนิค Organizing Data ตามรายการของ Refactoring.Guru ด้วยตัวอย่าง Java ที่เขียนขึ้นใหม่ พร้อมข้อพิจารณาในการเลือกใช้ เหมาะสำหรับการเรียน OOP และการปรับโครงสร้างโปรแกรม

Refactoring คือการเปลี่ยนโครงสร้างภายในโดยรักษาพฤติกรรมที่ผู้ใช้สังเกตได้ การเพิ่ม validation ใหม่ การเปลี่ยนกติกาธุรกิจ และการเปลี่ยนความหมายของ equality ควรแยกเป็นงานที่มีข้อกำหนดและการทดสอบชัดเจน

ตัวอย่างแต่ละหัวข้อเป็นอิสระจากกัน ใช้ Java 17 ขึ้นไป บางหัวข้อใช้ record ชื่อคลาสอาจซ้ำข้ามหัวข้อ จึงควรแยกไฟล์ในคนละ package หรือคนละโฟลเดอร์ ตัวอย่างแสดงโครงสร้างหลังปรับและอธิบายโครงสร้างเดิมในข้อความ ไม่ใช่แอปพลิเคชันเต็มรูปแบบ

#ภาพรวมทั้ง 15 เทคนิค

ลำดับ เทคนิค สิ่งที่ต้องการแก้
1 Self Encapsulate Field การเข้าถึง private field ภายในคลาสต้องผ่านจุดควบคุม
2 Replace Data Value with Object ข้อมูลพื้นฐานเริ่มมีกฎหรือพฤติกรรมของตัวเอง
3 Change Value to Reference หลายสำเนาแทนสิ่งเดียวกันที่มี identity
4 Change Reference to Value วัตถุเล็กที่ควรเปรียบเทียบด้วยค่า
5 Replace Array with Object ใช้ตำแหน่งใน array แทนความหมายของข้อมูลต่างชนิด
6 Duplicate Observed Data ข้อมูลธุรกิจผูกอยู่กับ GUI
7 Unidirectional → Bidirectional ต้องเดินความสัมพันธ์กลับอีกทิศทาง
8 Bidirectional → Unidirectional มีความสัมพันธ์ย้อนกลับที่ไม่จำเป็น
9 Replace Magic Number with Symbolic Constant ตัวเลขมีความหมายแต่ไม่มีชื่อ
10 Encapsulate Field ภายนอกเข้าถึง field โดยตรง
11 Encapsulate Collection ภายนอกแก้ collection ข้ามกติกาของเจ้าของ
12 Replace Type Code with Class ใช้ตัวเลขหรือสตริงแทนประเภทข้อมูล
13 Replace Type Code with Subclasses ประเภทคงที่กำหนดพฤติกรรมที่แตกต่าง
14 Replace Type Code with State/Strategy ต้องเปลี่ยนพฤติกรรมด้วย composition
15 Replace Subclass with Fields subclass ต่างกันเพียงค่าคงที่

#1. Self Encapsulate Field — ควบคุมการเข้าถึงภายในคลาส

ปัญหา: คลาสมี private field อยู่แล้ว แต่เมธอดภายในอ่านและเขียน field โดยตรงหลายจุด เมื่อจะเปลี่ยนวิธีอ่านหรือเขียนข้อมูลต้องตามแก้ทุกจุด

แนวทาง: เพิ่ม accessor แล้วให้เมธอดภายในเรียกผ่าน accessor นั้น สร้าง setter เฉพาะเมื่อจำเป็น และไม่ต้องเปิดเป็น public เสมอไป

ตัวอย่าง: เดิม reserve() ลดค่า available โดยตรง หลังปรับให้ผ่าน setAvailable() และ getAvailable()

final class Inventory {
    private int available;

    Inventory(int initial) {
        setAvailable(initial);
    }

    private int getAvailable() {
        return available;
    }

    private void setAvailable(int value) {
        available = value;
    }

    void reserve(int quantity) {
        if (quantity <= 0 || quantity > getAvailable()) {
            throw new IllegalArgumentException("Invalid quantity");
        }
        setAvailable(getAvailable() - quantity);
    }
}

เลือกใช้เมื่อ: ต้องรวมจุดเข้าถึงข้อมูล เช่น การอ่านค่าแบบ lazy หรือการรักษากฎที่มีอยู่แล้ว หาก accessor เพียงเพิ่มชั้นการเรียกโดยไม่มีประโยชน์ การเข้าถึง private field โดยตรงอาจอ่านง่ายกว่า

ข้อควรระวัง: การเพิ่มกฎใหม่ใน setter อาจเปลี่ยนพฤติกรรมเดิม และควรหลีกเลี่ยงการเรียกเมธอดที่ subclass override ได้จาก constructor

อ้างอิง: Self Encapsulate Field

#2. Replace Data Value with Object — ย้ายข้อมูลและพฤติกรรมมาอยู่ด้วยกัน

ปัญหา: ค่าสตริงหรือตัวเลขเริ่มมีตรรกะเฉพาะ เช่น รหัสสินค้าใช้สร้างชื่อไฟล์ บาร์โค้ด และรูปแบบการแสดงผล แต่ตรรกะกระจายอยู่หลายคลาส

แนวทาง: สร้างชนิดข้อมูลที่มีความหมาย แล้วนำค่ากับพฤติกรรมที่เกี่ยวข้องมาไว้ด้วยกัน

ตัวอย่าง: เดิม Product เก็บ String sku และประกอบชื่อป้ายเอง หลังปรับให้ Sku ดูแลพฤติกรรมดังกล่าว

import java.util.Objects;

record Sku(String value) {
    Sku {
        Objects.requireNonNull(value);
    }

    String labelFileName() {
        return value + "-label.pdf";
    }
}

record Product(Sku sku, String name) {
    String labelFileName() {
        return sku.labelFileName();
    }
}

ผล: เมธอดที่ต้องการ SKU สามารถรับ Sku โดยตรง ลดการส่งสตริงผิดประเภท และรวมกฎไว้ในที่เดียว

ข้อควรระวัง: อย่าห่อ primitive ทุกตัวโดยอัตโนมัติ ควรมีความหมายทางโดเมนหรือกฎที่คุ้มค่ากับชนิดข้อมูลใหม่ ตัวอย่างกำหนดว่า SKU ต้องไม่เป็น null ตั้งแต่ก่อนปรับโครงสร้าง

อ้างอิง: Replace Data Value with Object

#3. Change Value to Reference — ใช้วัตถุร่วมสำหรับสิ่งเดียวกัน

ปัญหา: แต่ละคำสั่งซื้อสร้าง Customer ของตัวเอง แม้เป็นลูกค้ารหัสเดียวกัน เมื่อชื่อเปลี่ยน ข้อมูลในแต่ละสำเนาอาจไม่ตรงกัน

แนวทาง: ใช้แหล่งค้นหาที่คืนวัตถุลูกค้าเดียวกันตาม identity ภายในขอบเขตที่กำหนด

import java.util.HashMap;
import java.util.Map;
import java.util.Objects;

final class Customer {
    private final String id;
    private String name;

    Customer(String id, String name) {
        this.id = Objects.requireNonNull(id);
        this.name = Objects.requireNonNull(name);
    }

    String id() { return id; }
    String name() { return name; }
    void rename(String value) { name = Objects.requireNonNull(value); }
}

final class CustomerDirectory {
    private final Map<String, Customer> customers = new HashMap<>();

    void register(Customer customer) {
        if (customers.putIfAbsent(customer.id(), customer) != null) {
            throw new IllegalArgumentException("Duplicate customer id");
        }
    }

    Customer get(String id) {
        Customer customer = customers.get(id);
        if (customer == null) throw new IllegalArgumentException("Unknown customer");
        return customer;
    }
}

record Purchase(Customer customer) {}

หลังลงทะเบียนลูกค้าหนึ่งครั้ง คำสั่งซื้อทุกใบใน directory เดียวกันเรียก get(id) แทนการ new Customer(...) ซ้ำ การเปลี่ยนชื่อจึงปรากฏผ่าน reference เดียวกัน

ข้อควรระวัง: เป้าหมายคือ identity ไม่ใช่การรวมทุกวัตถุที่มี field เท่ากัน ลูกค้าชื่อเหมือนกันอาจเป็นคนละคน ตัวอย่างเป็น in-memory และใช้เธรดเดียว ระบบจริงต้องกำหนดขอบเขต identity map การบันทึก และ concurrency เพิ่มเติม การเปลี่ยนจากสำเนาเป็น shared state ต้องตรวจสอบว่าตรงกับความหมายของระบบ

อ้างอิง: Change Value to Reference

#4. Change Reference to Value — ให้ความเท่ากันขึ้นกับค่า

ปัญหา: วัตถุขนาดเล็กถูกจัดการด้วย registry หรือ identity ทั้งที่ผู้ใช้สนใจเพียงค่าภายใน เช่น พิกัดบนตาราง

แนวทาง: ทำเป็น value object ที่ไม่เปลี่ยนแปลงค่า และเปรียบเทียบตามส่วนประกอบ

record GridPosition(int row, int column) {
    GridPosition moveDown() {
        return new GridPosition(row + 1, column);
    }
}

new GridPosition(2, 3).equals(new GridPosition(2, 3)) ได้ true และ moveDown() สร้างค่าใหม่โดยไม่แก้ตำแหน่งเดิม record สร้าง equals() และ hashCode() จาก components ให้

ข้อควรระวัง: การใช้ reference ในภาษา Java ไม่ได้ทำให้วัตถุนั้นเป็น reference object ตามความหมายของโดเมน สิ่งที่เปลี่ยนคือความหมายของ identity, equality และการแก้ไขค่า ตรวจสอบการใช้ == และ key ใน collection ด้วย record ที่มี List หรือวัตถุ mutable ภายในไม่ได้ immutable แบบลึกโดยอัตโนมัติ

อ้างอิง: Change Reference to Value

#5. Replace Array with Object — ใช้ชื่อ field แทนตำแหน่ง

ปัญหา: ใช้ Object[] เก็บข้อมูลคนละความหมาย เช่น {"SE101", "OOP", 3} ผู้ใช้ต้องจำว่า index 0, 1 และ 2 หมายถึงอะไรและต้อง cast ให้ถูก

แนวทาง: แทนแต่ละตำแหน่งด้วย field ที่มีชื่อและชนิดชัดเจน

record Course(String code, String title, int credits) {
    String displayName() {
        return code + " - " + title;
    }
}

ใช้ course.credits() แทน (Integer) row[2] ทำให้ compiler ช่วยตรวจชนิดและลดการสลับตำแหน่ง

เลือกใช้เมื่อ: array หนึ่งชุดทำหน้าที่เสมือน record ของข้อมูลหลายความหมาย Array ที่เก็บคะแนนจำนวนมากชนิดเดียวกันยังเหมาะสม ไม่ต้องเปลี่ยนทุก array เป็น object

อ้างอิง: Replace Array with Object

#6. Duplicate Observed Data — แยกข้อมูลธุรกิจออกจาก GUI

ปัญหา: หน้าจอเก็บทั้งข้อมูลและกฎธุรกิจ ทำให้ใช้ข้อมูลเดียวกันกับหลายหน้าจอหรือทดสอบโดยไม่เปิด GUI ได้ยาก

แนวทาง: ให้ domain model เป็นเจ้าของข้อมูลจริง และให้ GUI เก็บค่าแสดงผลที่อัปเดตตาม model ผ่าน notification

import java.util.ArrayList;
import java.util.List;
import java.util.function.IntConsumer;

final class ScoreModel {
    private int score;
    private final List<IntConsumer> observers = new ArrayList<>();

    int score() { return score; }
    void subscribe(IntConsumer observer) { observers.add(observer); }
    void unsubscribe(IntConsumer observer) { observers.remove(observer); }

    void setScore(int value) {
        score = value;
        for (IntConsumer observer : List.copyOf(observers)) {
            observer.accept(value);
        }
    }
}

final class ScoreView implements AutoCloseable {
    private final ScoreModel model;
    private String displayedText;
    private final IntConsumer listener = this::render;

    ScoreView(ScoreModel model) {
        this.model = model;
        model.subscribe(listener);
        render(model.score());
    }

    void userEntered(int value) { model.setScore(value); }
    private void render(int value) { displayedText = Integer.toString(value); }
    String displayedText() { return displayedText; }
    public void close() { model.unsubscribe(listener); }
}

ทิศทาง: ผู้ใช้แก้หน้าจอ → model เปลี่ยน → แจ้งผู้สังเกต → หน้าจอแสดงค่าใหม่ render() ไม่เรียก setter ของ model จึงไม่เกิดวงจรแจ้งเตือนกลับไปมา

ชื่อเทคนิคไม่ได้ชวนให้ทำสำเนาข้อมูลธุรกิจที่แก้ไขได้อย่างอิสระ สถานะบนหน้าจอเป็นภาพสะท้อนของ model ตัวอย่างใช้ callback และจำลอง view เพื่อให้เห็นหลักการ โดยสมมติทำงานบน UI thread เดียวกัน GUI จริงต้องจัดการเธรดและยกเลิก listener เมื่อปิดหน้าจอ ส่วนเว็บที่ทำงานเป็น request/response ต้องเลือกกลไกซิงก์ตามสถาปัตยกรรม

อ้างอิง: Duplicate Observed Data

#7. Change Unidirectional Association to Bidirectional — เพิ่มทิศทางที่ต้องใช้

ปัญหา: Student รู้ว่าตนอยู่ Classroom ใด แต่ห้องเรียนต้องแสดงนักเรียนทั้งหมดและยังไม่มีทางเข้าถึง

แนวทาง: เพิ่มความสัมพันธ์ย้อนกลับ และกำหนดเมธอดเดียวที่รับผิดชอบทำให้สองด้านสอดคล้องกัน

import java.util.HashSet;
import java.util.Set;

final class Classroom {
    private final Set<Student> students = new HashSet<>();

    Set<Student> students() { return Set.copyOf(students); }
    void attachInternal(Student student) { students.add(student); }
    void detachInternal(Student student) { students.remove(student); }
}

final class Student {
    private Classroom classroom;

    Classroom classroom() { return classroom; }

    void moveTo(Classroom next) {
        if (classroom == next) return;
        if (classroom != null) classroom.detachInternal(this);
        classroom = next;
        if (next != null) next.attachInternal(this);
    }
}

moveTo(null) ถอนนักเรียนออกจากห้องเดิม และย้ายห้องด้วย moveTo(next) ซึ่งลบความสัมพันธ์เดิมก่อนเพิ่มใหม่

ข้อควรระวัง: เมธอดลงท้าย Internal ในตัวอย่างเป็น package-private เพื่อให้เห็นการประสานงาน ควรจำกัดไว้ใน package ของโดเมนและให้ผู้ใช้เรียก moveTo() เท่านั้น ระบบที่มีหลายเธรดหรือฐานข้อมูลต้องทำให้การเปลี่ยนทั้งสองด้านเป็นหนึ่งหน่วยงานที่สอดคล้องกัน

อ้างอิง: Change Unidirectional Association to Bidirectional

#8. Change Bidirectional Association to Unidirectional — ตัดทิศทางที่ไม่ใช้

ปัญหา: Order ถือรายการ OrderLine และแต่ละรายการถือ Order กลับ ทั้งที่ไม่มีกรณีใช้งานให้รายการเข้าถึง order

แนวทาง: ลบ reference ย้อนกลับและโค้ดซิงก์ที่ไม่จำเป็น

import java.util.ArrayList;
import java.util.List;

record OrderLine(String sku, int quantity) {}

final class Order {
    private final List<OrderLine> lines = new ArrayList<>();

    void addLine(OrderLine line) { lines.add(line); }
    List<OrderLine> lines() { return List.copyOf(lines); }
}

เหลือ Order → OrderLine จึงเข้าใจความเป็นเจ้าของง่ายขึ้นและลด coupling

ข้อควรระวัง: ตรวจสอบผู้เรียก การ serialize และการ mapping persistence ก่อนลบ ความสัมพันธ์แบบสองทิศทางไม่ผิดในตัวเอง ควรมีเมื่อรองรับกรณีใช้งานจริง

อ้างอิง: Change Bidirectional Association to Unidirectional

#9. Replace Magic Number with Symbolic Constant — ตั้งชื่อให้ตัวเลขที่มีความหมาย

ปัญหา: ตัวเลขอย่าง 86400 ปรากฏในสูตร โดยผู้อ่านต้องเดาความหมายและหน่วย

แนวทาง: ตั้งชื่อค่าคงที่ตามความหมาย

final class TimeConversion {
    private static final long SECONDS_PER_DAY = 86_400L;

    static long toSeconds(long days) {
        return days * SECONDS_PER_DAY;
    }
}

เดิมเป็น days * 86400 หลังปรับสูตรยังเหมือนเดิม แต่ชื่อระบุความสัมพันธ์ของหน่วยชัดเจน

ข้อควรระวัง: ไม่ต้องสร้าง constant ให้ทุก 0 หรือ 1 และไม่ควรรวมตัวเลขที่เท่ากันแต่คนละความหมายเป็น constant เดียว ตัวอย่างหมายถึงช่วงเวลา 24 ชั่วโมง การคำนวณวันตามปฏิทินและ timezone ต้องใช้แบบจำลองเวลาที่เหมาะสม

อ้างอิง: Replace Magic Number with Symbolic Constant

#10. Encapsulate Field — ปิดการเข้าถึง field จากภายนอก

ปัญหา: public String displayName ทำให้ทุกส่วนของโปรแกรมอ่านและเขียนตรงเข้าสู่ representation ของคลาส

แนวทาง: เปลี่ยน field เป็น private และให้เข้าถึงผ่านเมธอดที่จำเป็น

final class UserProfile {
    private String displayName;

    UserProfile(String displayName) {
        this.displayName = displayName;
    }

    String displayName() { return displayName; }
    void rename(String value) { displayName = value; }
}

ผู้ใช้ภายใน package เรียก profile.rename("Naruapon") แทนการกำหนด field โดยตรง หากเป็น API ข้าม package จึงค่อยเปิดคลาสและเมธอดที่ต้องใช้เป็น public

ความต่างจากข้อ 1: Encapsulate Field เปลี่ยนขอบเขตการเข้าถึงจากภายนอก ส่วน Self Encapsulate Field ทำให้เมธอดภายในคลาสใช้ accessor ด้วย ไม่จำเป็นต้องสร้าง setter สำหรับทุก field

อ้างอิง: Encapsulate Field

#11. Encapsulate Collection — ให้เจ้าของควบคุมการเปลี่ยนสมาชิก

ปัญหา: getter คืน mutable list ตัวจริง ผู้เรียกจึงเพิ่ม ลบ หรือ clear ได้โดยข้ามเมธอดของเจ้าของ

แนวทาง: คืนรายการที่ผู้เรียกแก้ไขไม่ได้ และเปิดเมธอดเฉพาะสำหรับการเพิ่มหรือลบ

import java.util.ArrayList;
import java.util.List;
import java.util.Objects;

record Book(String isbn, String title) {}

final class ReadingList {
    private final List<Book> books = new ArrayList<>();

    List<Book> books() { return List.copyOf(books); }
    void addBook(Book book) { books.add(Objects.requireNonNull(book)); }
    boolean removeBook(Book book) { return books.remove(book); }
}

List.copyOf() คืน snapshot ที่แก้ไขโครงสร้างไม่ได้ เมื่อเจ้าของเพิ่มสมาชิกภายหลัง snapshot เดิมจะไม่เพิ่มตาม ต่างจาก unmodifiable view ที่อาจสะท้อนการเปลี่ยน collection ต้นทาง

ข้อควรระวัง: read-only collection ไม่ได้ทำให้สมาชิกข้างใน immutable โดยอัตโนมัติ ตัวอย่าง Book มี components เป็นสตริงจึงไม่มี mutable member และกำหนดว่า null เป็นค่าที่ไม่รองรับ การเปลี่ยน getter จาก mutable list เป็น snapshot ต้องย้ายผู้เรียกไปใช้เมธอดของเจ้าของและตรวจสอบพฤติกรรมการถือรายการเก่าไว้ด้วย

อ้างอิง: Encapsulate Collection

#12. Replace Type Code with Class — แทนรหัสด้วยชนิดข้อมูล

ปัญหา: ฟิลด์ int categoryCode มีชุดค่าที่อนุญาต แต่ compiler ยังยอมให้ส่งตัวเลขใดก็ได้ ตัวรหัสมีหน้าที่ระบุหมวดหมู่ ไม่ได้เลือกอัลกอริทึมธุรกิจ

แนวทาง: สร้างคลาสสำหรับประเภทนั้นและควบคุมชุดค่าที่สร้างได้

import java.util.Objects;

final class CatalogCategory {
    static final CatalogCategory BOOK = new CatalogCategory("BOOK");
    static final CatalogCategory COURSE = new CatalogCategory("COURSE");

    private final String code;
    private CatalogCategory(String code) { this.code = code; }
    String code() { return code; }
}

record CatalogItem(String name, CatalogCategory category) {
    CatalogItem {
        Objects.requireNonNull(category);
    }
}

เมธอดที่รับ CatalogCategory จะไม่รับ 123 หรือสตริงผิดหมวดโดยตรง ตัวอย่างนี้ใช้คลาสเพื่อแสดงเทคนิคดั้งเดิม ใน Java ชุดค่าปิดที่เรียบง่ายมักเขียนด้วย enum ได้กระชับกว่า

ข้อควรระวัง: ถ้ารหัสทำให้มี switch เลือกพฤติกรรมซ้ำหลายจุด การห่อรหัสอย่างเดียวอาจยังไม่แก้ปัญหา ควรพิจารณาข้อ 13 หรือ 14 และยังอาจต้องแปลงรหัสตรงขอบเขตฐานข้อมูลหรือ API

อ้างอิง: Replace Type Code with Class

#13. Replace Type Code with Subclasses — แยกพฤติกรรมด้วยคลาสย่อย

ปัญหา: คลาสใช้รหัสชนิดรูปทรง เช่น CIRCLE และ RECTANGLE เพื่อเลือกสูตรพื้นที่ และประเภทไม่เปลี่ยนตลอดอายุวัตถุ

แนวทาง: ให้แต่ละ subclass รับผิดชอบสูตรของตน แล้วเรียกผ่าน polymorphism

abstract class Shape {
    abstract double area();
}

final class Circle extends Shape {
    private final double radius;
    Circle(double radius) { this.radius = radius; }
    double area() { return Math.PI * radius * radius; }
}

final class Rectangle extends Shape {
    private final double width;
    private final double height;

    Rectangle(double width, double height) {
        this.width = width;
        this.height = height;
    }

    double area() { return width * height; }
}

ผู้เรียกใช้ shape.area() โดยไม่ต้องรู้ชนิดจริง การสร้างวัตถุจากรหัสภายนอกอาจยังมี factory ที่เลือกชนิดเพียงจุดเดียว

เลือกใช้เมื่อ: ความแตกต่างเป็นพฤติกรรมของชนิดที่คงที่และเหมาะกับ inheritance หากประเภทต้องเปลี่ยนระหว่างรัน หรือมีหลายมิติที่ทำให้ subclass เพิ่มแบบทวีคูณ ให้พิจารณา composition

อ้างอิง: Replace Type Code with Subclasses

#14. Replace Type Code with State/Strategy — สลับพฤติกรรมผ่านวัตถุประกอบ

ปัญหา: คลาสเลือกพฤติกรรมด้วย type code แต่เปลี่ยนประเภทระหว่างใช้งานได้ หรือ inheritance ของคลาสนั้นไม่เหมาะกับการแยกเพิ่ม

แนวทาง: แยกพฤติกรรมเป็นวัตถุและให้ context ถือ reference ไปยังพฤติกรรมนั้น

import java.util.Objects;

interface ShippingStrategy {
    long feeSatang();
}

final class StandardShipping implements ShippingStrategy {
    public long feeSatang() { return 4_000L; }
}

final class ExpressShipping implements ShippingStrategy {
    public long feeSatang() { return 8_000L; }
}

final class Checkout {
    private ShippingStrategy shipping;

    Checkout(ShippingStrategy shipping) { changeShipping(shipping); }

    void changeShipping(ShippingStrategy next) {
        shipping = Objects.requireNonNull(next);
    }

    long shippingFeeSatang() { return shipping.feeSatang(); }
}

เปลี่ยนจากมาตรฐานเป็นด่วนด้วย checkout.changeShipping(new ExpressShipping()) โดยยังเป็น checkout ตัวเดิม ตัวอย่างนี้เป็น Strategy สำหรับเลือกวิธีจัดส่ง ส่วน State เหมาะกับสถานะ เช่น Draft, Paid และ Shipped ที่กำหนดทั้งการกระทำที่อนุญาตและการเปลี่ยนสถานะ

ข้อควรระวัง: การใช้ State ต้องกำหนด transition ที่ถูกต้อง ไม่ควรเปิดให้ผู้เรียกเปลี่ยนไปสถานะใดก็ได้โดยข้ามกฎ สำหรับกติกาง่ายที่ต่างกันเพียงค่าคงที่ การใช้ field อาจพอและไม่จำเป็นต้องสร้างหลาย strategy class

อ้างอิง: Replace Type Code with State/Strategy

#15. Replace Subclass with Fields — ยุบคลาสย่อยที่ต่างกันแค่ข้อมูล

ปัญหา: BasicPlan กับ PremiumPlan มีโครงสร้างเหมือนกัน ทุกเมธอดต่างกันเพียงชื่อแพ็กเกจและค่าบริการที่คืนกลับ ไม่มีอัลกอริทึมหรือ invariant เฉพาะชนิด

แนวทาง: เก็บความแตกต่างใน field ของคลาสเดียว แล้วลบ subclass ที่ไม่จำเป็น

record SubscriptionPlan(String name, long monthlyFeeSatang) {
    static SubscriptionPlan basic() {
        return new SubscriptionPlan("Basic", 9_900L);
    }

    static SubscriptionPlan premium() {
        return new SubscriptionPlan("Premium", 19_900L);
    }
}

ใช้ SubscriptionPlan.basic().monthlyFeeSatang() แทนการสร้าง subclass เพื่อคืนตัวเลขเดียว โรงงานสร้างวัตถุในตัวอย่างเป็นจุดรวมค่าของแพ็กเกจ

ข้อควรระวัง: ถ้าคลาสย่อยมีขั้นตอนประมวลผลหรือข้อจำกัดต่างกันจริง การยุบอาจนำไปสู่เงื่อนไขที่ซับซ้อนกว่าเดิม ตรวจสอบด้วยว่าผู้เรียกอาศัยชนิด subclass ใน signature หรือ instanceof อยู่หรือไม่

อ้างอิง: Replace Subclass with Fields

#คู่เทคนิคที่ต้องตัดสินใจตามบริบท

ทางเลือก คำถามที่ควรถาม แนวทางเลือก
Self Encapsulate / Encapsulate Field ต้องควบคุมการใช้จากภายในหรือภายนอก? ภายในใช้ accessor → Self; ปิด public field → Encapsulate
Value → Reference / Reference → Value สิ่งนี้ต้องมี identity ร่วม หรือเพียงค่าเท่ากัน? ลูกค้าที่ระบุด้วยรหัส → Reference; พิกัด → Value
Uni → Bi / Bi → Uni มีกรณีใช้งานเดินความสัมพันธ์ย้อนกลับจริงหรือไม่? เพิ่มเมื่อจำเป็น ลบเมื่อไม่มีผู้ใช้
Type Code → Class / Subclasses / Strategy เป็นข้อมูล ประเภทคงที่ หรือพฤติกรรมที่สลับได้? Class หรือ enum / Subclasses / Strategy
Subclasses / Fields ความแตกต่างเป็นอัลกอริทึมหรือค่าคงที่? อัลกอริทึม → พิจารณา polymorphism; ค่าคงที่ → Fields

ไม่มีทิศทางใดที่ดีกว่าเสมอ การเพิ่ม abstraction มีต้นทุน จึงควรเริ่มจากปัญหาที่เกิดขึ้นจริง เช่น จุดแก้ไขซ้ำ กฎข้อมูลรั่วไหล หรือความสัมพันธ์ที่รักษาให้ตรงกันยาก

#ขั้นตอนปรับโครงสร้างโดยรักษาพฤติกรรม

  1. ระบุผู้เรียกและเขียน characterization test สำหรับพฤติกรรมปัจจุบัน โดยเฉพาะกรณีขอบและข้อผิดพลาดที่ผู้ใช้สังเกตได้
  2. เลือกเทคนิคที่แก้ปัญหานั้นโดยตรง และปรับทีละจุด เช่น เพิ่ม accessor ก่อนย้าย field
  3. ย้ายผู้เรียกไปยัง API ใหม่ แล้วตรวจสอบว่าไม่มีทางลัดที่ยังแก้ field หรือ collection เดิม
  4. ตรวจสอบความหมายของ equality, identity, null, หน่วยข้อมูล และการถือ reference ร่วมกัน
  5. รันการทดสอบที่เกี่ยวข้อง ก่อนลบโค้ดหรือโครงสร้างเดิมที่ไม่ใช้แล้ว

สำหรับระบบที่เชื่อมฐานข้อมูล การเปลี่ยน object model อาจต้องมี migration และการปรับ serialization เพิ่มด้วย ไม่ควรถือว่าการเปลี่ยนคลาส Java จะปรับสัญญาของ API หรือข้อมูลเก่าตามไปเอง

#แบบฝึกหัดสำหรับผู้เรียน

  1. เปลี่ยน Object[] ที่เก็บรหัสวิชา ชื่อวิชา และหน่วยกิตให้เป็นชนิดข้อมูลที่มีชื่อ แล้วอธิบายข้อผิดพลาดที่ compiler ช่วยตรวจได้
  2. ออกแบบ ReadingList ให้เพิ่มและลบหนังสือได้โดยผู้เรียกแก้รายการที่ getter คืนมาไม่ได้ พร้อมอธิบาย snapshot และ view
  3. เลือกแบบจำลองสำหรับ Customer, GridPosition และ SubscriptionPlan ว่าควรอาศัย identity หรือ value พร้อมเหตุผล
  4. เพิ่ม view อีกหนึ่งตัวให้ ScoreModel และตรวจสอบว่าทั้งสอง view แสดงค่าใหม่เมื่อ model เปลี่ยน
  5. เปรียบเทียบการใช้ field, subclass และ strategy สำหรับค่าจัดส่ง แล้วระบุเงื่อนไขที่ทำให้แต่ละแบบเหมาะสม