ゲームプログラマのための設計シリーズ:実装詳細編の記事です。
概要
- 確保済みの領域を再利用することで、型の変更により生じる動的メモリ確保を避ける
- 確保済みの領域にオブジェクトを生成するには、配置newかstd::construct_atを用いる
本文
GofのStateパターンを考えます。
class IState
{
public:
virtual ~IState() = default;
virtual void update() = 0;
};
// 攻撃状態
class AttackState final : public IState
{
public:
void update() override { Print("Attack"); }
};
// 防御状態
class DefenceState final : public IState
{
public:
void update() override { Print("Defence"); }
};
class Enemy
{
public:
void update()
{
// ステート遷移
if ( /* 何某かの条件 */)
{
mpState = std::make_unique<AttackState>(); // 切り替えの度、動的メモリ確保&解放
}
else if (/* 何某かの条件 */)
{
mpState = std::make_unique<DefenceState>(); // 切り替えの度、動的メモリ確保&解放
}
// 実行
if(mpState)
{
mpState->update();
}
}
private:
std::unique_ptr<IState> mpState;
};
ステートの変更の度に動的メモリ確保が走るので、パフォーマンス/メモリの断片化の両面でよくないです。
かといって、すべてのステートのインスタンスを保持するのもちょっと無駄が多いです。
Stateパターンの恩恵を受けつつ、動的確保を避けるにはどうしたらよいでしょうか。
(※実際はこのくらいのケースではvariantがよいでしょう。→参考
IState派生クラスの種類が膨大だったりして、Enemyがそのすべてを知る必要がないような場合ではvariantは不向きになり、そのような状況で用いる方法の紹介です)
配置new
まず、IState派生クラスを配置するためのメモリを用意します。
サイズ/アライメントは、IState派生の中で一番大きいものに合わせてください。
+template<size_t cSize = 64, size_t cAlignment = alignof(void*)>
+class StateStorage
+{
+private:
+ alignas(cAlignment) std::byte mBuffer[cSize];
+};
std::byteはC++17で追加されたものですが、それより前ならunsigned char/charでもOKです。
次に、この領域にIState派生のクラスを構築できるようにします。
template<size_t cSize = 64, size_t cAlignment = alignof(void*)>
class StateStorage
{
+public:
+ // mBufferにTを構築する
+ template<typename T, typename... TArgs>
+ void emplace(TArgs&&... args)
+ {
+ static_assert(std::is_base_of_v<IState, T>); // TはIStateの基底か?(なくても別のところでコンパイルエラーになる)
+ static_assert(sizeof(T) <= cSize); // 用意した領域をはみ出さないか?
+ static_assert(alignof(T) <= cAlignment); // 用意した領域とアライメントがあっているか?
+
+ if (mPtr)
+ {
+ mPtr->~IState(); // すでに構築済みだったら、破棄
+ }
+ mPtr = new(mBuffer) T{ std::forward<TArgs>(args)... }; // 配置new
+ }
private:
alignas(cAlignment) std::byte mBuffer[cSize];
+ IState* mPtr{ nullptr }; // 配置newで構築したオブジェクトを指すポインタ
};
new(mBuffer) T{ std::forward<TArgs>(args)... };
の部分が配置newです。新しくヒープからメモリを確保するのではなく、mBufferを利用してオブジェクトを構築します。
配置newで構築されたオブジェクトはdeleteしてはダメなので、手動でデストラクタを呼びます(mPtr->~IState()
)。
もう少し必要なものと便利なものをちょこちょこ付け加えて完成です。
template<size_t cSize = 64, size_t cAlignment = alignof(void*)>
class StateStorage
{
public:
+ // コンストラクタとデストラクタ
+ explicit StateStorage() = default;
+ ~StateStorage()
+ {
+ if (mPtr)
+ {
+ mPtr->~IState();
+ }
+ }
+ // コピー・ムーブはデフォルト実装ではダメで、特に使うこともないので消す
+ StateStorage(const StateStorage&) = delete;
+ StateStorage& operator=(const StateStorage) = delete;
+ StateStorage(StateStorage&&) = delete;
+ StateStorage& operator=(StateStorage&&) = delete;
// mBufferにTを構築する
template<typename T, typename... TArgs>
void emplace(TArgs&&... args)
{
static_assert(std::is_base_of_v<IState, T>);
static_assert(sizeof(T) <= cSize);
static_assert(alignof(T) <= cAlignment);
if (mPtr)
{
mPtr->~IState();
}
mPtr = new(mBuffer) T{ std::forward<TArgs>(args)... };
}
// 構築したオブジェクトを取得
+ IState* get() { return mPtr; }
+ const IState* get() const { return mPtr; }
+ IState* operator->() { return get(); }
+ const IState* operator->() const { return get(); }
private:
alignas(cAlignment) std::byte mBuffer[cSize];
IState* mPtr{ nullptr };
};
これで、IState利用者側でステートを切り替える度に動的メモリ確保が不要になりました!
class Enemy
{
public:
void update()
{
// ステート遷移
if (/* 何某かの条件 */)
{
mStorage.emplace<AttackState>(); // 動的メモリ確保なし
}
else if (/* 何某かの条件 */)
{
mStorage.emplace<DefenceState>(); // 動的メモリ確保なし
}
// 実行
if( auto* state = mStorage.get() )
{
state->update();
}
}
private:
StateStorage<> mStorage{};
};
construct_atとdestroy_at
C++17でstd::destroy_at
、C++20でstd::construct_at
が追加されています。
それぞれ、配置用の構築/破棄関数です。
std::construct_atは、配置newとは結構違うところがあります。
配置new | std::construct_at | |
---|---|---|
いろいろな初期化構文 | 〇 | × |
配列 | 〇 | △(std::uninitialized_copyを使う) |
operator newのオーバーロードの影響 | あり | なし |
constexpr | × | 〇 |
void test()
{
alignas(4) std::byte buffer[8];
// 集成体初期化
struct Obj { int a, b; };
auto* obj = new(buffer) Obj{ 1,2 };
std::destroy_at(obj);
// 配列
struct Obj2 { int a; Obj2() { Print("Constructor"); } ~Obj2() { Print("Destructor"); } };
auto* obj2 = new(buffer) Obj2[2];
std::destroy_at(obj2);
}
template<typename T>
struct Obj3
{
Obj3(T) {}
};
void test2()
{
alignas(4) std::byte buffer[8];
auto* obj3 = new(buffer) Obj3{ 0 }; // Obj3<int>に推論
std::destroy_at(obj3);
}
そもそもstd::construct_atがconstexpr対応のため作られたものだそうなので、そうでない場合は基本配置newでいいかと思っています。「operator newのオーバーロードの影響」も一応回避できます(construct_atがやってくれていることを手動でやるだけ)が、ここまできちんとやったことはないです。。。
void test()
{
alignas(4) std::byte buffer[8];
// この部分で
// ①operator&のオーバーロード
// ②operator newのオーバーロード
// により意図しない結果になるのを防いでいる
auto* ptr = const_cast<void*>(static_cast<const volatile void*>(std::addressof(buffer)));
auto* obj = new(ptr) int;
}
std::destroy_at
は配列に適用すると要素すべてのデストラクタを呼んでくれるようになっています。
配列でなければ、デストラクタ直呼びとどちらでも構わないです。
余談:mPtrはいる?
StateStorage::mBufferには常にIState派生のインスタンスが入っているので、いちいちmPtrに配置newの結果を納めなくてもreinterpret_castで行けてしまいそうな気がします。(emplaceする前にアクセスするのはなしとする)
template<size_t cSize = 64, size_t cAlignment = alignof(void*)>
class StateStorage
{
public:
// 構築したオブジェクトを取得
+ IState* get() { return reinterpret_cast<IState*>(&mBuffer); }
private:
alignas(cAlignment) std::byte mBuffer[cSize];
- IState* mPtr{ nullptr }; // 配置newの返り値を受けるポインタは不要か?
// (他の部分は前掲時と同じ)
};
これは、
- 配置newの時点でmBufferのライフタイムが切れるとみなされ、それ以降のアクセスは未定義動作になるのではないか?(std::launderを付けたらそれはクリアされる?)
- いわゆる「strict aliasing rules」はchar/unsigned char/std::byteに変換してからのアクセスは許可しているが、逆はダメなのではないか?
という2点で怪しいんじゃないかと思っています。(実際やってみると動作はするが・・・)
規格やネット上の解説を読んでもこのあたり納得しきれていないので、いつも一応配置newの返り値を使っています。。。