java-recipes

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

DP-14: Command パターン

操作をオブジェクトとして表現し、Undo/Redo やキューイング(順番待ち処理)を実現するパターンです。 テキストエディタの「元に戻す(Ctrl+Z)」機能を例に、Java 8 / 17 / 21 で解説します。

Command パターンとは

通常、メソッドは「呼び出されたその場で実行される」ものです。しかし Command パターンでは、 「何を実行するか」をオブジェクトとして表現します。 操作をオブジェクト化することで、実行をあとに回したり(キューイング)、 実行した操作を記録して取り消したり(Undo)、取り消した操作をもう一度やり直したり(Redo)することが可能になります。

Command パターンの構成要素

  • Command インターフェース: execute()undo() を定義する
  • ConcreteCommand(具体的なコマンド): 実際の操作を実装する(InsertCommand、DeleteCommand など)
  • Receiver(受信者): 実際の処理を行うオブジェクト(TextEditor など)
  • Invoker(呼び出し側): コマンドを受け取って実行・履歴管理する(CommandHistory など)

Java 標準ライブラリでは javax.swing.Action インターフェースが Command パターンの代表例です。 メニュー項目とツールバーボタンが同じ Action オブジェクトを共有できるため、 「コピー」「ペースト」などの操作を一か所で定義して複数のUI部品から呼び出せます。

Command パターンが役立つ場面

  • テキストエディタの Undo/Redo 機能
  • データベーストランザクション(コミット・ロールバック)
  • タスクキュー(操作を溜めておいて後でまとめて実行する)
  • マクロ記録(複数の操作をまとめて再実行する)

サンプルコード

CommandPatternSample.java
import java.util.ArrayDeque;
import java.util.Deque;

public class CommandPatternSample {

    // テキストエディタ本体: StringBuilder でテキストを保持する
    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();
        }
    }

    // Command インターフェース: 操作の実行と取り消しを定義する
    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);
        }

        // undo: 挿入したテキストを削除する
        @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;   // undo 時に復元するために保持する

        public DeleteCommand(TextEditor editor, int pos, int len) {
            this.editor = editor;
            this.pos = pos;
            this.len = len;
        }

        @Override
        public void execute() {
            // execute 時に削除する文字列を保存しておく(undo で復元するため)
            deletedText = editor.getText().substring(pos, pos + len);
            editor.deleteText(pos, len);
        }

        // undo: 削除したテキストを元の位置に戻す
        @Override
        public void undo() {
            editor.insertText(pos, deletedText);
        }
    }

    // コマンド履歴を管理するクラス: Undo/Redo を実現する
    static class CommandHistory {
        private final Deque<Command> history = new ArrayDeque<Command>(); // 実行済みコマンドのスタック
        private final Deque<Command> redoStack = new ArrayDeque<Command>(); // redo 用スタック

        // コマンドを実行して履歴に積む
        public void execute(Command command) {
            command.execute();
            history.push(command);
            redoStack.clear(); // 新しい操作をしたら redo 履歴をクリアする
        }

        // 直前の操作を取り消す
        public void undo() {
            if (history.isEmpty()) {
                System.out.println("[Undo] 取り消す操作がありません");
                return;
            }
            Command command = history.pop();
            command.undo();
            redoStack.push(command);
        }

        // 取り消した操作をやり直す
        public void redo() {
            if (redoStack.isEmpty()) {
                System.out.println("[Redo] やり直す操作がありません");
                return;
            }
            Command command = redoStack.pop();
            command.execute();
            history.push(command);
        }

        // 現在の履歴件数を返す
        public int getHistorySize() {
            return history.size();
        }
    }

    public static void main(String[] args) {
        System.out.println("=== Command パターン: テキストエディタ Undo/Redo ===");

        TextEditor editor = new TextEditor();
        CommandHistory commandHistory = new CommandHistory();

        // 操作1: "Hello" を挿入する
        commandHistory.execute(new InsertCommand(editor, 0, "Hello"));
        System.out.println("挿入後: \"" + editor.getText() + "\"");

        // 操作2: ", Java" を末尾に挿入する
        commandHistory.execute(new InsertCommand(editor, 5, ", Java"));
        System.out.println("挿入後: \"" + editor.getText() + "\"");

        // 操作3: 末尾の "Java" を削除する
        commandHistory.execute(new DeleteCommand(editor, 7, 4));
        System.out.println("削除後: \"" + editor.getText() + "\"");

        System.out.println("履歴件数: " + commandHistory.getHistorySize());

        System.out.println("\n--- Undo 操作 ---");
        // Undo: 削除をなかったことにする
        commandHistory.undo();
        System.out.println("Undo 後: \"" + editor.getText() + "\"");

        // Undo: 2番目の挿入をなかったことにする
        commandHistory.undo();
        System.out.println("Undo 後: \"" + editor.getText() + "\"");

        System.out.println("\n--- Redo 操作 ---");
        // Redo: 2番目の挿入をやり直す
        commandHistory.redo();
        System.out.println("Redo 後: \"" + editor.getText() + "\"");

        System.out.println("\n--- 空の履歴で Undo ---");
        // 履歴を全て取り消す
        commandHistory.undo();
        commandHistory.undo();
        // これ以上 Undo するものがない
        commandHistory.undo();
    }
}

Java 8 では Command インターフェースに execute() と undo() を定義し、各コマンドクラスがそれぞれ実装します。DeleteCommand は execute() 時に削除したテキストを保存しておくことで undo() 時に復元できます。

よくあるミス・注意点

⚠️ undo() の実装を忘れる(execute() だけ実装してしまう)

Command インターフェースに undo() を定義しても、 各コマンドクラスに正しく実装しなければ Undo 機能は動作しません。 特に DeleteCommand は execute() 時に「削除した文字列」を保存しておかないと undo() 時に復元できないため、 execute() と undo() の両方を実装する際は「undo に必要な情報を execute 時に保存する」という設計が重要です。

⚠️ 新しい操作をしたときに redo 履歴をクリアしない

Undo した後に新しい操作を行ったときは、それ以前の redo 履歴をクリアする必要があります。 これをしないと「Undo → 新しい操作 → Redo」で意図しない操作が復元されてしまいます。 サンプルコードでは execute() の中でredoStack.clear() を呼び出してこれを防いでいます。

⚠️ 空の履歴で undo() を呼び出して例外が発生する

履歴が空の状態で undo() を呼び出すと、 スタックが空なので pop() で例外が発生する可能性があります。 必ず isEmpty() で確認してから処理するようにしましょう。

テストする観点

  • execute() を呼び出した後、テキストが期待通りに変化していること
  • execute() → undo() の順に呼び出すと、元の状態に戻ること(境界値: 1回の操作、複数回の操作)
  • undo() → redo() の順に呼び出すと、undo 前の状態に戻ること
  • 空の履歴で undo() を呼び出しても例外が発生しないこと(境界値: 履歴が空)
  • 空の redo スタックで redo() を呼び出しても例外が発生しないこと
  • Undo した後に新しい操作を行うと redo 履歴がクリアされること
  • InsertCommand と DeleteCommand を組み合わせたとき、undo/redo が正しく連動すること

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