[C++]surrogate call function(代理呼び出し関数)なるもの

surrogate call functionなる物をたまたま見かけたけども、特に日本語の記事とかなかったので調べてみました。

surrogate call function??

以下のような動作をする関数オブジェクトのような何かのことです。

template<typename Func1, typename Func2>
struct Surrogate {

    constexpr Surrogate(Func1* f1, Func2* f2)
        : m_f1{f1}
        , m_f2{f2}
    {}
    
    constexpr operator Func1*(){
        return m_f1;
    }
    
    constexpr operator Func2*(){
        return m_f2;
    }
    
private:
    Func1* m_f1;
    Func2* m_f2;
};

void func1(int n) {
    std::cout << "n = " << n << std::endl;
}

void func2(double f) {
    std::cout << std::setprecision(8) << "f = " << f << std::endl;
}

int main()
{
    Surrogate callable{func1, func2};
        
    callable(128);
    callable(1024.2048);
}

//出力
//n = 128
//f = 1024.2048

[Wandbox]三へ( へ՞ਊ ՞)へ ハッハッ

Surrogateクラスは関数呼び出し演算子を一切実装していないはずなのに関数オブジェクトのように扱えているうえに、オーバーロードが正しく処理されています。
何が起きてるのか分かり辛いですが、よく見ると実装されている二つの演算子オーバーロードは暗黙の型変換演算子です。
つまりは、Surrogate型のオブジェクトに対し関数呼び出しがなされた時、そのような演算子が無いので暗黙の型変換を行い、そこから得られた関数ポインタに対して関数呼び出しを試みてくれます。しかもその際に、引数型に応じてオーバーロード解決までしてくれています。コンパイラさん素敵・・・。
分かってしまえば簡単なのですが、絶対に気付かないしやろうとも思わないでしょう・・・。でもこの挙動は少し悪用できそうな気がしないでもないですね。

ちなみに、関数ポインタでなくてラムダ式を渡すこともできます。ただし、キャプチャをしてはいけません。

//省略

int main()
{
    Surrogate blender{+[](int n){func1(n + n);}, +[](double f){func2(f + f);}};
        
    blender(128);
    blender(1024.2048);
}

[Wandbox]三へ( へ՞ਊ ՞)へ ハッハッ

キャプチャをしていないラムダ式は、同じシグネチャ(引数、戻り値の型が同じ)の関数ポインタへ暗黙変換することが出来ます。しかし、この場合テンプレートパラメータの推論時に関数オブジェクトとしての型が推論されてしまいエラーになるので、単項+演算子(decay operator)を頭につけて、明示的に関数ポインタに変換してやります。
では、Surrogateクラスは関数オブジェクトをメンバに持つようにして、暗黙の型変換はそれの参照を返すようにしても上手くいくのでは?と思いますが、こちらはどうやら許されないご様子、関数呼び出し演算子は実装されていないとエラーになります。

合法?

とても怪しいこの挙動ですが、実は規格にちゃんと記載があります。
N4659 §16.3.1.1.2より

16.3.1.1.2 Call to object of class type

1 If the primary-expression E in the function call syntax evaluates to a class object of type “cv T”, then the set of candidate functions includes at least the function call operators of T. The function call operators of T are obtained by ordinary lookup of the name operator() in the context of (E).operator().

2 In addition, for each non-explicit conversion function declared in T of the form
operator conversion-type-id () cv-qualifier ref-qualifieropt noexcept-specifieropt attribute-specifier-seqopt ;
where cv-qualifier is the same cv-qualification as, or a greater cv-qualification than, cv, and where conversiontype-id denotes the type “pointer to function of (P1,...,Pn) returning R”, or the type “reference to pointer to function of (P1,...,Pn) returning R”, or the type “reference to function of (P1,...,Pn) returning R”, a surrogate call function with the unique name call-function and having the form
R call-function ( conversion-type-id F, P1 a1, ..., Pn an) { return F (a1, ..., an); }
is also considered as a candidate function. Similarly, surrogate call functions are added to the set of candidate functions for each non-explicit conversion function declared in a base class of T provided the function is not hidden within T by another intervening declaration.

3 If such a surrogate call function is selected by overload resolution, the corresponding conversion function will be called to convert E to the appropriate function pointer or reference, and the function will then be invoked with the arguments of the call. If the conversion function cannot be called (e.g., because of an ambiguity), the program is ill-formed.

4 The argument list submitted to overload resolution consists of the argument expressions present in the function call syntax preceded by the implied object argument (E).

以下google翻訳片手の超意訳

16.3.1.1.2 クラスのオブジェクトに対する関数呼び出し

1. obj(P1...Pn)と書かれたときにobjがクラスT(そのCV修飾含む)のオブジェクトであれば、Tの関数呼び出し演算子を(オーバーロードの)候補関数に加える。
2. さらに、Tに暗黙の型変換関数が(1つ以上)宣言されているとき、その変換先の型が
Rを返し引数(P1...Pn)を受け取る関数ポインタ
Rを返し引数(P1...Pn)を受け取る関数ポインタへの参照
Rを返し引数(P1...Pn)を受け取る関数への参照
であれば(CV修飾は同じかより厳しいものである必要がある)
次のような形式の一意の名前(call-function)を持つ代理呼び出し関数として、それらも候補関数に加える。
R call-function ( conversion-type-id F, P1 a1, ..., Pn an) { return F (a1, ..., an); }
※conversion-type-idは型変換関数の変換先の型

そのような暗黙の型変換演算子がTの基底クラスで宣言されているときも同様に扱われる。ただし、そのような型変換演算子が別の宣言によってT内に隠蔽されていない場合に限る(同じシグネチャでTとその基底で型変換演算子が宣言されている場合はTの宣言が優先される)。

3.そのような代理呼び出し関数がオーバーロード解決の結果選択されたとき、対応する暗黙の型変換関数が呼び出され、適切な関数ポインタまたは参照に変換し、与えられた引数で呼び出される。型変換関数を呼び出すことが出来ない場合(例えば、曖昧さのため)はそのプログラムは不適格(ill-formed)となる。

4. オーバーロード解決に用いられる引数リストは、関数呼び出し構文の引数リストの先頭に暗黙のオブジェクト引数を置いた形で構成される(メンバ関数ならthis、代理呼び出し関数ならばconversion-type-id)

つまりは、クラス内の関数ポインタへの暗黙変換演算子は一旦関数の形に置き換えられオーバーロード解決を行うようです。
そしてオーバーロード解決後に型変換等を行う様子。そして、その一旦置き換えられる関数をsurrogate call functionと呼ぶようです。

ちなみにこの挙動、テンプレート完全ガイドという本には記載があったり、stackoverflowの質問が2012年だったりするので、C++03の時代から存在している様子?

なんだかC++の闇を覗いてしまったような気がします。ラムダや関数ポインタを一つのクラスにごちゃ混ぜにして、統一的に呼び出すのは確かにどこかで使うことが出来そうではありますが・・・

おまけ、任意のラムダで同じことをする

やはり時代はラムダなので、ラムダでも同じようなことがしたいです。しかし、surrogate call functionとなれるのは関数ポインタへの変換だけなので、同じ手は使えません。
2つのラムダの関数呼び出し演算子を一所に集め、オーバーロード解決をしたうえで呼び出す、それが出来るのは継承しかありません(多分)。

template<typename Func1, typename Func2>
struct Blender : Func1, Func2 {

    constexpr Blender(Func1& f1, Func2& f2)
        : Func1{f1}
        , Func2{f2}
    {}

    constexpr Blender(Func1&& f1, Func2&& f2)
        : Func1{std::move(f1)}
        , Func2{std::move(f2)}
    {}

    using Func1::operator();
    using Func2::operator();
};

int main()
{
    int bi = 10;
    double bd = 11.0;
    
    Blender blender{[bi](int n){func1(bi + n);}, [bd](double f){func2(bd + f);}};
        
    blender(128);
    blender(1024.2048);
}

[Wandbox]三へ( へ՞ਊ ՞)へ ハッハッ

C++におけるラムダの正体は関数オブジェクトなので、当然継承できます。公開継承しておけば関数呼び出し演算子も継承先で公開されるため、複数のラムダを継承することで一つのクラスに複数の関数呼び出し演算子を実装できます。
そのクラスのオブジェクトで関数呼び出しを行えば、あとは普通のオーバーロード解決と同じプロセスで適切なオーバーロードが選択されます。
ちなみに、C++17のコンストラクタ引数からのテンプレート引数推定が無いと、いったんラムダをautoで受けてからdecltypeするという手順を踏まなければならず、あまり実用的ではありません。
(もう少し素敵な実装→C++ - C++における関数の受け渡しについて|teratail