StackベースのUndo/RedoをC#で実装する|CompositeCommandで複数操作も一括取り消し

2026-04-17
Contents

はじめに

楽譜エディタ「PICOM」を作り始めて、Undo/Redoがないと不便だと感じ実装しました。おそらく、多くの編集系UIを持つアプリでは必須機能だと思います。

そこで、この記事では、PICOMで実装したUndo/Redoの仕組みを紹介します。教科書通りのCommandパターンですが、実際に使えるものにするには「複数操作を1手としてまとめたい」「履歴が無限に増えるのは困る」といった現実的な課題があります。そのあたりをどう解決したかを具体的に書きます。

📌この記事の要約

ICommandインターフェースを定義し、UndoRedoManagerが2本のStackで履歴を管理します。CompositeCommandで複数のコマンドを1手にまとめ、ピアノロール上の複数音符を同時に移動するような操作も一発でUndoできます。履歴には上限を設けてメモリリークを防いでいます。

Undo/Redoの仕組みを考える

Undo/Redoを愚直に実装する方法として、操作をするたびに状態を時系列に沿って記憶するという方法が思いつきます。しかし、この方法ではメモリ効率も悪く、状態管理も非常に複雑化します。

そこで、デザインパターンであるCommandパターンを利用します。Commandパターンでは、状態ではなく 操作 を記憶するため、以前の状態に復元したり、次の状態へ進めたりなどが容易に実現できます。 続いて、実際の実装を見ていきましょう。

ICommand:最小インターフェースから始める

Commandパターンの出発点は「実行」と「取り消し」を1ペアで持つインターフェースです。PICOMでは次のように定義しています。

public interface ICommand
{
    /// <summary>コマンドを実行する</summary>
    void Execute();

    /// <summary>コマンドを取り消す</summary>
    void Undo();

    /// <summary>コマンドの説明(デバッグ/UI表示用)</summary>
    string Description { get; }
}

ポイントはDescriptionを入れていることです。履歴一覧を出したり、デバッグでConsole.WriteLineする時に、どのコマンドが何をしたのかを文字列で追えるとデバッグの効率が雲泥の差です。.NET標準のSystem.Windows.Input.ICommandとは別物なので、名前が被る場合は名前空間を明示するかリネームしてください。PICOMはBlazor WASMで実装しているため、System.WindowsICommandと衝突することはなかったです。

具体的なコマンドの実装例として、音符追加コマンドを載せておきます。

public class AddNoteCommand : ICommand
{
    private readonly Track _track;
    private readonly int _position128Note;
    private readonly SoundComponentBase _component;

    public string Description => $"ノート追加 at {_position128Note}";

    public AddNoteCommand(Track track, int position128Note, SoundComponentBase component)
    {
        _track = track;
        _position128Note = position128Note;
        _component = component;
    }

    public void Execute() => _track.AddAt(_position128Note, _component);
    public void Undo() => _track.RemoveAt(_position128Note, _component);
}

ExecuteUndoが対称になっているのがCommandパターンの気持ちよさです。Undoで失敗するような操作は原則として作らない、というのが運用上の鉄則です。

UndoRedoManager:2本のStackで履歴を管理

履歴の管理本体はUndoRedoManagerです。_undoStack_redoStackの2本で押したり引いたりします。

public class UndoRedoManager
{
    private readonly Stack<ICommand> _undoStack = new();
    private readonly Stack<ICommand> _redoStack = new();
    private readonly int _maxHistorySize;

    public event Action? StateChanged;

    public UndoRedoManager(int maxHistorySize = 100)
    {
        _maxHistorySize = maxHistorySize;
    }

    public bool CanUndo => _undoStack.Count > 0;
    public bool CanRedo => _redoStack.Count > 0;

    public void Execute(ICommand command)
    {
        command.Execute();
        _undoStack.Push(command);
        _redoStack.Clear();
        TrimHistory();
        StateChanged?.Invoke();
    }

    public void Undo()
    {
        if (!CanUndo) return;
        var command = _undoStack.Pop();
        command.Undo();
        _redoStack.Push(command);
        StateChanged?.Invoke();
    }

    public void Redo()
    {
        if (!CanRedo) return;
        var command = _redoStack.Pop();
        command.Execute();
        _undoStack.Push(command);
        StateChanged?.Invoke();
    }
}

ここで注目ポイントが2つあります。

1つ目はExecuteの中で_redoStack.Clear()を呼んでいる点です。Undoした後に新しい操作を実行したら、「未来の枝」(Redo可能だった履歴)は全部捨てるのが一般的な挙動です。Adobe系のアプリでUndo→別の操作をするとRedoが効かなくなるのと同じ挙動で、ユーザーが違和感なく使えます。

2つ目はStateChangedイベントです。Blazor側ではこのイベントに購読して、UndoボタンとRedoボタンのdisabled属性をCanUndo / CanRedoに連動させます。イベント駆動にしておけば、どこからコマンドを実行してもUIが自動で追従します。

TrimHistory:履歴上限でメモリリークを防ぐ

長時間編集していると履歴はどんどん積み上がります。PICOMの楽譜エディタでは、音符の配置・削除・移動だけでも1時間の編集で数百件を超えるので、履歴を無制限に持っているとメモリが膨れ上がる恐れがあります。

そこで、最大履歴数を超えたら古いものから捨てるTrimHistoryを実装しました。

private void TrimHistory()
{
    while (_undoStack.Count > _maxHistorySize)
    {
        var list = _undoStack.ToList();
        list.RemoveAt(list.Count - 1);
        _undoStack.Clear();
        foreach (var item in list.AsEnumerable().Reverse())
        {
            _undoStack.Push(item);
        }
    }
}

Stack<T>は最古要素を直接削除するAPIを持っていないので、一度Listに展開して最後(最古)を削り、再度Pushし直しています。愚直ですが確実で、_maxHistorySize = 100程度なら実行コストは無視できる範囲です。

メリット
  • Undo/Redoの挙動がPush/Popと綺麗に一致して読みやすい
  • Clear / Countなどの基本APIで十分足りる
  • 履歴構造の意図が型から自明
デメリット
  • 最古要素の削除に展開→詰め直しが必要(LinkedListならO(1))
  • 双方向からの参照が欲しくなった時に不便
  • もし履歴数が数万を想定する場合はLinkedList<ICommand>Deque的な構造を使った方が良いのですが、エディタのUndo履歴は100件前後で十分なので、可読性を優先してStackにしています。

    CompositeCommand:複数操作を1手にまとめる

    実装していて一番「これがないとまずい」と感じたのがCompositeCommandです。たとえばピアノロール上で複数の音符を選択して一括で下に動かす操作を考えると、これを1つ1つMoveNotesCommandとして履歴に積んだら、Undoを何回も押さないと元の状態に戻りません。

    解決策は「中に複数のコマンドを持ち、Execute/Undoで全部を順番に回すコマンド」を作ることです。

    public class CompositeCommand : ICommand
    {
        private readonly List<ICommand> _commands;
        private readonly string _description;
    
        public string Description => _description;
    
        public CompositeCommand(string description, IEnumerable<ICommand> commands)
        {
            _description = description;
            _commands = commands.ToList();
        }
    
        public void Execute()
        {
            foreach (var command in _commands)
            {
                command.Execute();
            }
        }
    
        public void Undo()
        {
            for (int i = _commands.Count - 1; i >= 0; i--)
            {
                _commands[i].Undo();
            }
        }
    }
    

    地味に大事なのがUndo時は逆順で回すところです。「AをしてからBをした」なら、戻す時は「Bを戻してからAを戻す」のが正しい順序です。コマンド間に副作用の依存があるとこの順序を間違えた時にバグるので、注意ポイントです。

    使い方はこんな感じです。

    var commands = selectedNotes.Select(note => new MoveNoteCommand(track, note, delta));
    var composite = new CompositeCommand($"{selectedNotes.Count}個の音符を移動", commands);
    _undoRedoManager.Execute(composite);
    

    ユーザー視点では「選択した音符を全部動かす」を1手としてUndoできるので、快適に使えます。

    まとめ

    • ICommandExecute / Undo / Descriptionの3点セットで十分
    • UndoRedoManagerは2本のStackで管理、Redoスタックは新規Executeで破棄
    • 履歴上限を入れないとメモリ使用量が膨らむことに注意
    • 複数操作を一括でするCompositeCommandは必須レベルで便利。Undo時は逆順に回す
    プロフィール画像
    WRITTEN BY
    あきぞら

    東京都市大学 情報工学部在籍(2027年卒業予定)。
    中学時代よりC#および.NETを用いた個人開発をスタート。現在はWebアプリエンジニアとして活動し、国内大手SaaS企業より内定。 培った技術の活用法から、新卒エンジニアとしてのキャリア形成、生産性を高めるITツールの導入まで、ITに興味のある方や現役エンジニアに役立つバラエティ豊かな情報を発信しています。