【C++】いまさらだけどSFINAEを理解する

2024-04-16C++

C++によるプログラミングの醍醐味の1つにテンプレートを駆使したコーディングがあります(と勝手に思っています)。テンプレートを使うと柔軟で強力なコーディングが可能ですが、それなりに知識の必要なところが難点です。生半可に使うとコンパイル時に吐き出される大量のエラーメッセージで泣きを見ることになります。
今回、その中でも知識として曖昧なままだったSFINAEについて調べてみたので自分なりにまとめてみたいと思います。

SFINAEとは

SFINAEとはSubstitution Falilure Is Not An Errorの略で、直訳すると「置き換え失敗はエラーではない」という意味になります。
あるテンプレートの展開において、置き換えに失敗した場合はそこでコンパイルエラーとするのではなく、対象から除外してコンパイルを継続する機能のことです。最終的に適切な置き換えが存在しなかった場合にコンパイルエラーとなります。

これだけみるとSFINAEは決して難しくありません。SFINAEが難しく見えるのはSFINAEのためにメタ関数やらテンプレートの特殊化/部分特殊化、型推論などが入り混じって複雑になり、コンパイル時に何が行われているのかがわかりにくくなっているためと思われます。

なお、SFINAEを応用することで、クラスが特定のメンバを持っているかどうかなど適合チェックに使えたりもしますが、本稿では扱いません。

SFINAEの例

例1

まずは、C++の勉強のためによく使わせてもらっているcpprefjp – C++日本語リファレンスを参考にして作成した以下のコードを元に、SFINAEについて考えてみます。

#include <iostream>
#include <vector>
#include <array>

template <typename T>
auto print(T v) -> decltype(std::begin(v), void())  // (1)
{
    std::cout << "container" << std::endl;
}

template <typename T>
auto print(T []) -> void                            // (2)
{
    std::cout << "array" << std::endl;
}

template <typename T>
auto print(T v) -> decltype(v + 0, void())          // (3)
{
    std::cout << "value" << std::endl;
}

#if 0
// (3)を次のように書くとコンパイルエラー
template <typename T>
auto print(T t) -> void
{
    std::cout << "value" << std::endl;
}
#endif

template<>
auto print<float>(float v) -> void                  // (4)
{
    std::cout << "value(specialized for float)" << std::endl;
}

void print(...)                                     // (5)
{
    std::cout << "other type" << std::endl;
}

int main()
{
    std::vector<float>  a = { 0, 1, 2 };
    double b[3] = { 0, 1, 2 };
    std::array<int, 3> c = { 0, 1, 2 };
    int d = 0;
    struct { int x; int y; } e = { 0, 1 };

    std::cout << "a: ";        print(a);        // (a-1)
    std::cout << "a.data(): "; print(a.data()); // (a-2)
    std::cout << "a.at(0): ";  print(a.at(0));  // (a-3)
    std::cout << "b: ";        print(b);        // (b)
    std::cout << "c: "   ;     print(c);        // (c-1)
    std::cout << "c.data(): "; print(c.data()); // (c-2)
    std::cout << "d: ";        print(d);        // (d)
    std::cout << "e: ";        print(e);        // (e)

    return 0;
}

出力結果:

a: container
a.data(): array
a.at(0): value(specialized for float)
b: array
c: container
c.data(): array
d: value
e: othre type

上の例では5つのprint()関数が定義されています。戻り値の型はいずれも同じで、(1)と(3)は引数の型も同じでです。にもかかわらず、コンパイル可能で意図したとおりに関数が呼び分けられています。

どのように適切な関数が選択されているのか考えてみます(間違っていればご指摘いただけると幸いです)。

  1. (a-1)(c-1)のケース
    関数の引数から見ると(1)(3)が候補となるが、decltype()を評価すると、(3)のv + 0はコンテナに対してoperator+()が定義されていないので、不適格となり候補から外れる(SFINAE)。一方、(1)のstd::begin<T>(v)は有効であるため、(1)が選択される。

  2. (a-2)(b)(c-2)のケース
    いずれもポインタであるため、引数がポインタとなっている(2)が選択される。

  3. (d)のケース
    関数の引数から見ると(1)(3)が候補となるが、decltype()を評価すると、(1)のstd::begin<int>(v)は不適格であるため候補から外れる(SFINAE)。一方、(3)のv + 0intに対して定義されていて有効なため、(3)が選択される。

  4. (a-3)のケース
    関数の引数から見ると(1)(3)(4)が候補となるが、floatに特殊化された関数が存在するため、(4)が選択される。

  5. (e)のケース
    (1)~(4)いずれも候補とならないため、最後の候補として残った(5)が選択される。

なお、#if 0#endifでくくった部分を(3)の代わりに書くと、(a-1)(c-1)に対して候補が2つ存在することになり、曖昧さを解決できずにコンパイルが通らなくなります。

例2

#include <iostream>
#include <vector>

template <typename T, typename U = typename T::iterator>
void print(T t)
{
    std::cout << "Iterator available." << std::endl;
}

void print(...)
{
    std::cout << "No iterator." << std::endl;
}

int main()
{
    std::vector<int> a = { 0, 1, 2 };
    int b = 0;

    std::cout << "a: " << print(a);
    std::cout << "b: " << print(b);

    return 0;
}

出力結果:

a: Iterator available.
b: No iterator.

この例では、intに対してはiteratorは定義されていないので、SFINAEにより2番目のprint()が選択されています。

std::enable_if

SFINAEをうまく働かせるためによく使われるメタ関数として<type_traits>ヘッダで定義されるstd::enable_if<>があります。

std::enable_if<>は、条件式Condと型Tを取り、Condtrueの時に型Tを定義します。falseの時は型が定義されないので通常不適格となり、SFINAEによって対象から外れます。型Tは省略可能でその場合にはvoidが定義されます。

実際の実装はこんな感じのようです。

template <bool Cond, typename T = void>
struct enable_if {};

template <typename T>
struct enable_if<true, T> {
    using type = T;
};

template <bool Cond, typename T = void>
using enable_if_t = typename enable_if<Cond, T>::type; // C++14以降

先に挙げた例1の(3)を算術型に限定したい場合は、次のように使います。decltype(v + 0)みたいなことをやるよりは、意味がわかりやすいかと。

template <typename T>
auto print(T t) -> typename std::enable_if_t<std::is_arithmetic_v<T>>
{
    std::cout << "arithmetic value" << std::endl;
}

または、

template <typename T, typename = std::enable_if_t<std::is_arithmetic_v<T>>>
auto print(T t) -> void
{
    std::cout << "arithmetic value" << std::endl;
}

また、std::enable_if<>でよく見かけるのはenablerというイディオム的な使い方です。

extern void * enabler;

template <typename T, std::enable_if_t<std::is_arithmetic_v<T>>*& = enabler>
auto print(T t) -> void
{
    std::cout << "arithmetic value" << std::endl;
}

最近ではこちらの方が推奨されるようですね。

template <typename T, std::enable_if_t<std::is_arithmetic_v<T>, std::nullptr_t> = nullptr>
auto print(T t) -> void
{
    std::cout << "arithmetic value" << std::endl;
}

enablerについては、以下が参考になります。

  1. 本の虫: C++0xにおけるenable_ifの新しい使い方
  2. std::enable_ifを使ってオーバーロードする時、enablerを使う? – Qiita

std::enable_ifを使う際の注意点

そのstd::enable_ifは本当に必要か?

std::enable_if<>を知ったばかりの頃にやりがちなのが、「特定の性質を持った型を受け入れる関数」だけを作って、後はコンパイルエラーで弾くような実装をしてしまうことです(自分だけか?)。

よく考えて見れば、std::enable_if<>はSFINAEを補助し、コンパイル時の曖昧さを回避する手段の1つに過ぎません。上の目的であれば素直にstatic_assert()を使うべきだと思います。自分でエラーメッセージも書けるし。

typename = std::enable_if<…>とenablerの違い

この両者の文法的な違いを理解しておくこと。自分もやってしまったのが、以下のように前者の形でテンプレート関数を複数定義することでしょうか。これはコンパイルできません。

template <typename T, typename = std::enable_if_t<std::is_integral_v<T>>>
T func(T t) {...}

template <typename T, typename = std::enable_if_t<std::is_floating_point_v<T>>>
T func(T t) {...}

これは次の形にすると、よくわかるはず。

template <typename T, typename T2 = (型T2のデフォルトの型1)>
T func(T t) {...}

template <typename T, typename T2 = (型T2のデフォルトの型2)>
T func(T t) {...}

実際には型T2が省略されていて、template <typename T1, typename T2> T func(T t){} という同じテンプレート関数に2つの異なるデフォルトの型を与えていることに気づけば、コンパイルの通らない理由がわかると思います。

こういうときには、enablerを使用するのが良いです。

template <typename T, std::enable_if_t<std::is_integral_v<T>, std::nullptr_t> = nullptr>
T func(T t) {...}

template <typename T, std::enable_if_t<std::is_floating_point_v<T>, std::nullptr_t> = nullptr>
T func(T t) {...}

enablerでは、Tに整数型を渡した場合、上がfunc<整数型, std::nullptr_t = nullptr>()、下は不適格となり、SFINAEにより上が有効になります。同様にTに浮動小数点型が渡された場合、上が不適格、下はfunc<浮動小数点型, std::nullptr_t = nullptr>()となることで、SFINAEにより下が有効になる。

(参考)C++ – SFINAE できるコードとできないコードの違い|teratail

if constexpr(C++17以降)

C++17でコンパイル時に評価されるif constexprが導入されたことで、std::enable_if<>の使用頻度を減らせるようになったようです。

template <typename T>
T func(T t)
{
    if constexpr(std::is_integral_v<T>)
    {
        // 整数型の時の処理
    }
    else if constexpr(std::is_floating_point_v<T>)
    {
        // 浮動小数点型の時の処理
    }
    else
    {
        // それ以外の処理
    }
}

std::enable_if<>を使うより、だいぶすっきりと書けますね。SFINAEも考えなくて良くなります。

まとめ

テンプレートを使ってプログラムを作成するときに重要なSFINAEと、SFINAEをうまく働かせるための仕組みの1つであるstd::enable_if<>について調べてまとめてみました。正直なところまだ完全に理解できたとは言い難いですが、少しずつ分かってきたと思います。

  • SFINAEはテンプレートの展開時に不適格なものがあっても、すぐにコンパイルエラーとせずに、他に適切なものがないかコンパイラが探してくれる仕組み
  • std::enable_if<>メタ関数はSFINAEを働かせるための仕組みの1つ
  • std::enable_if<>を使うオーバーロード関数が複数ある場合にはenablerを使う
  • C++17以降で導入されたif constexprを使うと、すっきり書ける場合がある

最新のC++20ではコンセプトというものが導入されて、さらにSFINAEを意識するコードを書かなくても良くなったらしいので、そのうち調べてまとめてみたいとおもいます。

C++,SFINAE

Posted by izadori