highlight

2020年8月6日木曜日

std::is_invocable でオーバーロード関数の呼び出し可否を判定

c++17 で追加された std::is_invocable について考えた。 1つ目のテンプレートパラメタが判定対象の型になっている。ある関数について、ある型の引数を渡したときの呼び出しの可否を調べたい場合、確かに関数(と渡す引数)の型さえ分かれば呼び出しの可否は判定できるので、この形には納得がいく。関数の型は decltype で取ればいい。

#include <iostream>
#include <type_traits>
#include <boost/type_index.hpp>

struct A {};

struct B {};

// no overload
int foo([[maybe_unused]] A const& a, [[maybe_unused]] B const& b) {
    return 42;
}

// overload
int ovlFoo(int, [[maybe_unused]] A const& a) {
    return 9;
}

float ovlFoo(int, [[maybe_unused]] B const& b) {
    return 2.7182;
}

template <class T>
auto ti() {
    return boost::typeindex::type_id_with_cvr<T>();
}

template <class T, class U>
void showTypes() {
    std::cout << "(" << ti<T>().pretty_name() << ", " << ti<U>().pretty_name() << ")" << std::endl;
}
template <class T, class U>
void test0() {
    if constexpr (std::is_invocable_v<decltype(foo), T, U>) {
        std::cout << "Can call foo: ";
    } else {
        std::cout << "Cannot call foo: ";
    }
    showTypes<T, U>();
}
と思いきや、オーバーロードされている関数について、任意の型で呼び出せるか判定したい場合に、はたと手が止まってしまった。

is_invocable の1つ目のパラメタに、decltype で型を取ろうとしても、オーバーロードされた関数の様な複数の型が返ってくるようなものに decltype を適用するのは不適格とされているので使えない。 私はこの時点で一旦 is_invocable の使用は諦めて SFINAE で判定すればいいやと思っていた。 しかしある時 c++20 で導入されたコンセプト std::invocable も同じ様なテンプレート引数を取るようだという事を知ってしまった。 このコンセプトを使う上で、使い方をキチンと分かっておく必要がある気がしたので、ここで漸く真面目に考えたところ、こういう時は関数オブジェクトクラスを使ってたのでは?という単純な事に気がついた。 ラムダが使えるようになってからは出番が激減していたので、その存在が記憶の隅っこに追いやられて居たのか、なかなかコレを思いつかなかった。 最終的に結局はジェネリックラムダを使う方法が良いのかな、という目下の結論に至ったのだが、その過程を記事にする。

基本的な SFINAE

まずは SFINAE の基本形。SFINAE の説明はググれば色々出てくると思う。やり方も様々。 SFINAE の利用法としては至って普通。

// basic sfinae
template <class T, class U>
class CanCallOvlFooSFINAE {
    template <class A0, class A1>
    static auto check(A0* t, A1* u) -> decltype(ovlFoo(*t, *u), std::true_type{});

    template <class, class>
    static std::false_type check(...);
public:
    static constexpr bool value = decltype(check<T, U>(nullptr, nullptr))::value;
};

template <class T, class U>
void testRawSFINAE() {
    if constexpr (CanCallOvlFooSFINAE<T, U>::value) {
        std::cout << "Can call ovlFoo[SFINAE]: ";
    } else {
        std::cout << "Cannot call ovlFoo[SFINAE]: ";
    }
    showTypes<T, U>();

}

void_t を使った SFINAE

void_t は展開中にエラーが無ければ void 型になるという性質を利用すれば SFINAE を簡素に記述できる。

// void_t sfinae
template <class, class, class = void>
struct CanCallOvlFooVoidSfinae : std::false_type {
};

template <class T, class U>
struct CanCallOvlFooVoidSfinae<T, U, std::void_t<decltype(ovlFoo(std::declval<T>(), std::declval<U>()))>>
: std::true_type {
};
template <class T, class U>
void testVoidSFINAE() {
    if constexpr (CanCallOvlFooVoidSfinae<T, U>::value) {
        std::cout << "Can call ovlFoo[void_t]: ";
    } else {
        std::cout << "Cannot call ovlFoo[void_t]: ";
    }
    showTypes<T, U>();
}

is_detected を使った SFINAE

C++20 でも experimental/type_traits ヘッダにしか入ってないので、まだまだ使うのは先になりそうだけど、自前の名前空間に定義しておけば、時代を先取りできる。

namespace detail {
   template <class A, template <class...> class Op, class... Args>
   struct is_detect_impl : std::false_type {};
   template <template <class...> class Op, class... Args>
   struct is_detect_impl<std::void_t<Op<Args...>>, Op, Args...> : std::true_type {};
}
template <template <class...> class Op, class... Args>
using my_detected = detail::is_detect_impl<void, Op, Args...>;
// ここまでがライブラリに入る感じになる見込み。my_detected じゃなくて is_detected として

template <class T, class U>
using CanCallOvlFooDetectOp = decltype(ovlFoo(std::declval<T>(), std::declval<U>()));

template <class T, class U>
using CanCallOvlFooDetect = my_detected<CanCallOvlFooDetectOp, T, U>;

template <class T, class U>
void testDetectedSFINAE() {
    if constexpr (CanCallOvlFooDetect<T, U>::value) {
        std::cout << "Can call ovlFoo[detected]: ";
    } else {
        std::cout << "Cannot call ovlFoo[detected]: ";
    }
    showTypes<T, U>();
}

関数オブジェクト型を使う

ここからが本題に入ってくる。std::is_invocable を使うために、オーバーロード関数の型と同じになるような関数オブジェクト型を定義する。こうすれば、わざわざ SFINAE を使う必要が無かった。 operator() 関数は宣言だけでいい。 余談だが Haskell をかじってから、これをファンクタというのは違和感を感じるようになった。

// function object
struct CanCallOvlFooFO {
    template <class T, class U>
    auto operator()(T&& tv, U&& uv) -> decltype(ovlFoo(std::forward<T>(tv), std::forward<U>(uv)));
};

template <class T, class U>
void testInvocableFO() {
    if constexpr (std::is_invocable_v<CanCallOvlFooFO, T, U>) {
        std::cout << "Can call foo[FunctionObject]: ";
    } else {
        std::cout << "Cannot call foo[FunctionObject]: ";
    }
    showTypes<T, U>();
}

既存のオーバーロード関数を相手するわけではなく、これから自前で定義するという状況なら、関数の実体込みで operator() を定義しておけば、std::is_invocable に型をそのまま渡せるので使い勝手は高まるし、次節の generic lambda 云々の話は不要になる。 なお、このままだと関数呼び出しが煩わしいという事であれば、下の様に inline インスタンスを作っておけばいい。

CanCallOvlFooFO()(args...);  // 余分なカッコのせいで、見た目がいまいち気に入らない

inline constexpr CanCallOvlFooFO canCallOvlFoo = CanCallOvlFooFO{};
canCallOvlFoo(args...);  // 普通の関数と使い勝手が同じ

Generic lambda

関数オブジェクトを使えばオーバーロード関数でも is_invocable を利用できることがわかった。 ということは、ジェネリックラムダ作って decltype する、でも良いんじゃないのか?

// generic lambda
template <class T, class U>
void testInvocableLmbd() {
    auto constexpr lmbd = [](auto&& t, auto&& u)
        -> decltype(ovlFoo(std::forward<decltype(t)>(t), std::forward<decltype(u)>(u))) {};
    if constexpr (std::is_invocable_v<decltype(lmbd), T, U>) {
        std::cout << "Can call foo[invocable(lambda)]: ";
    } else {
        std::cout << "Cannot call foo[invocable(lambda)]: ";
    }
    showTypes<T, U>();
}

ok. これでも is_invocable はちゃんと判定できた。 ラムダの文法は関数本体の {} が必須なので return で何を返すかちょっと考え込んだが、decltype で使うだけなせいか空にしておけば問題なかった。

  • 引数が 2 つになっただけでも返り値の型の decltype() が面倒くさいのなんのって
  • variadic templates にしたほうが寧ろ簡潔じゃないか?オーバーロードしてる関数は引数の数が同じとは限らないし
  • ここまでやるなら関数名を引数に取るマクロにしておくだけで再利用できるな
  • 例外仕様を書かないのが許されるのは小学生までだよねー、きゃはははは
  • 例外仕様の noexcept() の中身は decltype() と同じになるからマクロにしておこう
  • C++20 だとジェネリックラムダにテンプレートパラメタを書けるようになったから使ってみたい
といった事を考えながら整理したコードがこちら。ジェネリックラムダのテンプレート付きも auto のままでも記述量が変わらないのが残念な感じ。 しかも、テンプレート引数名(ここでは _a)消費せずに済む auto のままの方が良さそうに見える。 新しい記法を試してみたものの、この使い方だと従来のジェネリックラムダの方に軍配が上がるかな。手元ではもうこの部分 #if 0 に変えてある。

// general generic lambda macro
#define ReturnTypeSnippet(...) noexcept(noexcept(__VA_ARGS__)) -> decltype(__VA_ARGS__)
#if defined(__cpp_generic_lambdas) && __cpp_generic_lambdas >= 201707L
#define MakeEmptyLambdaFromFunction(FUNC) \
    []<class... _a>(_a&&... arg) ReturnTypeSnippet(FUNC(std::forward<_a>(arg)...)) {}
#else
#define MakeEmptyLambdaFromFunction(FUNC) \
    [](auto&&... arg) ReturnTypeSnippet(FUNC(std::forward<decltype(arg)>(arg)...)) {}
#endif
template <class T, class U>
void testInvocableLambdaMacro() {
    auto constexpr lmbd = MakeEmptyLambdaFromFunction(ovlFoo);
    if constexpr (std::is_invocable_v<decltype(lmbd), T, U>) {
        std::cout << "Can call foo[invocable(make_lambda)]: ";
    } else {
        std::cout << "Cannot call foo[invocable(make_lambda)]: ";
    }
    showTypes<T, U>();
}

これで std::invocable コンセプトが使われてるテンプレート引数に、オーバーロード関数を渡す時代になっても安心だ(多分)。 実のところまだ std::invocable コンセプトについては、リファレンスすらちゃんと読んでないので、本当に大丈夫かは知らない。

0 件のコメント:

コメントを投稿

スパムフィルタが機能しないようなので、コメント不可にしました。

注: コメントを投稿できるのは、このブログのメンバーだけです。