はじめに
std::function は、関数ポインタだろうが関数オブジェクト(ラムダ式)だろうが、同様に受け入れてくれて等価に扱えるステキな型です。何より型の指定がわかりやすくて最高です。
std::function<double(double, int)> fn = [](d, i){ return d * i; };
double と int を引数に取り、double を返す関数を扱うことが自明です。ポインタ名が型の中に埋もれてしまう関数ポインタとは大違いです。
この std::function について、
- テンプレートの型引数はどうやって実現しているか
- メモリアロケーションが発生するケース
という2点について、調べた結果をまとめます。
型の指定について
私が std::function に惚れ込んだのは、何より型の指定が関数シグネチャそのもので分かりやすかったからなのですが、これと同じものを作ろうとした時につまずきました。()を含んだ型引数なんて、いったいどうやれば受けられるのだろう?
結論から言うと、次のようにすることで実現できます。
template<typename TFunc>
class MyFunction;
template<typename TResult, typename ...TArgs>
class MyFunction<TResult(TArgs...)> {
// クラス本体の定義を行う
};
型引数を1つ受ける型を宣言だけしておき、返値の型と引数の型(可変長)からなる関数型への特殊化を行って本体を定義する、という流れになります。こうすることで、MyFunction は std::function と同様に、返値型(引数型)
で表現される関数型しか引数に受け付けなくなります。それとともに、定義の中では返値型と引数型を分離して扱えるようになります。
部分特殊化というと、大枠の実装がまずあって、個別の実装を特殊化するというイメージがあったのですが、先に宣言だけしておいて、特殊化した中でのみ定義を行うことで、受け付ける型の制約ができるわけですね。type_traits を使って static_assert にするのも手ですが、const 修飾やポインタ型などによる制約を掛けたい場合は、こちらの方が有効そうです。
何より、関数型も型引数に取れるということを失念していました。つい「変数として定義できる型しか型引数にできない」と思い込んでいましたが、それをいうなら void 型も同じことでしたね。
蛇足かもしれませんが、部分特殊化の例として、ポインタ型しか受け付けないテンプレートクラスの作り方も示しておきます。考え方は関数型に限定する前述のコードと全く同じです。
template<typename TPointer>
class MyPointerWrapper;
template<typename T>
class MyPointerWrapper<T*> {
// ここでクラス本体の定義を行う
};
これで MyPointerWrapper はポインタ型しか受け付けなくなりますが、T という型は特殊化先でも見えるので、その型の特性を利用した実装ができます。先に述べた関数型を扱う部分特殊化と同じ理屈です。
メモリアロケーションについて
std::function は関数ポインタも関数オブジェクトも受け入れてくれると冒頭で述べましたが、どちらが代入されているかにより、実行時に処理が分岐します。このコストは場合によっては無視できない……ということは、結構色んな記事で述べられています。
ここで問題視したいのは、関数オブジェクトを突っ込む時の挙動です。ラムダ式には、任意の個数の変数をコピー、または参照させることができます。いわゆるキャプチャです。当然キャプチャすることで、そのオブジェクトのサイズは増加します。ラムダ式によらない自作の関数オブジェクトにおいても、メンバーに何を持たせるかは自由ですから、どのみち大量のデータを抱えた関数オブジェクトというものは存在し得ることがわかります。
で、それを代入するわけですから、受け取る側にはそれだけのバッファが必要です。std::function がどれだけのバッファを持つかは仕様上規定されていないため、実装依存ということになります。このサイズは、APIで取得する手段が提供されていないため、実際にコードを覗いてみるか、アロケーションを監視しつつ代入するオブジェクトのサイズを増やして検証してみるしかありません。
参考までに、MSVC2015におけるsizeof(std::function) は、x86で40バイト、x64で64バイトです。しかし、このうちどれだけが関数オブジェクトのストレージとして利用されるのかは分かりません。そこでソースコード(functionalヘッダ)を覗くと、次のようなコードが見て取れます。
const int _Num_ptrs = 6 + 16 / sizeof (void *);
const size_t _Space_size = (_Num_ptrs - 1) * sizeof (void *);
この _Space_size を超えるとラージサイズと判定されて、アロケーションが起きるようです。できればアロケーションは発生させたくないものです。この計算式によれば、x86では36バイト、x64では56バイトがストレージ用に確保されていると判断できます。
ではどう対策するかですが、std::function のラッパークラスを作り、関数オブジェクトの代入時にsizeof(argObj) とストレージサイズのチェックを入れる、といった運用になると思います。ストレージサイズが実装依存のマジックナンバーになるのが悔しいですが、とりあえずビルドすることが想定されるコンパイラの中での最小値を調査して使うことになるのかな、と思います。
static_assert(sizeof(argObj) <= AllowedMaxSize);
この AllowedMaxSize が std::function::space_size みたいにして取れるといいんですが……そもそもちゃんとアロケータ対応してくれればよかったんですが……(C++17でユーザーアロケータを利用する仕様が削除)
ここらへんをサポートするクラスを作るうえでは、最初に紹介した関数型を部分特殊化で受けるテクニックが役に立つと思います。
追記:サイズによらずアロケーションが発生するケース
サイズが規定値以下でも、キャプチャしようとする型が「コピーコンストラクタを独自定義している」時点でアロケーションが発生してしまいます。言い換えれば「memcpyだけじゃ正しいコピーにならない型」とも言えます。
何故かは把握していませんが、std::functionにラムダ式を代入する際に、固定長バッファだけでは足りない実装が行われているのでしょう。ここについて掘り下げた記事を近日公開予定です。
追記の追記:いわゆる「memcpyじゃコピーが済まない型」だとアロケーションが生じる理由
「memcpyじゃコピーが済まない型」は、traits的にはis_trivially_constructibleがfalseになる型、と言い換えることができます。
memcpyでは済まないということは、コピーコンストラクタによる処理を通す必要があり、型消去してファンクタをストレージした状態でそれを実現するには、コピーコンストラクタのメンバー関数ポインタを保持しておく必要があります。
というわけで、メンバ関数ポインタを保持しておくための領域を確保しているのでした。メンバ関数ポインタはサイズが膨らむ場合があるため、静的なストレージだけでは賄いきれないと判断したのでしょう。ちょうどMicrosoftがMSVCのSTL実装をgithubで 公開 したことですし、秋の夜長にじっくり読んでみるのもいいかもしれませんね。
まとめ
- std::function が受ける型引数は関数型で、部分特殊化により返値型と引数型をばらして受けられる
- 突っ込む関数オブジェクトのサイズ(とキャプチャする型がmemcpyだけでコピーできること)に気を遣えば、動的アロケーションを気にせず std::function を使える(場合によってはラッパーなりなんなりを用意する)
それは皆さん、楽しいC++ライフを。