ゲームプログラマのための設計シリーズ:言語機能編の記事です。
概要
- C++における処理の抽象化の手段の比較
- 大きく分けると静的(コンパイル時)分岐か動的(実行時)分岐がある
- 複数の処理セットをまとめて抽象化したいか、という視点も
本文
抽象化とは
この記事における抽象化とは、
「ある処理Aが依存するものについて、Aが知る必要のない知識を隠蔽すること」
とします。
例えば、何かをぶっ壊す処理を書きたいとします。
void func(Arg arg)
{
// ここに、argを破壊する処理を書きたい
// 前後でログを出したりなど他の事をする
}
このような車クラスCarがあってそれを破壊したいとすると、func関数にCarに関する知識が入り込んでしまいます。
class Car
{
public:
void removeTire(); // タイヤをとって
void removeBody(); // 車体をとって
void removeEngine(); // エンジンをとる
};
void func(Car& arg)
{
// func関数君としては、車かどうかや、まして車の構造なんて知りたくない・・・
arg.removeTire();
arg.removeBody();
arg.removeEngine();
}
この状況からいかにfunc関数からCarなどの 具体的 な情報を排して、破壊するという 抽象化 された情報のみが残させるようにするか、という目的においてC++ではどのような手段をとれるのでしょうか。
仮想関数
この記事にたどりついた方にはいうまでもないですが、まずは仮想関数です。
// インターフェース
class ICrashable
{
public:
virtual ~ICrashable() = default;
virtual void crash() = 0;
};
// その実装
class Car : public ICrashable
{
public:
void crash() override
{
removeTire();
removeBody();
removeEngine();
}
void removeTire(); // タイヤをとって
void removeBody(); // 車体をとって
void removeEngine(); // エンジンをとる
};
// 引数がCarであるかどうかを気にする必要がなくなった
void func(ICrashable& arg)
{
arg.crash();
// 前後でログを出したりなど他の事をする
}
funcから、Carやその実装に関する知識を取り除くことができました。
ポイント:
- 動的分岐=仮想関数の呼び出しコスト
- 複数の関数をセットにして抽象化できる
高階関数
高階関数とは、関数を引数や戻り値に持つ関数です。
C++では関数ポインタ/std::functionなどで関数を取り回します。
#include <functional>
class Car
{
public:
void removeTire(); // タイヤをとって
void removeBody(); // 車体をとって
void removeEngine(); // エンジンをとる
};
// -------------------------------------------
// 破壊する関数を引数にとる
void func(std::function<void()> crashFunc)
{
crashFunc();
// 前後でログを出したりなど他の事をする
}
// funcを使う側で、具体的なCarの壊し方を記述
void crashCar(Car& car)
{
func([&car]() {
car.removeTire();
car.removeBody();
car.removeEngine();
});
}
ポイント:
- 動的分岐
- std::function::operator()の呼び出しは仮想関数の呼び出しよりさらに高コスト
- std::functionは裏でメモリ確保してしまうことがある。詳しくは別記事へ
- 必要なメモリが仮想関数を利用した場合より多くなる。(特に、複数の関数を抱えているが同じクラスの別のメンバ関数にバインドする、という場合に無駄が大きい)
- 複数の関数を抽象化すると引数が増えてごちゃつく。
オーバーロード
引数違いの同名関数を用意し、オーバーロード解決により処理を呼び分ける方法です。
class Car
{
public:
void removeTire(); // タイヤをとって
void removeBody(); // 車体をとって
void removeEngine(); // エンジンをとる
};
// Carの壊し方を切り出した
void Crash(Car& car)
{
car.removeTire();
car.removeBody();
car.removeEngine();
}
template<typename Arg>
void func(Arg& arg)
{
Crash(arg);
// 前後でログを出したりなど他の事をする
}
これだけだとただ関数に切り出しただけですが・・・
class PC
{
public:
void removeDisplay();
void removeKeyboard();
};
void Crash(PC& pc)
{
pc.removeDisplay();
pc.removeKeyboard();
}
↑のように引数違いのCrash関数を増やすことで、
void test()
{
Car car;
func(car); // 中でvoid Crash(Car& car)が使われる
PC pc;
func(pc); // 中でvoid Crash(PC& pc) が使われる
}
処理の分岐が実現できました。
ポイント:
- 静的分岐
- 分岐した処理に加え状態を持たせることはできない
template
テンプレートを用いた抽象化にはいろいろ変種があるので一例です。
template<typename Policy>
void func(Policy crashPolicy)
{
crashPolicy.crash();
// 前後でログを出したりなど他の事をする
}
// ----------------------------------------------
class CrashCarPolicy
{
public:
explicit CrashCarPolicy(Car& car) : mCar(car) {}
void crash()
{
mCar.removeTire();
mCar.removeBody();
mCar.removeEngine();
}
private:
Car& mCar;
};
void test()
{
Car car;
func(CrashCarPolicy{ car });
}
今回は関数が一つだけなので、これをoperator()に変えると高階関数と同じ見た目になります。
template<typename Policy>
void func(Policy crashPolicy)
{
crashPolicy(); // operator()の呼び出しに変更
}
void test()
{
Car car;
func([&car]() { car.removeTire(); ...; }); // ラムダでもOK
}
※この方法はfuncのシグネチャだけ見てもテンプレート引数に何を与えたらいいのかわからないのが欠点でしたが、C++20のConceptにより改善されました。
コンパイラ次第ではありますが、想定と違う型を渡そうとした際のコンパイルエラーのメッセージもわかりやすく(以前よりはマシに)なります。
// 型Tは、crashというメンバ関数を持つべしという制約
template<typename T>
concept CrashConcept = requires(T t)
{
t.crash();
};
// シグネチャだけで、どんな型を受け付けてくれるのかわかるようになった
template<CrashConcept Policy>
void func(Policy crashPolicy)
{
// intellisenseも効くようになってうれしい
crashPolicy.crash();
}
ポイント:
- 静的な分岐である
- 複数の関数をセットにして抽象化できる
まとめ
手法名 | 静的or動的 | 複数の関数をセットで抽象化 |
---|---|---|
仮想関数 | 動的 | 〇 |
高階関数 | 動的 | × |
オーバーロード | 静的 | × |
テンプレート | 静的 | 〇 |
実行時の処理負荷を考えると、可能な部分は静的な分岐に持ち込みたいところです。
しかし一方ゲームプログラミングでは、できるだけデータドリブンに持っていきたいという事情で動的分岐が求められる状況も多いと思います。(パラメータによって処理を分岐したい、など)
抽象化の目的は必ずしも分岐のためとは限らないので、よく見極めて使い分けたいものです。