きっかけ
以前に std::functionのことをもっとよく知りたい 記事を書きましたが、その後色々拗らせすぎて「じゃあもう自分で作るしかないじゃない!」という結論に至りました。
過去記事にも追記したのですが、関数オブジェクトのサイズや、キャプチャした値の型によっては global new によるアロケーションが起きてしまうのが、どうしても我慢ならなかったのです。
とりあえずの目標
- 返り値なし引数なし(void())のラムダ式を保持できるクラスを作る
- std::functionより高性能な点として、暗黙的なアロケーションを起こさないようにする
黒魔術の闇に飲まれないよう、用法用量を守ってスタート。
固定長バッファに placement new でコピーする
最終的には std::function の上位互換を目指しますが、まずはラムダ式を保持して型消去しつつも呼び出しができるものを目指します。
class MyFunction {
public:
static constexpr size_t buffer_size = 128;
template<typename F>
void Register(const F& func) {
static_assert(sizeof(F) <= buffer_size, "Too many captured variables!");
::new(buffer) F(func);
}
void operator()() {}
private:
uint8_t buffer[buffer_size];
};
とりあえずは Register() で登録するスタイルにします。std::function でバッファサイズが取れないのがうらめしかったので、取れるようにしました。サイズオーバーは static_assert で落とします。
placement new でコピーコンストラクタを呼んでいるので、キャプチャした値に関しても正しくコピーがなされるはずです。本当はデストラクタ呼び出しや、MyFunction 自体のコピーコンストラクタ定義も必要ですが、とりあえず省略。
登録時点でメンバ関数アドレスをかすめ取る
さて、オブジェクトはコピーしましたが、これはただのキャプチャした値の塊です。これだけ手元にあっても処理の呼び出しはできません。必要なのは operator() のメンバ関数ポインタです。
class MyFunction {
public:
static constexpr size_t buffer_size = 128;
template<typename F>
void Register(const F& func) {
static_assert(sizeof(F) <= buffer_size, "Too many captured variables!");
::new(buffer) F(func);
auto memFnPtr = &F::operator();
std::memcpy(&ptr, &memFnPtr, sizeof(memFnPtr));
}
void operator()() {}
private:
uint8_t buffer[buffer_size];
uintptr_t ptr = nullptr;
};
だんだん邪悪になってきました。関数のアドレスはプログラムロード時に静的に決まるものですから、これが無効な値になることはないでしょう。メンバ関数ポインタとそうでない型の間ではキャストが厳しく制限されるので、memcpy で有無を言わさずコピります。
ここで1点、私は重大な見落としをしていますが、とりあえず先に進みます。
ダミークラスのメンバ関数ポインタを通してオリジナルの関数を呼び出す
関数のアドレスは記録できたものの、結局元の型が分からなければ意味ないのでは?と最初は思いました。しかし、シグニチャさえ合っていればなんとかなるんじゃなかろうかと考え、次のような手段に出ました。
class MyFunction {
class Invoker {
public:
void Invoke() {}
};
public:
static constexpr size_t buffer_size = 128;
template<typename F>
void Register(const F& func) {
static_assert(sizeof(F) <= buffer_size, "Too many captured variables!");
::new(buffer) F(func);
auto memFnPtr = &F::operator();
std::memcpy(&ptr, &memFnPtr, sizeof(memFnPtr));
}
void operator()() {
decltype(&Invoker::Invoke) memFnPtr;
std::memcpy(&memFnPtr, &ptr, sizeof(memFnPtr));
auto functor = reinterpret_cast<Invoker*>(buffer);
(functor->*(memFnPtr))();
}
private:
uintptr_t ptr = nullptr;
uint8_t buffer[buffer_size];
};
アドレスをメンバ関数ポインタに突っ込むためだけのダミークラス Invoker を定義しています。Invoke() もメンバ関数ポインタ型を取得するためのダミーなので、シグネチャが合っていれば他の名前でも構いません。例によって memcpy でアドレスを代入し、バッファをインスタンスとして解釈して呼び出せば、オリジナルの関数がきちんと呼び出されます。
Invoker にはデータメンバがないので、キャプチャした値がちゃんと参照できるのか?という不安もありましたが、データメンバへのアクセスは関数中に this からのオフセットとして記録されているので、インスタンスポインタが指す先がオリジナルの型と同じレイアウトならば、パチっとはまってくれます。
今回は引数なしですが、可変引数テンプレートを使えば問題なく引数も扱えることでしょう。これで目標達成です、やったね!と言いたいところですが……
メンバ関数ポインタのサイズが常に sizeof(void*) とは限らない
ラムダ式を突っ込むだけならここまでの手順でも十分ですが、任意の関数オブジェクトを受けられるようになってしまっている以上、多重継承している関数オブジェクトが突っ込まれることも想定せねばなりません。そして こちら(ロベールのC++教室) にもあるように、多重継承や仮想継承を使った場合は、コンパイラ依存によりポインタのサイズが変わることが知られています。
つまり、上記の実装に多重継承や仮想継承した型を突っ込むと、まず Register() での memcpy がオーバーランしてバッファを壊します。その上 operator()() での memcpy がアドレスの半分しか行われず、半端なコピーの状態で関数を呼び出そうとするため、見るも無惨な実行時エラーとなります。ああ恐ろしい。
対応策としては、
- Register() 時にアドレスのサイズを記憶しておく
- アドレスの記憶先もバッファにしておき、インスタンスのコピー先をアドレスサイズ分オフセットする
- operator()() で処理を呼び出す際に、多重継承や仮想継承を行った Invoker クラスを用意しておき、アドレスサイズに応じてアドレスの突っ込み先を使い分ける
といったところでしょうか。
class MyFunction {
class Invoker {
public:
void Invoke() {}
};
class Base0 {};
class Base1 {};
class MultipleInheritanceInvoker : public Base0, public Base1 {
public:
void Invoke() {}
};
public:
static constexpr size_t buffer_size = 128;
template<typename F>
void Register(const F& func) {
auto memFnPtr = &F::operator();
static_assert(sizeof(F) <= buffer_size - sizeof(memFnPtr), "");
ptrSize = sizeof(memFnPtr);
::new(buffer + ptrSize) F(func);
std::memcpy(buffer, &memFnPtr, ptrSize);
}
void operator()() {
if (ptrSize == sizeof(void*)) {
Invoke<Invoker>();
}
else if (ptrSize == sizeof(void*) * 2) {
Invoke<MultipleInheritanceInvoker>();
}
}
private:
template<typename I>
void Invoke() {
decltype(&I::Invoke) memFnPtr;
std::memcpy(&memFnPtr, buffer, ptrSize);
auto functor = reinterpret_cast<I*>(buffer + ptrSize);
(functor->*(memFnPtr))();
}
size_t ptrSize = 0;
uint8_t buffer[buffer_size];
};
とりあえず、多重継承に対応したパターンを想定してみました。仮想継承でサイズがさらに膨れる場合はさらに対応が必要となるため、追って調査して追記する予定です。
とりあえずまとめ
- std::function でやっていることを推測しつつ、同等の実装ができた
- 関数のアドレスは色々悪用できそう
- でもメンバ関数ポインタは一筋縄ではいかないから要注意
細かい調整ができたら完成形も追記する予定です。