บันทึกสถานะของออบเจ็กต์เป็น Snapshot แล้วนำกลับมาใช้เมื่อต้องการย้อนการเปลี่ยนแปลง โดยไม่เปิดเผยรายละเอียดของสถานะให้ตัวจัดการประวัติ

บทความนี้อธิบาย Memento Pattern พร้อม Workshop สร้างโปรแกรมแก้ไขข้อความที่รองรับ Undo และ Redo ด้วย Java ใช้ไลบรารีมาตรฐานและโค้ดที่เขียนขึ้นสำหรับบทความนี้เท่านั้น

#1. Memento Pattern คืออะไร

info-memento-pattern-with-java

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

แนวคิดสำคัญมีสองส่วนที่ต้องทำร่วมกัน คือ การเก็บสถานะเดิม และ การรักษา Encapsulation ตัวจัดการประวัติไม่จำเป็นต้องรู้ว่า Snapshot มีข้อมูลอะไรบ้าง เพียงเก็บไว้และส่งกลับให้เจ้าของสถานะเมื่อต้องการ Restore

ตัวอย่างการใช้งานที่เหมาะสม ได้แก่ การย้อนการแก้ไขข้อความ การคืนค่ารูปร่างในโปรแกรมวาดภาพ และการทำ Checkpoint ของสถานะเกมที่อยู่ในหน่วยความจำ หากต้องการให้ Checkpoint อยู่ข้ามการปิดโปรแกรม ต้องเพิ่มระบบจัดเก็บข้อมูลถาวรด้วย

Memento เป็นวิธีเก็บและคืนสถานะ ส่วน Undo/Redo เป็นความสามารถที่เราสร้างเพิ่มจาก Memento โดยกำหนดนโยบายจัดการประวัติให้เหมาะกับโปรแกรม

#2. ปัญหาที่ต้องการแก้

สมมติโปรแกรมแก้ไขข้อความเปลี่ยนสถานะตามลำดับนี้

การกระทำ ข้อความปัจจุบัน
เริ่มต้น ""
เพิ่ม Hello Hello
เพิ่ม Java Hello Java
Undo Hello
Redo Hello Java

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

Memento แก้ปัญหาด้วยการให้ Editor เป็นผู้สร้างและอ่าน Snapshot ของตนเอง ส่วนตัวจัดการประวัติรับผิดชอบเฉพาะลำดับการจัดเก็บ

#3. องค์ประกอบหลัก

บทบาท หน้าที่ คลาสในตัวอย่าง
Originator เป็นเจ้าของสถานะ สร้าง Snapshot และ Restore TextEditor
Memento เก็บสถานะ ณ เวลาที่บันทึก TextEditor.Snapshot
Caretaker เก็บประวัติและเลือก Snapshot สำหรับ Undo/Redo History
Client เรียกการแก้ไขและการย้อนกลับ MementoDemo

ในตัวอย่าง Caretaker เห็น Snapshot ผ่านอินเทอร์เฟซ TextEditor.Memento ที่ไม่มีเมธอดอ่านข้อมูล ส่วนคลาส Snapshot จริงเป็น private ภายใน TextEditor

classDiagram
    class TextEditor {
        -String text
        +save() Memento
        +restore(Memento)
        +append(String)
        +getText() String
    }
    class Memento {
        <<interface>>
    }
    class Snapshot {
        -TextEditor owner
        -String text
    }
    class History {
        -TextEditor editor
        -Deque undoStack
        -Deque redoStack
        +append(String)
        +undo() boolean
        +redo() boolean
        +getText() String
    }
    TextEditor ..> Snapshot : creates and restores
    Snapshot ..|> Memento
    History --> TextEditor : manages
    History o--> Memento : stores

แผนภาพแสดงเมธอดที่ร่วมมือกันเพื่ออธิบายโครงสร้าง ส่วนการเข้าถึงจริงในโค้ดใช้ package access สำหรับ API ของ TextEditor และใช้ private สำหรับรายละเอียด Snapshot

#4. หลักการทำ Undo และ Redo

Workshop นี้ใช้ประวัติสองชุด โดยให้ข้อมูลที่เพิ่มล่าสุดอยู่บนสุดของแต่ละ Stack

#เมื่อแก้ไขข้อความ

  1. ตรวจสอบข้อมูลรับเข้า หากไม่ถูกต้องให้หยุดก่อนเปลี่ยนประวัติ
  2. บันทึกสถานะปัจจุบันลง undoStack
  3. เปลี่ยนข้อความใน Editor
  4. ล้าง redoStack เพราะเกิดเส้นทางการแก้ไขใหม่

#เมื่อ Undo

  1. หาก undoStack ว่าง ให้คืน false
  2. เก็บสถานะปัจจุบันลง redoStack
  3. นำ Snapshot บนสุดจาก undoStack มา Restore

#เมื่อ Redo

  1. หาก redoStack ว่าง ให้คืน false
  2. เก็บสถานะปัจจุบันลง undoStack
  3. นำ Snapshot บนสุดจาก redoStack มา Restore

จุดที่มักเขียนผิด: ถ้า Undo แล้วแก้ไขข้อมูลใหม่ ต้องล้าง Redo เดิมสำหรับประวัติแบบเส้นตรง เช่น Undo จาก Hello Java กลับเป็น Hello แล้วเพิ่ม Pattern ผลคือ Hello Pattern และไม่ควร Redo ไปเป็น Hello Java ได้อีก นโยบายนี้สอดคล้องกับพฤติกรรมเมื่อเพิ่ม Edit ใหม่ของ Java Swing UndoManager

#5. Workshop: โปรแกรมแก้ไขข้อความ

#สิ่งที่ต้องเตรียม

  • JDK 17 ขึ้นไปที่มี Java Compiler
  • Terminal หรือ IDE เช่น IntelliJ IDEA, Eclipse หรือ VS Code
  • ไม่ต้องใช้ Maven, Gradle หรือไลบรารีภายนอก

ตรวจสอบเครื่องมือด้วยคำสั่ง

java -version
javac -version

#สร้างไฟล์ MementoDemo.java

คัดลอกโค้ดทั้งหมดต่อไปนี้ลงในไฟล์ MementoDemo.java ชื่อไฟล์ต้องตรงกับคลาส public

import java.util.ArrayDeque;
import java.util.Deque;
import java.util.Objects;

public class MementoDemo {
    public static void main(String[] args) {
        History history = new History();
        show("Start", history);

        history.append("Hello");
        show("Append 1", history);

        history.append(" Java");
        show("Append 2", history);

        history.undo();
        show("Undo", history);

        history.redo();
        show("Redo", history);

        history.undo();
        history.append(" Pattern");
        show("New edit", history);

        System.out.println("Redo available: " + history.redo());

        history.undo();
        show("Undo 1", history);

        history.undo();
        show("Undo 2", history);

        System.out.println("Undo available: " + history.undo());
    }

    private static void show(String action, History history) {
        System.out.println(action + ": [" + history.getText() + "]");
    }
}

// Originator: owns the current state and understands its snapshots.
final class TextEditor {
    private String text = "";

    // Opaque handle: the caretaker cannot read the stored state.
    interface Memento {
    }

    // Memento implementation: only TextEditor can access its details.
    private static final class Snapshot implements Memento {
        private final TextEditor owner;
        private final String text;

        private Snapshot(TextEditor owner, String text) {
            this.owner = owner;
            this.text = text;
        }
    }

    Memento save() {
        return new Snapshot(this, text);
    }

    void restore(Memento memento) {
        if (!(memento instanceof Snapshot snapshot)
                || snapshot.owner != this) {
            throw new IllegalArgumentException(
                    "Snapshot does not belong to this editor");
        }
        text = snapshot.text;
    }

    void append(String suffix) {
        text = text.concat(Objects.requireNonNull(suffix, "suffix"));
    }

    String getText() {
        return text;
    }
}

// Caretaker: manages history without inspecting snapshot contents.
final class History {
    // Keep the editor private so clients cannot bypass history tracking.
    private final TextEditor editor = new TextEditor();
    private final Deque<TextEditor.Memento> undoStack = new ArrayDeque<>();
    private final Deque<TextEditor.Memento> redoStack = new ArrayDeque<>();

    void append(String suffix) {
        Objects.requireNonNull(suffix, "suffix");
        if (suffix.isEmpty()) {
            return; // No change: preserve both history stacks.
        }

        undoStack.push(editor.save());
        editor.append(suffix);
        redoStack.clear();
    }

    boolean undo() {
        if (undoStack.isEmpty()) {
            return false;
        }

        redoStack.push(editor.save());
        editor.restore(undoStack.pop());
        return true;
    }

    boolean redo() {
        if (redoStack.isEmpty()) {
            return false;
        }

        undoStack.push(editor.save());
        editor.restore(redoStack.pop());
        return true;
    }

    String getText() {
        return editor.getText();
    }
}

#คอมไพล์และรัน

javac -encoding UTF-8 MementoDemo.java
java MementoDemo

หรือใช้ Source-file Mode เพื่อทดลองโดยตรงด้วย JDK ที่มี Compiler

java MementoDemo.java

#ผลลัพธ์

Start: []
Append 1: [Hello]
Append 2: [Hello Java]
Undo: [Hello]
Redo: [Hello Java]
New edit: [Hello Pattern]
Redo available: false
Undo 1: [Hello]
Undo 2: []
Undo available: false

#6. อธิบายส่วนสำคัญของโค้ด

#6.1 Originator ตัดสินใจว่าจะบันทึกอะไร

TextEditor.save() สร้าง Snapshot จากสถานะปัจจุบัน ส่วน restore() อ่านสถานะนั้นกลับมา ตัวอย่างเก็บเฉพาะข้อความ หาก Editor เพิ่มตำแหน่งเคอร์เซอร์ ต้องเพิ่มการบันทึกและคืนค่าตำแหน่งนั้นด้วยเพื่อให้การ Restore ครบถ้วนตามความต้องการ

การ Restore ไม่จำเป็นต้องเก็บทุกฟิลด์ของออบเจ็กต์ ฟิลด์อย่าง Logger หรือการเชื่อมต่อฐานข้อมูลไม่ใช่สถานะเอกสารที่ควรคัดลอกกลับตามประวัติ

#6.2 Snapshot ไม่เปิดเผยสถานะให้ Caretaker

Memento เป็นอินเทอร์เฟซว่าง ขณะที่ Snapshot และฟิลด์ข้อมูลเป็น private ดังนั้น History เก็บ Reference ได้ แต่ไม่มี API ให้อ่านหรือแก้ข้อความใน Snapshot

การใช้อินเทอร์เฟซไม่ได้ห้ามคลาสอื่นสร้าง Implementation ใหม่ จึงมีการตรวจสอบชนิดใน restore() และตรวจ owner เพื่อปฏิเสธ Snapshot ที่ไม่ได้มาจาก Editor ตัวนี้ วิธีนี้ป้องกันการใช้ Snapshot ผิดออบเจ็กต์ใน API ปกติ ไม่ใช่กลไกรักษาความปลอดภัยสำหรับโค้ดที่ไม่เชื่อถือ

#6.3 ใช้ String ที่แก้ค่าเดิมไม่ได้

ฟิลด์ text ใน Snapshot เป็น final และอ้างถึง String ซึ่งเป็น Immutable จึงสามารถใช้ Reference ของข้อความเดิมได้อย่างปลอดภัย เมื่อแก้ไขข้อความ Editor จะอ้างถึง String ผลลัพธ์ใหม่ และ Snapshot เก่ายังคงแสดงข้อความเดิม ดูคุณสมบัติ Immutable ของ String ได้ใน Java String API

ถ้าเปลี่ยนเป็น StringBuilder, Array หรือ List ที่มีออบเจ็กต์แก้ค่าได้ การกำหนด final เพียงอย่างเดียวไม่ทำให้ข้อมูลภายใน Immutable ต้องคัดลอกข้อมูลและป้องกันการแก้ไขตามโครงสร้างจริง

#6.4 เก็บสถานะก่อนแก้ไข

History.append() เรียก editor.save() ก่อน editor.append() เพื่อให้ Undo ย้อนกลับไปยังสถานะก่อนหน้าของการแก้ไขนั้นได้ รวมถึงสถานะเริ่มต้นที่เป็นข้อความว่าง

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

#6.5 History ควบคุมเส้นทางการแก้ไข

Editor ที่ History ใช้อยู่เป็นฟิลด์ private และไม่ส่งออก Reference ให้ Client ทุกการเปลี่ยนข้อความจึงผ่าน History.append() หากเปิดให้ Client เรียกแก้ Editor โดยตรง ประวัติที่เก็บอยู่อาจไม่สอดคล้องกับสถานะจริง

#6.6 ใช้ Deque เป็น Stack

push() เพิ่ม Snapshot ที่หัวของ Deque และ pop() นำ Snapshot ล่าสุดออก ตัวอย่างใช้ ArrayDeque เป็น Implementation และตรวจ isEmpty() ก่อนนำข้อมูลออก อ่านรายละเอียดได้ใน Java ArrayDeque API

#7. ติดตามประวัติทีละขั้น

ในตารางนี้ ด้านซ้ายคือบนสุดของ Stack และ ∅ หมายถึงไม่มีรายการ ส่วน "" คือ Snapshot ที่มีข้อความว่างซึ่งนับเป็นหนึ่งรายการ

หลังการกระทำ ข้อความปัจจุบัน Undo Stack Redo Stack
เริ่มต้น "" ∅ ∅
Append Hello Hello "" ∅
Append Java Hello Java Hello, "" ∅
Undo Hello "" Hello Java
Redo Hello Java Hello, "" ∅
Undo Hello "" Hello Java
Append Pattern Hello Pattern Hello, "" ∅

สังเกตว่าทั้งสอง Stack ไม่จำเป็นต้องเก็บสถานะปัจจุบันไว้ตลอดเวลา สถานะปัจจุบันอยู่ใน Editor และถูกบันทึกลงอีก Stack ก่อน Undo หรือ Redo

#8. กรณีที่ควรตรวจสอบ

กรณี พฤติกรรมที่ต้องการ
Undo ก่อนแก้ไขครั้งแรก คืน false และข้อความยังว่าง
Redo ก่อนมี Undo คืน false
Undo หลายครั้งจนถึงจุดเริ่มต้น คืนข้อความว่าง และ Undo เพิ่มไม่ได้
Redo หลายครั้งหลัง Undo คืนสถานะตามลำดับเดิม
Undo แล้วแก้ไขข้อความใหม่ ล้าง Redo เดิม
Append ข้อความว่าง ไม่เพิ่มประวัติและไม่ล้าง Redo
Append null เกิด NullPointerException โดยสถานะและประวัติไม่เปลี่ยน
สร้าง Snapshot แล้วแก้ Editor Snapshot เดิมยังคืนค่าเดิมได้
Restore Snapshot ของ Editor คนละตัว เกิด IllegalArgumentException

ข้อกำหนดสำหรับข้อความว่างและ null เป็นนโยบายของตัวอย่างนี้ สามารถปรับได้ตามพฤติกรรมที่ระบบต้องการ

#9. ประสิทธิภาพและข้อจำกัด

  • ประวัติใช้หน่วยความจำ: Snapshot ในตัวอย่างเก็บ Reference ของ String เดิมโดยไม่คัดลอกตัวอักษรทันที แต่การต่อข้อความอาจสร้าง String ใหม่ และประวัติจะทำให้ String รุ่นเก่ายังไม่ถูกเก็บคืนโดย Garbage Collector
  • ข้อความที่โตขึ้นเรื่อย ๆ อาจมีต้นทุนสะสมสูง: ถ้าเพิ่มทีละตัวและเก็บทุกเวอร์ชัน ขนาดรวมของข้อความทุกรุ่นอาจเพิ่มแบบกำลังสองตามความยาวสุดท้าย ควรจำกัดประวัติหรือรวมการพิมพ์หลายครั้งเป็นหนึ่ง Edit
  • ต้นทุนขึ้นกับชนิดสถานะ: การสร้าง Snapshot และ Restore ในตัวอย่างนี้เปลี่ยน Reference จึงใช้เวลาคงที่ ส่วนการต่อ String ใช้เวลาตามขนาดผลลัพธ์ สำหรับสถานะที่ต้อง Deep Copy ต้นทุน Snapshot จะเพิ่มตามข้อมูลที่คัดลอก
  • Stack มีต้นทุนเพิ่มเติม: การ Push/Pop ของ ArrayDeque ใช้เวลาคงที่โดยเฉลี่ยแบบ amortized ส่วนการล้าง Redo มีต้นทุนตามจำนวนรายการที่ลบ
  • ประวัติอยู่ในหน่วยความจำ: ตัวอย่างยังไม่บันทึกลงไฟล์หรือฐานข้อมูล และจะสูญหายเมื่อจบโปรแกรม
  • ใช้แบบ Thread เดียว: ตัวอย่างไม่ได้ออกแบบสำหรับการแก้ไขพร้อมกัน หากมีหลาย Thread ต้องประสาน Editor และทั้งสอง Stack ภายใต้กลไกเดียวกัน

#10. Memento ต่างจาก Pattern ที่เกี่ยวข้องอย่างไร

แนวคิด สิ่งที่เน้น ความสัมพันธ์กับตัวอย่าง
Memento บันทึกและคืนสถานะโดยรักษา Encapsulation Snapshot เก็บข้อความเดิม
Command ห่อการกระทำเป็นออบเจ็กต์ อาจสร้าง AppendCommand และใช้ Memento เก็บสถานะก่อนสั่งงาน
State เปลี่ยนพฤติกรรมตามสถานะปัจจุบัน ไม่ได้จัดการประวัติย้อนหลังให้โดยอัตโนมัติ
Prototype สร้างออบเจ็กต์จากการคัดลอกต้นแบบ การ Copy อาจช่วยสร้าง Snapshot แต่ไม่ครอบคลุมการจัดการ Undo/Redo

Memento และ Command ใช้ร่วมกันได้ โดย Command รับผิดชอบคำสั่ง ส่วน Memento ช่วยเก็บสถานะก่อนคำสั่งที่ย้อนด้วยวิธีคำนวณกลับได้ยาก

การคืนสถานะออบเจ็กต์ในหน่วยความจำไม่ได้ย้อนผลข้างเคียงภายนอก เช่น อีเมลที่ส่งไปแล้วหรือรายการที่บันทึกในอีกระบบ กรณีนั้นต้องออกแบบ Transaction หรือการชดเชยแยกต่างหาก

#11. ตัวอย่างที่เกี่ยวข้องใน Java Swing

Java มี API สำหรับ Undo/Redo อยู่ในแพ็กเกจ javax.swing.undo โดย StateEdit ขอให้ StateEditable เก็บสถานะก่อนและหลังการแก้ไข แล้วสั่งให้คืนสถานะเมื่อ Undo/Redo ส่วน UndoManager จัดการรายการ Edit และลำดับการย้อนกลับ

API เหล่านี้เป็นตัวอย่างที่ช่วยเชื่อมแนวคิดกับ Java ส่วน Workshop ในบทความตั้งใจแยก Originator, Memento และ Caretaker ให้เห็นชัด จึงไม่ได้เรียก Swing และไม่มีหน้าจอ GUI

#12. แบบฝึกหัดต่อยอด

  1. เพิ่มตำแหน่งเคอร์เซอร์ใน Editor และ Snapshot แล้วทำให้ Undo คืนทั้งข้อความและตำแหน่งเคอร์เซอร์ โดยกำหนดให้ชัดว่าการเลื่อนเคอร์เซอร์อย่างเดียวสร้างประวัติหรือไม่
  2. จำกัดจำนวน Snapshot ของ Undo ไว้ 20 รายการ เมื่อล้นให้ลบรายการเก่าสุดที่ท้าย Deque
  3. เพิ่ม canUndo() และ canRedo() เพื่อให้ GUI เปิดหรือปิดปุ่มได้โดยไม่ต้องลองเรียกคำสั่ง
  4. เพิ่มคำสั่งลบข้อความและทดสอบ Undo/Redo สลับกับคำสั่งเพิ่มข้อความ
  5. รวมการพิมพ์หลายตัวอักษรเป็นหนึ่ง Edit เพื่อลดจำนวน Snapshot และให้ Undo เป็นกลุ่มคำ

#13. ประเด็นสำหรับทบทวน

  • Originator เป็นผู้รู้โครงสร้างสถานะและเป็นผู้ Restore
  • Memento เก็บสถานะที่ไม่ควรถูกแก้ผ่าน Caretaker
  • Caretaker รู้ลำดับประวัติ โดยไม่ต้องรู้ข้อมูลใน Snapshot
  • ประวัติ Undo/Redo แบบเส้นตรงต้องล้าง Redo เมื่อเกิดการแก้ไขใหม่หลัง Undo
  • final ไม่ได้ทำให้โครงสร้างข้อมูลที่แก้ค่าได้กลายเป็น Immutable

#แหล่งอ้างอิง

  1. Gamma, E., Helm, R., Johnson, R., & Vlissides, J. (1994). Design Patterns: Elements of Reusable Object-Oriented Software. Addison-Wesley. หัวข้อ Memento — แหล่งอ้างอิงพื้นฐานของ Pattern
  2. Oracle. StateEdit — Java SE 21 API
  3. Oracle. UndoManager — Java SE 21 API
  4. Oracle. ArrayDeque — Java SE 21 API
  5. Oracle. String — Java SE 21 API

โค้ด Workshop เขียนขึ้นสำหรับบทความนี้ ใช้ไวยากรณ์ Java 17 และไม่ขึ้นกับ Java Swing API ที่นำมาอ้างอิงประกอบ

การตรวจสอบตัวอย่าง: คอมไพล์และรันด้วย OpenJDK 17 ตรวจผลลัพธ์ Console ตรงตามบทความ และผ่านการตรวจสอบพฤติกรรม 17 รายการ ครอบคลุม Undo/Redo หลายขั้น การสร้างเส้นทางการแก้ไขใหม่ ข้อความว่าง ค่า null ความคงเดิมของ Snapshot และการปฏิเสธ Snapshot ต่างเจ้าของ