C++でUndo、Redoを実装する方法
この記事は、神戸電子専門学校 ゲーム技研部 Advent Calendar 2024の17日目の記事です。
はじめに
Ctrl+Zで巻き戻し(Undo)、Ctrl+Yで再実行(Redo) は何かを作るツールにおいてとても便利です。
実際にUnityやVisualStudio、ペイントソフトにZennの記事作成画面など、アプリケーションやツールではほぼ必ずと言っていいほど実装されています。
ただ、これを自作のアプリケーションやツールに実装しようと思った場合、最初からUndo、Redoを想定した設計にしておかないと実装にとても苦労することになってしまいます。
という事で、今回はC++でUndo、Redoを実装する方法について解説します。
Undo、Redoを実装する手段
Undo、Redoを実装する方法として、主に以下の2つがあります。
・編集対象のデータを丸々シリアライズ
・コマンドパターン
編集対象のデータを丸々シリアライズ
履歴を残す操作をするたびに編集対象のデータをすべて保存し、Undoをしたら今のデータを保存してひとつ前の履歴のデータを復元します。
メリット
・データのシリアライズ、デシリアライズ機能さえあれば実装がとても簡単
デメリット
・巨大なデータが大量に蓄積するためメモリ使用量が跳ね上がる
・データをすべて保存、復元するため処理負荷が高い
総括
この方法をそのまま使うのは現実的ではありませんが、保存対象をデータ全体ではなく差分や操作した部分的なデータのみにするなどの工夫をすることで、アプリケーションによっては実用できるかもしれないです。(mementoパターンというらしい)
また、この方法であれば後からUndo、Redoを実装しやすいため、既にある程度作りこんでしまったアプリケーションにどうしても急いでUndo、Redoを実装する必要があるならこの方法が手軽です。
コマンドパターン
ユーザーからのすべての操作をコマンドとして履歴に残し、現在値のコマンドの巻き戻し処理と実行処理を使いUndo、Redoを実現します。
メリット
・メモリ使用量が少ない
・最低限の処理負荷で実装できる
デメリット
・ユーザーからデータへの操作をすべてコマンドを経由する必要がある
・コマンドの実行関数の再現性を確保するのが大変
総括
メモリや処理負荷的に現実的な実装方法ですが、適切に実装しないと思わぬエラーになってしまうため工夫が必要です。
今回はコマンドパターンを使ったUndo、Redoの実装について紹介します。
実装
ここからは実装について解説していきます。
CommandBase
全てのコマンドの基底となるクラスです。
Do関数にコマンドの実行処理、UnDo関数にコマンドの巻き戻し処理をオーバーライドして実装します。
実行、巻き戻し処理は必ずセット必要になるため、純粋仮想関数で定義しています。
// すべてのコマンドの基底となるクラス
class CommandBase
{
public:
// 実行処理の純粋仮想関数
virtual void Do() = 0;
// 巻き戻し処理の純粋仮想関数
virtual void UnDo() = 0;
};
CommandManager
コマンドを管理するクラスです。
全てのコマンドをこのクラスを通して実行することで、操作の履歴を残すことができます。
コマンドの履歴はリストで、コマンドの現在値はイテレータで実装します。
// 実行したコマンドを格納するコンテナ
std::list<std::shared_ptr<CommandBase>> m_lCommands;
// コマンドのイテレータ
std::list<std::shared_ptr<CommandBase>>::iterator m_commItr;
初期化
イテレータが先頭にあるか判定するために、空のコマンドをリストの一番最初に追加します。
private:
// リストの先頭でNullとしてふるまう為のコマンド
class EmptyCommand :public CommandBase
{
public:
void Do()override {}
void UnDo()override {}
};
public:
// 初期化処理
void SetUp()
{
// 先頭を表すためのコマンドを挿入
m_lCommands.emplace_back(std::make_shared<EmptyCommand>());
// イテレータも先頭を指す
m_commItr = m_lCommands.begin();
}
コマンドの実行
Do関数でコマンドを実行すると、コマンドのDo関数を呼んだ後にリストの最後尾に追加し、その要素のイテレータを現在値のイテレータとします。
追加前のイテレータが最後尾を指して無い場合は、最初に現在値のイテレータ以降にあるコマンドをすべて削除する操作が入ります。
// コマンドを実行する関数
void Do(std::shared_ptr<CommandBase> a_spCommand)
{
// コマンドの処理を実行
a_spCommand->Do();
// イテレータが最後尾でなければUndo済みコマンドを削除する
if (m_commItr != std::prev(m_lCommands.end()))
{
// イテレータより後ろのコマンドをクリアする
m_lCommands.erase(std::next(m_commItr), m_lCommands.end());
}
// コマンドを追加
m_lCommands.emplace_back(a_spCommand);
// 一番最後のコマンドのイテレータを取得
m_commItr = std::prev(m_lCommands.end());
}
テンプレートを使うことで、コマンド操作を安全かつ簡潔にすることもできます。
// コマンドの作成、初期化、実行を行うクラス
template<typename CommType, typename...ArgTypes>
void Do(ArgTypes...a_args)
{
// コマンドのインスタンスの作成と初期化
std::shared_ptr<CommType> spNewComm = std::make_shared<CommType>(a_args...);
// コマンドの実行
Do(spNewComm);
}
コマンドの巻き戻し
Undo関数を実行すると、今イテレータが指しているコマンドのUndo関数を呼んだ後にイテレータを一つ手前に移動させます。
// コマンドを巻き戻す関数
void UnDo()
{
// 既にイテレータが先頭だったら何もしない
if (m_commItr == m_lCommands.begin())
{
return;
}
// 今イテレータが指しているコマンドのUndo関数を呼ぶ
std::shared_ptr<CommandBase> spCommand = *m_commItr;
if (spCommand == nullptr)
{
return;
}
spCommand->UnDo();
// イテレータを1つ前に戻す
m_commItr = std::prev(m_commItr);
}
コマンドのやり直し
Redo関数を実行すると、イテレータを1つ後に移動させた後にそのコマンドのDo関数を呼びます。
イテレータが最後尾を指している場合は何もしません。
// コマンドを再実行する関数
void Redo()
{
// 既にイテレータが最後の要素を指してたら何もしない
if (m_commItr == std::prev(m_lCommands.end()))
{
return;
}
// イテレータを1つ後ろに進める
++m_commItr;
// イテレータが指してるコマンドのDo関数を呼ぶ
m_commItr->get()->Do();
}
サンプル
これまでの実装のまとめです。
// 全てのコマンドの基底となるクラス
class CommandBase
{
public:
// 実行処理の純粋仮想関数
virtual void Do() = 0;
// 巻き戻し処理の純粋仮想関数
virtual void UnDo() = 0;
private:
};
// コマンドを管理するクラス
class CommandManager
{
private:
// リストの先頭でNullとして扱うためのコマンド
class EmptyCommand :public CommandBase
{
public:
void Do()override {}
void UnDo()override {}
private:
};
public:
void SetUp()
{
// 先頭を表すためのコマンドを挿入
m_lCommands.emplace_back(std::make_shared<EmptyCommand>());
// イテレータも先頭を指す
m_commItr = m_lCommands.begin();
}
// コマンドを実行する関数
void Do(std::shared_ptr<CommandBase> a_spCommand)
{
// コマンドの処理を実行
a_spCommand->Do();
// イテレータが最後尾でなければUndo済みコマンドを削除する
if (m_commItr != std::prev(m_lCommands.end()))
{
// イテレータより後ろのコマンドをクリアする
m_lCommands.erase(std::next(m_commItr), m_lCommands.end());
}
// コマンドを追加
m_lCommands.emplace_back(a_spCommand);
// 一番最後のコマンドのイテレータを取得
m_commItr = std::prev(m_lCommands.end());
}
// コマンドの作成、初期化、実行を行うクラス
template<typename CommType, typename...ArgTypes>
void Do(ArgTypes...a_args)
{
// コマンドのインスタンスの作成と初期化
std::shared_ptr<CommType> spNewComm = std::make_shared<CommType>(a_args...);
// コマンドの実行
Do(spNewComm);
}
// コマンドを巻き戻す関数
void UnDo()
{
// 既にイテレータが先頭だったら何もしない
if (m_commItr == m_lCommands.begin())
{
return;
}
std::shared_ptr<CommandBase> spCommand = *m_commItr;
// 今イテレータが指しているコマンドのUndo関数を呼ぶ
spCommand->UnDo();
// イテレータを1つ前に戻す
m_commItr = std::prev(m_commItr);
}
// コマンドを再実行する関数
void Redo()
{
// 既にイテレータが最後の要素を指してたら何もしない
if (m_commItr == std::prev(m_lCommands.end()))
{
return;
}
// イテレータを1つ後ろに進める
++m_commItr;
// イテレータが指してるコマンドのDo関数を呼ぶ
m_commItr->get()->Do();
}
private:
// 実行したコマンドを格納するコンテナ
std::list<std::shared_ptr<CommandBase>> m_lCommands;
// コマンドのイテレータ
std::list<std::shared_ptr<CommandBase>>::iterator m_commItr;
};
コマンドの作成
それでは、実際にコマンドを作ってみましょう。
制約
コマンドを作成する際は、再現性を維持するために以下の二つの決まりを守る必要があります。
・コマンドで生成したインスタンスに依存しない
・データを操作する際はすべてコマンドを通す
1つずつ詳しく解説します。
コマンドで生成したインスタンスに依存しない
コマンドで生成したインスタンスに依存したコマンドは、Undoで依存先のインスタンスが解放した後に再確保されると実行できなくなってしまいます。
そのため、ポインタやEntityIDではなくGUIDや一意な名前など、インスタンスを再生成しても維持できる情報だけに依存したコマンドを作るようにしましょう。
また、依存先のインスタンスを作るコマンド側でも、Undoして作り直した後も同じGUIDや名前になるよう工夫する必要があります。
データを操作する際はすべてコマンドを通す
Undo、Redoはコマンド処理でデータが完全に復元できることを前提に実装するため、ユーザーからの操作をすべてコマンドを通す必要があります。
操作の種類が増えるたびに新しいコマンドクラスを作っていてはキリが無いので、できるだけ汎用的なコマンドを作って手間を省きましょう。
/// <summary>
/// コンポーネントのメンバ変数に値を代入するコマンド
/// </summary>
/// <typeparam name="CompType">コンポーネントの型</typeparam>
/// <typeparam name="VarType">代入する変数の型</typeparam>
template<typename CompType, typename VarType>
class Cmm_ChangeCompVariable :public CommandBase
{
public:
/// <summary>
/// コマンドの実行に使う値をセット
/// </summary>
/// <param name="a_objGUID">値を変更するコンポーネントの持ち主のGUID</param>
/// <param name="a_pMemVarPtr">値を変更するメンバ変数のポインタ</param>
/// <param name="a_val">代入する値</param>
Cmm_ChangeCompVariable(std::string_view a_objGUID, VarType CompType::* a_pMemVarPtr, VarType a_val)
{
m_objGUID = a_objGUID;
m_pMemVarPtr = a_pMemVarPtr;
m_newVal = a_val;
}
// 実行処理
void Do()override
{
// コンポーネントのポインタを取得
CompType* pComp = GetCompPtr();
if (pComp == nullptr)
{
return;
}
// 変更前の値を保存
m_oldVal = pComp->*m_pMemVarPtr;
// 値を変更
pComp->*m_pMemVarPtr = m_newVal;
}
// 巻き戻し処理
void UnDo()override
{
// コンポーネントのポインタを取得
CompType* pComp = GetCompPtr();
if (pComp == nullptr)
{
return;
}
// 変更前の値をセット
pComp->*m_pMemVarPtr = m_oldVal;
}
private:
// 対象のコンポーネントのポインタを取得する関数
CompType* GetCompPtr()
{
// オブジェクトのポインタを取得
GameObject* pObj = ObjMng::Ins().GetObjectFromGUID(m_objGUID);
if (pObj == nullptr)
{
// エラー処理
return nullptr;
}
// コンポーネントのポインタを取得
CompType* pComp = pObj->GetComponent<CompType>();
if (pComp == nullptr)
{
// エラー処理
}
return pComp;
}
private:
// オブジェクトのGUID
std::string m_objGUID;
// メンバ変数のポインタ
VarType CompType::* m_pMemVarPtr;
// 変更前の値
VarType m_oldVal;
// 変更後の値
VarType m_newVal;
};
CmmMng::Ins().Do<Cmm_ChangeCompVariable>(objGUID, &Transform::m_x, 50);
応用
全てのコマンドにシリアライズ、デシリアライズを実装しておくことで、コマンドの履歴を外部ファイルに保存しておくことができます。
そのためもしエディタが途中でクラッシュしてしまっても、前回のセーブデータとそこからのコマンドを実行することで直前の操作まで復元することができます。
また、コマンドが大量にたまったら外部ファイルに書き出してからアプリケーションから削除することもできるため、長時間エディタを使用しても安定してパフォーマンスを維持することができます。
ただ、C++にはリフレクションが無いためすべての情報を文字列に変換する必要があり、ここで紹介したようなコマンドを使うのが難しくなります。
そのため、以下のような追加の実装が必要になることがあります。
class Transform :public ComponentBase
{
public:
// Serializable関数内で関数オブジェクトなどを使い文字列とメンバ変数ポインタへのアクセス処理を紐づけておく
void Register()override
{
Serializable("X", &Transform::m_x);
Serializable("Y", &Transform::m_y);
Serializable("Z", &Transform::m_z);
}
float m_x = 0;
float m_y = 0;
float m_z = 0;
};
// ↓のようなコンポーネントの登録関数内でコンポーネントのRegister関数を呼ぶようにする
CompMng::Ins().RegisterComponent<Transform>();
ゲームエンジンなどを作っている方は、スクリプト機能のついでに実装してみてはいかがでしょうか。
おわりに
これで、自作のアプリケーションにUndo、Redoを実装できるようになりました。
皆さんもUndo、Redoを実装してCtrl+Zを連打できるようにしてみてください。
何か間違いや改善案などあれば教えていただけますと幸いです。
Discussion