ゲームプログラマのための設計シリーズ:デザインパターン編の記事です。
概要
- 型消去のメリット
- C++における型消去の実装方法は、継承・External Polymorphism派生・VTable自作などがある
- External Polymorphism派生と、VTable自作の方法はメモリと処理負荷のトレードオフ
本文
で、抽象化のメリットとその方法について議論しました。
class Car
{
public:
void crash(); // 破壊する
};
class PC
{
public:
void crash(); // 破壊する
};
void func(Arg arg)
{
arg.crash(); // 具体的に何かは知らずに、とにかく破壊したい
// 前後に何か他の処理がある
}
こちらをもう少し深堀してみます。
シンプルな継承
一つの解は、継承を用いた方法でした。
// 破壊できるものが継承するインターフェース
+class ICrashable
+{
+public:
+ virtual ~ICrashable() = default;
+ virtual void crash() = 0;
+};
+class Car : public ICrashable
{
public:
void crash() override {}
};
+class PC : public ICrashable
{
public:
void crash() override{}
};
void func(ICrashable& arg)
{
arg.crash(); // CarとかPCとかを知らずに済む
// 前後に何か他の処理がある
}
funcにとって、CarやPCといった具体的な型を知る必要がなくなり、抽象化が達成されています。(このようなシンプルな継承についてそう呼ぶ人はほとんどいませんが)型が消去されたと考えることができます。
しかし、この方法は
- 既存のCar/PCクラスに変更が必要
- ICrashableと同様の他のインターフェースを足していくと、Car/PCクラスが多重継承になり責務も増える
という問題があり、Adapter/External Polymorphismパターンといったコンポジションベースの方法が考えられたのでした。
External Polymorphism派生の型消去
の記事で紹介したExternal Polymorphism(厳密には、途中のAdapterパターン改)を適用すると以下のような感じになります。
class Car
{
public:
void crash() {}
};
class PC
{
public:
void crash() {}
};
// インターフェースは同じ
class ICrashable
{
public:
virtual ~ICrashable() = default;
virtual void crash() = 0;
};
// Adapterパターン改
template<typename T>
class Crashable : public ICrashable
{
public:
explicit Crashable(T&& obj)
: mObj(std::forward<T>(obj))
{}
void crash()
{
mObj.crash();
}
private:
T mObj;
};
void test()
{
// Crashableのテンプレート引数は本来推論されるが、のちの説明のため明示
Crashable<Car> car{ Car{} };
Crashable<PC> pc{ PC{} };
func(pc);
func(car);
}
シンプルな継承を用いた場合の問題が解消されています。
強いて不満を上げるならば
- func関数のユーザーはICrashableとCrashable<T>の両方を知らないといけない。ICrashableを見ただけではCrashable<T>を使えばいいとわからない
- Crashable<Car>/Crashable<PC>で型が違うので、そのままでは同じ配列などに入れられない(参照セマンティクスである)
これらの問題を、型消去によって解決できます。
// 型消去された、破壊可能なクラス
class CrashableErased
{
public:
template<typename T>
explicit CrashableErased(T&& t)
: mPtr(std::make_unique<CrashableInner<T>>(std::forward<T>(t)))
{}
void crash()
{
mPtr->crash();
}
private:
std::unique_ptr<ICrashable> mPtr;
};
void test()
{
// Crashable<T>が隠蔽され、同じ型になった
CrashableErased car{ Car{} };
CrashableErased pc{ PC{} };
}
ポイントはCrashableErasedのコンストラクタです。この中でCrashable<T>を生成するようにすることで、ユーザーからその存在を隠しています。もはやICrashableやCrashable<T>はユーザーから見える必要はないので、CrashableErasedの内部クラスにするなり別の名前空間に入れてしまうなりで隠してしまいましょう。
C++20ならばConceptも用意してあげることで
// (①この関数を使いたいなあ)
void func(CrashableErased&);
// (②引数にはこのクラスを使えばいいのね。コンストラクタ引数はどうしたらよいのだろう)
class CrashableErased
{
public:
template<CrashConcept T>
explicit CrashableErased(T&& t)
: mPtr(std::make_unique<CrashableInner<T>>(std::forward<T>(t)))
{}
// 他にもいろいろ書いてあるが、ユーザーが知る必要はない
};
// (③CrashableErasedのコンストラクタには、crash関数を持つクラスを与えればいいのか!)
template<typename T>
concept CrashConcept = requires(T t)
{
t.crash(); // Tはメンバ関数crashを持つべしという制約
};
という具合に順を追って仕様を確認できるので、生のExternal Polymorphismと比較して多少ユーザーフレンドリーになりました。そもそもExternal Polymorphismパターンは少々煩雑なのでこれ以上構造を追加するのか、という話ではありますが
- 型消去で内部実装を隠すことで、あまり考えずに使う分にはユーザーが把握しやすくなる
- とはいえコード量が増えているのは間違いないので、内部処理を追うタイプのユーザーにとってはさらに大変になる
のトレードオフになるという印象です。
自作VTableによる実装
External Polymorphism以外の型消去パターンも紹介します。
External Polymorphismでは、ICrashableの仮想関数により動的ディスパッチを行っていました。
動的ディスパッチの部分を自前で実装することにより、ICrashableやCrashable<T>といったクラスを増やさずに型消去を実現できます。
// ICrashableやCrashable<T>が不要。
class CrashableVTable
{
public:
template<CrashConcept T>
explicit CrashableVTable(T&& t)
: mObj(t)
, mCrashFunc([](std::any& obj) { std::any_cast<T&>(obj).crash(); })
{}
void crash()
{
mCrashFunc(mObj);
}
private:
std::any mObj;
void (*const mCrashFunc)(std::any&); // この関数ポインタが、仮想関数の役目を果たす
};
void test()
{
CrashableVTable car( Car{} );
CrashableVTable pc{ PC{} };
func(pc);
func(car);
オブジェクトの保持に使っているstd::any
についてはこちら
肝はコンストラクタのmCrashFuncの初期化部分
mCrashFunc([](std::any& obj) { std::any_cast<T&>(obj).crash(); })
です。型情報をまだ知っているうちに、本来の型にダウンキャストする関数をmCrashFuncに差し込むことで型安全にstd::anyから型を取り出してcrash関数を呼べるようにしています。
コード量も減り仮想関数の呼び出しもなくなって(厳密には、vtableの参照一つ分節約されている)万々歳なのですが、std::anyの特徴として
- 値の格納時に動的メモリ確保を行う可能性がありゲーム向けでない
-
std::any_cast
は、現在保持している型と整合しない場合に例外を出すためのif文による分岐があるので、下手すると仮想関数のコールより重くなりかねない(今回の用途では絶対に型が合うため不要な分岐であり無駄)
という欠点を抱えています。ただ、これはstd::anyを自作することでほぼ完全に克服可能です。別記事で紹介したいと思います。
これはトレードオフになりますが、vtableを見に行く処理を節約=本来のvtableの中身を展開してCrashableVTableに持っているわけなので、crashと同様の関数が増えていくと仮想関数を用いた方法と違ってCrashableVTableのインスタンスサイズが増えていきます。(仮想関数テーブルを使わない処理系は考えないものとする)まあ、このパターンを使うような場面で仮想関数一発分の負荷を気にするかというと、否ですかね。。。
まとめ
型消去したい場合、既存の型を変更できる&インターフェースが一つで済む場合のみシンプルな継承を用いてもよい。それ以外の場合はAdapterパターン、External Polymorphismパターン、Vtable自作パターンを用いる。
処理負荷 | メモリ(関数が増えていく場合) | コード量 | |
---|---|---|---|
Adapter/External Polymorphism派生 | △(多) | 〇(少) | △(多) |
Vtable自作 | 〇(少) | △(多) | 〇(少) |