ゲームプログラマのための設計シリーズ:実装詳細編の記事です。
概要
- C++での高階関数の実装法を比較
- 一緒に状態を受け取らなくていいなら関数ポインタ
- 実装を隠す必要がないならテンプレート
本文
高階関数とは、関数を引数or戻り値に持つ関数の事です。
C++の実装方法は大きく分けて3パターン考えられるので紹介&比較してみます。
※これを使うと何がうれしいのかは↓
https://qiita.com/kobitnex/items/0f04bcd58fb8887a4ab7
関数ポインタによる実装
// 関数ポインタ
void HighFunc0(float (*argFunc)(int))
{
const float val = argFunc(0);
}
ポイント:
- 良くも悪くも、関数以外の余分な状態はついてこない
- それゆえキャプチャありのラムダ式などは渡せない
void test()
{
// キャプチャなしのラムダ式なら、関数ポインタに暗黙変換される
HighFunc0([](int) {return 0.f; });
}
※関数ポインタと似たものに、関数型 というものがあります。
戻り値には使えず引数にのみ使えますが、結局関数ポインタ扱いされるようです。
using FuncType = float(int); // 関数型
using FuncPtr = float (*)(int); // 関数ポインタ型
std::functionによる実装
std::functionとは、関数のように呼び出せるものをなんでも格納できるクラスです。
#include <functional>
void test()
{
int(*funcPtr)(float); // 関数ポインタ
auto lambda = [](int) {return 0.f; }; // ラムダ式
struct S
{
float operator()(int) { return 0.f; }
} functor; // operator()を持つクラスのインスタンス
// みんな()をつけて呼び出し可能
const float val0 = funcPtr(0); // (簡略化のため初期化してないので未定義動作)
const float val1 = lambda(0);
const float val2 = functor(0);
// std::functionに格納可能
std::function<float(int)> func;
func = funcPtr;
func = lambda;
func = functor;
}
std::functionの見過ごせないデメリットとして、代入時にメモリ確保が走る可能性があるということが挙げられます。動的メモリ確保をしないfunctionの実装はいろいろ公開されているので、ぜひ取り入れてみてください。
https://blog.toylogic.co.jp/programmer/3364.html
https://zenn.dev/suuta/articles/c4c47e8626d5aa
が、動的確保を避けるということは受け取れるインスタンスのサイズに制限が発生するということでもあります。
ポイント:
- 良くも悪くも、一緒に状態を受け取る
- std::functionでは動的メモリ確保が生じることがある
- 動的メモリ確保を回避する独自実装は可能だが、受け取れるインスタンスのサイズに制約ができる
- std::function::operator()の呼び出しは仮想関数コールより高コスト
テンプレートによる実装
テンプレートを用いても、関数のように呼び出せるものすべてを受けるコードを書くことができます。
// Funcはintをとりfloatを返す関数
template<typename Func>
void HighFunc1(Func&& func)
{
const float val = func(0);
}
関数のシグネチャからFuncに何を渡したらよいかはっきりしない問題がありましたが、C++20のConceptにより欠点を解消することができるようになりました。コンパイラにもよりますが、間違った型を渡したときのエラー文も以前よりわかりやすくなっているはずです。
template<typename Func>
requires std::is_invocable_r_v<float,Func,int> // Funcとはintをとりfloatを返す関数(っぽいやつ)であるという制約
void HighFunc2(Func&& func)
{
const float val = func(0);
}
ちなみに、それ以前はデフォルト引数でお茶を濁していました。
template<typename Func = float(int)>
void HighFunc4(Func&& func)
ポイント:
- 良くも悪くも、一緒に状態を受け取る
- インスタンスのサイズに制約なく受け取れる
- テンプレートなので、実体化する側のコードから実装を隠すことができない
※余談:キャプチャありのラムダを返したい場合はテンプレートではなく、戻り値型推論を使うことになります。
// 「引数をaと足した結果を返す関数」を返す関数
auto retAddFunc(int a)
{
// ラムダ式はそれぞれ固有の型を持つので推論するしかない
return [a](int b) { return a + b; };
}
まとめ
関数ポインタ | std::function(的なもの) | テンプレート | |
---|---|---|---|
受け取れる状態のサイズ | なし | 動的メモリ確保or有限 | 無限 |
実装の隠蔽 | 可 | 可 | 不可 |
呼び出しにかかるオーバーヘッド | 低 | 高 | 低 |
- 状態を受け取る必要がないなら関数ポインタ
- 実装を隠す必要がないならテンプレート
- どちらでもない場合、std::function(的なもの)
という感じで判断するのが良いかと思います。