シリアライズを使って自作アプリにUndo/Redoを実装する


Siv3D Advent Calendar 2016 4 日目の記事です。

みなさんは自分の作っているアプリケーションに、直前の動作を取り消すUndo機能が欲しくなったことはありますか?
自作ゲームのエディタだったり、お絵かきソフトやその他編集ソフトなど、さまざまなアプリケーションにとってUndo/Redoは当たり前のように付いていて欲しい機能ですが、実際にプログラムにUndoを実装するとなると容易ではなく、プログラムを大幅に書き換えなければならない上、戻ろうとするとなぜかプログラムがクラッシュする、消えるはずのオブジェクトが消えない、など多くのバグを抱えがちです。

この記事ではそんなUndo/Redo機能をシリアライズを使って(比較的)簡単に実装する方法を紹介します。
また、この記事に出てくるソースコードの全体はGitHub上で公開しています。

シリアライズとは

シリアライズとは、ソフトウェア内部で扱っているデータを丸ごと、ファイルで保存したりネットワークで送受信することができるように変換すること。これとは逆に、ファイルに保存されているシリアライズされたデータや、ネットワークを通じて送られてきたシリアライズされたデータを、ソフトウェアで扱うことのできる元のデータ形式に復元することを「デシリアライズ」(deserialize)「デシリアライゼーション」(deserialization)という。
出典:IT用語辞典 - シリアライズとは

プログラムの以前の状態をシリアライズして履歴に持っておけば、いつでもそれを復元することができます。

実装方法

この記事では、次の手順でアプリケーションにUndo/Redo機能を実装します。

  1. クラスをシリアライズ可能にする
  2. セーブを行うタイミングを設定する
  3. ReversibleApplicationクラスを使ってUndo/Redoを行えるようにする

例として、次のSketchクラスにUndo/Redo機能を追加してみます。
Sketchクラスは、Siv3Dリファレンスのスケッチのプログラムをクラスにまとめたものです。

Sketch.cpp
class Sketch
{
public:

    Sketch(const Size& size = Window::Size(), const Color& color = Palette::White) :
        m_image(size, color),
        m_texture(m_image)
    {}

    void update()
    {
        if (Input::MouseL.pressed)
        {
            Line(Mouse::PreviousPos(), Mouse::Pos()).overwrite(m_image, 8, Palette::Orange);

            m_texture.fill(m_image);
        }
    }

    void draw()const
    {
        m_texture.draw();
    }

private:

    Image m_image;
    DynamicTexture m_texture;
};

これを実行すると、次のようになります。

1. クラスをシリアライズ可能にする

Siv3Dのシリアライズ機能は、cerealライブラリを使って実装されており、自分のクラスにcereal用のシリアライズ関数を定義することで、Siv3DのMemoryWriterやBinaryWriterを用いたシリアライズが行えるようになります。次のコードはシリアライズ可能にしたSketchクラスです。

SerializableSketch.cpp
class Sketch
{
public:

    /* Sketch.cppと同じ部分は省略 */

    template <class Archive>
    void save(Archive& archive)const
    {
        archive(m_image);
    }

    template <class Archive>
    void load(Archive& archive)
    {
        archive(m_image);
        m_texture.fill(m_image);
    }

private:

    Image m_image;
    DynamicTexture m_texture;
};

このような形でsave関数とload関数を定義し、archive()の引数にシリアライズしたいメンバ変数を全て記述します。今回m_textureはロードした後m_imageから復元するのでシリアライズには含めません。

また、シリアライズ関数はこれ以外にも、次のような定義の仕方ができます(詳しくはドキュメントを参照してください)。
・save関数とload関数の中身が全く同じならserialize関数にまとめることができる
・クラスの外に定義することができる

2. セーブを行うタイミングを設定する

ReversibleApplicationクラスはアプリケーションのメンバ関数であるOptional<String> popAction();を毎フレーム呼び出し、値が入っていればアプリケーションをシリアライズして保存します。したがってアプリケーション側では以下のようにpopAction()を定義します。

ReversibleSketch.cpp
class Sketch
{
public:

    /* SerializableSketch.cppと同じ部分は省略 */

    void update()
    {
        if (Input::MouseL.pressed)
        {
            Line(Mouse::PreviousPos(), Mouse::Pos()).overwrite(m_image, 8, Palette::Orange);

            m_texture.fill(m_image);
        }

        if (Input::MouseL.released)
        {
            m_newAction = String(L"線を描く");
        }
    }

    Optional<String> popAction()
    {
        const Optional<String> result = m_newAction;
        m_newAction = none;
        return result;
    }

private:

    Image m_image;
    DynamicTexture m_texture;
    Optional<String> m_newAction;
};

これで、マウスを離すたびに画像の履歴が作られるようになります。

3. ReversibleApplicationクラスを使ってUndo/Redoを行えるようにする

ReversibleApplicationクラスのupdate関数で中身の更新と、Ctrl+Zが押されたらUndo、Ctrl+Yが押されたらRedoを行うようにしています。このupdate関数とdraw関数は適宜書き換えてください。ReversibleApplicationGUIクラスはReversibleApplicationクラスに履歴ウィンドウが付いたもので、機能は同じです。

ReversibleSketch.cpp
void Main()
{
    ReversibleApplicationGUI<Sketch> app(Sketch{});

    while (System::Update())
    {
        app.update();
        app.draw();
    }
}

これを実行すると、次のようになります。

サンプル

以下のサンプルのソースコードはこちらから見ることができます。

履歴付き電卓

Siv3Dリファレンスの電卓のプログラムに履歴機能を追加したものです。

可逆テトリス

Undo機能付きテトリスです。

コンパイルエラーが出る場合

シリアライズしたいクラスがcerealの要求を満たしていない場合はコンパイルに失敗します。自分が遭遇したコンパイルエラーとその対処をまとめておきます。

error C2338: cereal could not find any output serialization functions for the provided type and archive combination.

  • 原因:シリアライズ用の関数が定義されていない
  • 対処(次のどれか)
    • シリアライズ関数を定義する
      • ソースを編集できないライブラリのクラスも、クラスの外部にシリアライズ関数を定義することができる
    • そのメンバをシリアライズから外す
      • 上記のSketchの例では、m_textureはデシリアライズ時にm_imageから写すのでシリアライズに含めていない

error C2512: 'クラス名': クラス、構造体、共用体に既定のコンストラクターがありません。

  • 原因:cerealはデシリアライズ時にデフォルトコンストラクタを使って復元するが、それが定義されていない
  • 対処(次のどれか)
    • デフォルトコンストラクタを定義する
    • load_and_construct関数を定義する(スマートポインタの場合 参考:cereal - Types with no default constructor)
    • そのクラスをシリアライズから外す

error C2338: cereal detected a non-const save.

  • 原因:シリアライズを行うsave関数にconst修飾子が付いていない
  • 対処(次のどれか)
    • constを付ける
      • どうしてもsave関数でメンバを変更したい場合はメンバ変数にmutableを付ければコンパイルは通る(自分はmutableを付けて問題なく動いたが、正しく動くことが保証されてるかはわからない)

その他注意

シリアライズ関数に足りないデータがあっても指摘してくれない

上で書いた通り、クラスの全てのメンバをシリアライズに含める必要はないので、1つのクラスに可逆なデータとそうでないデータを混在させることができます。逆に言うと、必要なデータがシリアライズに含まれているかは自分で確かめないといけません。

std::shared_ptr, std::weak_ptrをシリアライズする場合

cerealはスマートポインタにも対応していて、複数のスマートポインタ間で共有されたデータをシリアライズすると、復元時にはデータが重複することなく元の参照関係が維持されます(参考:cereal - Pointers)。しかし実際のアドレスは当然変わるので、同じアドレスを共有するスマートポインタは全てシリアライズに含める必要があります。

最後に

自分で使ってみて感じた利点と欠点を書いておきます。

・Undo/Redoを実装するのにコードをほとんど汚さないで済むのはとてもいい
しかもそのままアプリケーションのデータ保存にも使えるのでエディターにはとても役に立つ。

・メモリ消費がそれほど問題になることはない
1動作ごとにメモリ消費はどんどん増えていくが、履歴の保存数の上限(ReversibleApplicationコンストラクタの第二引数)を決めておけばほとんど問題になることはないと思う。少しいじればバイト数を上限に設定することもできる。

・速度は遅い
毎回使っているメモリを全てコピーしているようなものなので、高解像度の画像を編集する用途とかだと厳しい感じがする。

・外部クラスをシリアライズできるようにするのは面倒くさい
外にシリアライズ関数を定義できるとはいえ、そのクラスのプライベートメンバは触りようがないので、場合によっては完全にラップしたクラスを新しく作る必要がある。自分がSiv3DのGUIクラスをシリアライズしようとした時は、数百行新しいコードを書く必要があった。

参考

(1) Siv3D/Reference-JP - バイナリファイル
(2) cereal - Docs
(3) Qiita - C++のcerealのシリアライズが快適すぎるやばい


明日は @ChunChunMorning さんの記事です。
よろしくお願いします。