概要

操作そのものをオブジェクトとして表現し、実行・取り消し・やり直しを統一的に扱う。Command パターンは、テキストエディタの Undo/Redo やマクロ記録、トランザクションの補償処理など、操作の履歴管理が必要な場面で広く使われます。操作をオブジェクト化することで、実行と取り消しのロジックを1つのクラスに閉じ込められ、履歴のスタック管理も自然に書けます。この記事では、テキストエディタの挿入・削除操作を Command として実装し、Deque による Undo/Redo スタックの管理方法を整理します。Java 17 の record で操作結果を簡潔に返す方法、Java 21 の sealed interface でコマンドの種類を型安全に限定する方法も確認します。

使いどころ

テキストエディタの挿入・削除操作を Command オブジェクトとして記録し、Ctrl+Z / Ctrl+Y で Undo/Redo する

業務システムの一括更新処理をコマンド列として構築し、途中でエラーが起きたら逆順に補償トランザクションを実行する

ユーザーの操作ログをコマンドオブジェクトとして保存し、操作の再生やマクロ実行に利用する

コード例

CommandPatternDemo.java
import java.util.ArrayDeque;
import java.util.Deque;

public class CommandPatternDemo {

    static class TextEditor {
        private StringBuilder text = new StringBuilder();
        public void insertText(int pos, String str) {
            text.insert(pos, str);
        }
        public void deleteText(int pos, int len) {
            text.delete(pos, pos + len);
        }
        public String getText() { return text.toString(); }
    }

    interface Command {
        void execute();
        void undo();
    }

    static class InsertCommand implements Command {
        private final TextEditor editor;
        private final int pos;
        private final String str;
        public InsertCommand(TextEditor editor, int pos, String str) {
            this.editor = editor; this.pos = pos; this.str = str;
        }
        @Override public void execute() { editor.insertText(pos, str); }
        @Override public void undo() { editor.deleteText(pos, str.length()); }
    }

    static class DeleteCommand implements Command {
        private final TextEditor editor;
        private final int pos;
        private final int len;
        private String deletedText;
        public DeleteCommand(TextEditor editor, int pos, int len) {
            this.editor = editor; this.pos = pos; this.len = len;
        }
        @Override public void execute() {
            deletedText = editor.getText().substring(pos, pos + len);
            editor.deleteText(pos, len);
        }
        @Override public void undo() { editor.insertText(pos, deletedText); }
    }

    record CommandResult(boolean success, String text, int historySize) {}

    static class CommandHistory {
        private final Deque<Command> history = new ArrayDeque<>();
        private final Deque<Command> redoStack = new ArrayDeque<>();

        public CommandResult execute(Command cmd, TextEditor ed) {
            cmd.execute();
            history.push(cmd);
            redoStack.clear();
            return new CommandResult(true, ed.getText(), history.size());
        }
        public CommandResult undo(TextEditor ed) {
            if (history.isEmpty()) return new CommandResult(false, ed.getText(), 0);
            var cmd = history.pop();
            cmd.undo();
            redoStack.push(cmd);
            return new CommandResult(true, ed.getText(), history.size());
        }
        public CommandResult redo(TextEditor ed) {
            if (redoStack.isEmpty()) return new CommandResult(false, ed.getText(), history.size());
            var cmd = redoStack.pop();
            cmd.execute();
            history.push(cmd);
            return new CommandResult(true, ed.getText(), history.size());
        }
    }

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

        var r1 = history.execute(new InsertCommand(editor, 0, "Hello"), editor);
        System.out.println("挿入後: " + r1.text());

        var r2 = history.execute(new InsertCommand(editor, 5, ", Java"), editor);
        System.out.println("挿入後: " + r2.text());

        var u1 = history.undo(editor);
        System.out.println("Undo後: " + u1.text());

        var rd = history.redo(editor);
        System.out.println("Redo後: " + rd.text());
    }
}

Java 8 / 17 / 21 の完全なサンプルコードは GitHub リポジトリ で確認できます。

Version Coverage

record で CommandResult を定義でき、コマンド実行結果を簡潔に表現できる。var による型推論で履歴操作も読みやすい。

Java 17
// Java 17: record で実行結果をまとめて返す
record CommandResult(boolean success,
    String currentText, int historySize) {}

public CommandResult execute(Command cmd, TextEditor ed) {
    cmd.execute();
    history.push(cmd);
    redoStack.clear();
    return new CommandResult(true, ed.getText(),
                             history.size());
}

Library Comparison

標準 API(interface + Deque)Undo/Redo の履歴管理を自前で制御したい場面。外部依存なしで完結する。マクロ記録やバッチ実行など高度な機能が必要になると自前のコードが増える。
javax.swing.undo.UndoManagerSwing アプリケーションで UndoableEdit を使ったテキスト編集の Undo/Redo を行う場合。Swing 前提のため、Web やバッチ処理では利用できない。

注意点

DeleteCommand の undo で元のテキストを復元するためには、execute 時に削除した文字列を保持しておく必要がある。保存し忘れると undo で復元できない

Redo スタックは新しいコマンドを実行したらクリアするのが一般的。クリアしないと Undo → 新操作 → Redo で不整合が起きる

コマンドオブジェクトが Receiver(エディタ等)への参照を保持するため、履歴が長くなるとメモリを圧迫する。履歴件数の上限を設けることを検討する

マルチスレッド環境でコマンド履歴を共有する場合は同期が必要。ConcurrentLinkedDeque や synchronized ブロックでの保護を検討する

FAQ

Command パターンと Strategy パターンの違いは何ですか。

Command は操作のオブジェクト化で、実行と取り消しをセットにします。Strategy はアルゴリズムの差し替えが目的で、取り消しの概念は含みません。

Undo の上限を設けるにはどうすればよいですか。

Deque のサイズが上限を超えたら removeLast() で古い履歴を破棄します。LinkedList ベースの Deque なら O(1) で末尾を除去できます。

マクロ記録はどう実現しますか。

コマンドのリストを保持する MacroCommand を作り、execute で全コマンドを順に実行します。GoF の Composite と組み合わせた形です。

関連書籍

この記事のテーマをさらに深く学びたい方へ。

※ Amazon アソシエイトリンクを含みます