java-recipes

ホーム デザインパターン › DP-18

DP-18: Memento パターン

オブジェクトの内部状態をスナップショットとして保存し、後で復元できるパターンです。 カプセル化を壊さずに状態の保存・復元(Undo/Redo)を実現します。 テキストエディタやゲームのセーブ機能が典型的な応用例です。

Memento パターンとは

テキストエディタで「Ctrl+Z」を押すと直前の状態に戻せます。この Undo 機能を実装するには 「過去の状態をどこかに保存しておく」必要があります。しかし単純に外部クラスがエディタの内部フィールドを 直接読み書きすると、カプセル化が壊れてしまいます。 Memento パターンはこの問題を解決します。エディタ(Originator)が自分自身でスナップショット(Memento)を作成し、 履歴管理クラス(Caretaker)はそのスナップショットを保持するだけで中身には触れません。

Memento パターンの登場人物

  • Memento(スナップショット): 状態のコピーを保持する不変オブジェクト(例: EditorMemento)
  • Originator(生成者): 状態を持ち、Memento の作成と復元を担う(例: TextEditor)
  • Caretaker(管理者): Memento を保持するが中身にはアクセスしない(例: EditorHistory)

「Caretaker は Memento を持っているが、中身を知らない」というカプセル化の原則が このパターンの最も重要なポイントです。 Memento の内容にアクセスできるのは Originator だけです。

サンプルコード

MementoPatternSample.java
import java.util.ArrayDeque;
import java.util.Deque;

public class MementoPatternSample {

    // Memento: 状態のスナップショット(外部からは内部を見せない)
    static class EditorMemento {
        private final String content;  // 保存するテキスト内容
        private final int cursorPos;   // カーソル位置

        // Originator だけが作成・内容アクセス可能
        EditorMemento(String content, int cursorPos) {
            this.content = content;
            this.cursorPos = cursorPos;
        }

        // Originator のみが呼び出す
        String getContent() {
            return content;
        }

        int getCursorPos() {
            return cursorPos;
        }
    }

    // Originator: 状態を持ち、Memento を作成・復元する
    static class TextEditor {
        private String content = "";
        private int cursorPos = 0;

        void type(String text) {
            content = content.substring(0, cursorPos) + text + content.substring(cursorPos);
            cursorPos += text.length();
        }

        void delete(int count) {
            int start = Math.max(0, cursorPos - count);
            content = content.substring(0, start) + content.substring(cursorPos);
            cursorPos = start;
        }

        // 現在の状態をスナップショットとして保存
        EditorMemento save() {
            return new EditorMemento(content, cursorPos);
        }

        // スナップショットから状態を復元
        void restore(EditorMemento memento) {
            this.content = memento.getContent();
            this.cursorPos = memento.getCursorPos();
        }

        void display() {
            System.out.println("  内容: \"" + content + "\" (カーソル位置: " + cursorPos + ")");
        }
    }

    // Caretaker: Memento を管理するが、内容には触れない
    static class EditorHistory {
        private final Deque<EditorMemento> undoStack = new ArrayDeque<>();
        private final Deque<EditorMemento> redoStack = new ArrayDeque<>();

        void save(EditorMemento memento) {
            undoStack.push(memento);
            redoStack.clear(); // 新しい操作をしたら Redo 履歴をクリア
        }

        EditorMemento undo() {
            if (undoStack.isEmpty()) {
                return null;
            }
            EditorMemento memento = undoStack.pop();
            redoStack.push(memento);
            if (!undoStack.isEmpty()) {
                return undoStack.peek();
            }
            return new EditorMemento("", 0);
        }

        boolean canUndo() {
            return !undoStack.isEmpty();
        }
    }

    public static void main(String[] args) {
        TextEditor editor = new TextEditor();
        EditorHistory history = new EditorHistory();

        System.out.println("=== 初期状態 ===");
        editor.display();

        System.out.println("\n=== 「Hello」と入力 ===");
        history.save(editor.save()); // 現在の状態を保存
        editor.type("Hello");
        editor.display();

        System.out.println("\n=== 「 World」と入力 ===");
        history.save(editor.save());
        editor.type(" World");
        editor.display();

        System.out.println("\n=== 「!!!」と入力 ===");
        history.save(editor.save());
        editor.type("!!!");
        editor.display();

        System.out.println("\n=== Undo(1回目)===");
        if (history.canUndo()) {
            EditorMemento prev = history.undo();
            if (prev != null) {
                editor.restore(prev);
            }
        }
        editor.display();

        System.out.println("\n=== Undo(2回目)===");
        if (history.canUndo()) {
            EditorMemento prev = history.undo();
            if (prev != null) {
                editor.restore(prev);
            }
        }
        editor.display();
    }
}

Java 8 では EditorMemento クラスのアクセス修飾子を工夫してカプセル化を保ちます。Caretaker(EditorHistory)は Memento を保持しますが内容にはアクセスせず、Originator(TextEditor)だけが Memento の中身を読み書きします。

よくあるミス・注意点

⚠️ Memento に可変オブジェクトをそのまま格納してしまう

Memento がリストや配列の参照を保持している場合、保存後に元のオブジェクトが変更されると Memento の内容も変わってしまいます。 Memento に格納するとき、コレクションや配列は必ずディープコピー(値のコピー)を作りましょう。

⚠️ Undo のたびに save() を呼び忘れる

状態を変更する前に history.save(editor.save()) を 呼ばないと、その操作を Undo できません。 「操作の直前に保存」というルールを徹底するか、エディタ内部で自動保存する設計にしましょう。

⚠️ 大量の状態保存によるメモリ消費

1回の操作ごとに全状態をコピーすると、特に大きなデータを扱う場合にメモリを大量消費します。 「変更差分だけを保存する」方式や「保存履歴の上限数を設ける」といった工夫が実務では必要になります。

テストする観点

  • save() → 操作 → restore() の後、元の状態に戻ること
  • Undo を複数回実行して、操作前の状態まで正しく遡れること
  • 保存なしで Undo しようとしたとき、エラーが起きずに何も変わらないこと(境界値)
  • Memento 取得後に Originator の状態を変更しても、Memento の内容が変わらないこと(不変性確認)
  • delete() で境界値(カーソルが先頭の状態で削除)を試しても範囲外エラーが起きないこと
  • Caretaker(EditorHistory)が Memento の具体的な内容(文字列・カーソル位置)にアクセスしていないこと

GitHub でソースコードを見る →