オーバーロード解決の制御 #4:SFINAE

オーバーロードの解決は C++ で最も複雑なことの 1 つですが、ほとんどの場合、それについて考える必要はありません。あなたのコントロール。

4 番目の投稿では、タグ ディスパッチに代わる、奇妙な名前の強力な代替手段、SFINAE を紹介します。

はじめに

前回の投稿を覚えていますか?

要約すると、 construct() を書きました 初期化されていないメモリの範囲を取り、デフォルトのコンストラクターを呼び出して初期化する関数。スローするコンストラクターを持つ型が何もリークしないようにするために、例外処理が必要でした。ただし、スローしないコンストラクターを持つ型の場合、このオーバーヘッドは回避できます。

このスイッチをタグ ディスパッチで実装した結果、次のようになりました。

#include <new>
#include <type_traits>

template <typename T>
void construct(std::true_type, T *begin, T *end)
{
 for (auto cur = begin; cur != end; ++cur)
 ::new(static_cast<void*>(cur)) T(); 
}

template <typename T>
void construct(std::false_type, T *begin, T *end)
{
 auto cur = begin;
 try
 {
 for (; cur != end; ++cur)
 ::new(static_cast<void*>(cur)) T(); 
 }
 catch (...)
 {
 for (auto new_cur = begin; new_cur != cur; ++new_cur)
 new_cur->~T();
 throw; 
 }
}

template <typename T>
void construct(T *begin, T *end)
{
 construct(std::is_nothrow_default_constructible<T>{}, begin, end);
}

std::is_nothrow_default_constructible の結果の型に基づく 、別の実装が選択されています。この種の問題にタグ ディスパッチを使用することは非常に洗練されており、私はいつもそれを好んでいます。

しかし、この投稿のために、SFINAE を使用して同じ問題を解決する方法を次に示します。

#include <new>
#include <type_traits>

template <typename T,
 typename = typename std::enable_if<std::is_nothrow_default_constructible<T>::value>::type>
void construct(T *begin, T *end)
{
 for (auto cur = begin; cur != end; ++cur)
 ::new(static_cast<void*>(cur)) T(); 
}

template <typename T,
 typename = typename std::enable_if<!std::is_nothrow_default_constructible<T>::value>::type>
void construct(T *begin, T *end)
{
 auto cur = begin;
 try
 {
 for (; cur != end; ++cur)
 ::new(static_cast<void*>(cur)) T(); 
 }
 catch (...)
 {
 for (auto new_cur = begin; new_cur != cur; ++new_cur)
 new_cur->~T();
 throw; 
 }
}

このコードはまったく同じことを行います。 construct() を呼び出す for - たとえば - int 最初の実装を呼び出し、スロー コンストラクターを持つ型の場合は 2 番目の実装を呼び出します。

これは複雑に見えるので、一歩下がって詳しく見てみましょう。

置換失敗…

コンテナーから値を消去する次の関数テンプレートを検討してください:

template <typename Cont>
void erase(Cont &c, const typename Cont::key_type &value)
{
 c.erase(value);
}

STL 内のすべてのセットとマップに対して呼び出すことができます (したがって std::mapstd::unordered_set 、…) および erase() を持つ他のすべてのタイプ typedef key_type を取るメンバー関数 . std::vector<int> のように別の型で呼び出すとどうなるか ?

コンパイラはテンプレート引数推定を実行します Cont の型を推測します std::vector<int> になる .その後、代入 すべてのテンプレート引数を推定された型に置き換えることで署名 (つまり、引数、戻り値の型) を変更し、次の署名を生成します:

void erase(std::vector<int> &c, const std::vector<int>::key_type &value)

しかし std::vector<int> typedef key_type がありません !したがって、置換プロセスは無効な型になり、§14.8.2[temp.deduct]/8 は次を指定します:

これは単純に、「これがコンパイルされない結果になる場合、型推定は失敗する」という意味です。 「即時のコンテキスト」とは、たとえば次のことを意味します。エラーになる別のテンプレートのインスタンス化は、置換の失敗とは見なされません .

通常、コンパイラ エラー メッセージが表示されるだけです。

…エラーではありません

しかし、関数が次のようにオーバーロードされているとしましょう:

template <typename T>
void erase(std::vector<T> &c, const T &value)
{
 c.erase(std::remove(c.begin(), c.end(), value), c.end());
}

このオーバーロードは、Erase-remove-idiom を使用して std::vector<T> から値を消去します。 .

ここで、コンパイラはオーバーロードの解決を実行する必要があります。これを行うには、name-lookup がスコープ内のその名前を持つすべての関数を見つけた後、関数テンプレートで上記のようにテンプレート引数推定を実行します。置換後、次のシグネチャが得られます。

void erase(std::vector<int> &c, const std::vector<int>::key_type &value)

void erase(std::vector<int> &c, const int &value)

最初のものはいずれにしても無効な式を持っているため、型推定は失敗します.しかし、§14.8.3[temp.over]/1:の微妙な部分により、プログラムはとにかくコンパイルされ、コンパイラは正しいオーバーロードを選択します:

「引数の推論とチェックが成功した場合」、つまり、型推論の失敗がなく、その場合にのみ、関数はオーバーロード解決の候補になります。それ以外の場合はそうではありません。

したがって、過負荷の場合、置換の失敗はエラーではありません - SFINAE .

std::enable_if

erase() で 実装 SFINAE を使用してオーバーロードの解決を制御する方法については既に説明しました。最初のオーバーロードは、key_type を持つコンテナーに対してのみ考慮されます。 typedef 以外の場合は、置換が失敗し、オーバーロード解決の候補とは見なされません。

しかし、construct() はどのように サンプル作品?

まず、std::enable_if を見てみましょう。 、次のように実装できます:

template <bool B, typename T = void>
struct enable_if;

template <typename T>
struct enable_if<false, T> {};

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

したがって、ブール値を最初の値として、オプションのタイプを 2 番目の引数として取ります。ブール値が true の場合のみ メンバ typedef type を持っていますか? .

この例では、テンプレート引数リストで次のように使用しています:

typename = typename std::enable_if<std::is_nothrow_default_constructible<T>::value>::type

これは、デフォルトのテンプレート型引数を名前なしで宣言するだけです。デフォルトは std::enable_if<std::is_nothrow_default_constructible<T>::value> の型です。 .std::is_nothrow_default_constructible<T>::value T のデフォルトのコンストラクターかどうかを調べます noexcept です value を設定します したがって、value の場合 true です 、テンプレート引数はデフォルトで std::enable_if<...>::type に設定されています 、これは単純に void です .ただし、false の場合 、メンバ typedef type がありません std::enable_if で !

おなじみですね?これは置換の失敗につながるため、オーバーロードはオーバーロードの解決の一部とは見なされません。

型と式の SFINAE

しかし、それは醜いです。タグ ディスパッチ バージョンの方がはるかに優れています。では、なぜ SFINAE を使用する必要があるのでしょうか?

これまでに示したものはすべて タイプ SFINAE の例です (存在しないメンバー typedef/value を使用)。ただし、C++11 以降、式 SFINAE もあります。 . 式SFINAE 関数シグネチャの任意の式で発生します。

たとえば、erase() の最初のオーバーロード 次のように指定することもできます:

template <typename Cont, typename Key>
void erase(Cont &c, const Key &value, std::size_t = c.erase(value))
{
 c.erase(value);
}

erase() メンバー関数は Cont::size_type を返します であるため、結果を使用して名前のないパラメーターを初期化できます。 Cont の置換の場合 呼び出しが無効になり、式 SFINAE が開始され、オーバーロードの解決から無視されます。

しかし、式はまだ評価されています。これはバグです!評価されるべきではありません。署名のどこかにそれを持ちたいだけです.したがって、評価されないコンテキストが必要ですが、SFINAE に影響を与えます:

template <typename Cont, typename Key, typename = decltype(c.erase(value))>
void erase(Cont &c, const Key &value)
{
 ...
}

decltype() を使用しました ここ。 decltype() (sizeof() のように 、 noexcept() など) は式を評価せず、その型のみをチェックします。また、型を返すため、デフォルトのテンプレート引数を再度使用しました。しかし、引数の名前が使用できないため、上記のコードはコンパイルされません。そこで、新しいものを作成する必要があります:

template <typename Cont, typename Key, typename = decltype(Cont{}.erase(Key{}))>
void erase(Cont &c, const Key &value)
{
 ...
}

ここで、メンバー関数を呼び出すオブジェクトをいくつか作成しましたが、 Cont{} は R 値であるため、erase() を呼び出すことができない場合があります また、SFINAE は必要以上に作用します:デフォルトのコンストラクターがなければ、候補も失敗します!

したがって、std::declval を使用する必要があります :

template <typename Cont, typename Key, typename = decltype(std::declval<Cont>().erase(std::declval<Key>()))>
void erase(Cont &c, const Key &value)
{
 ...
}

std::declval<T> 単純に T& を返すヘルパー関数です .

その T をどのように作成しますか ?そうではありません。定義がありません! decltype() のような未評価のコンテキストでのみ使用することを意図しています であるため、呼び出されることはないので必要ありません。

したがって、式 SFINAE を使用すると、メンバー関数の存在または他の任意の式の有効性に基づいて、テンプレート化されたオーバーロードを無視することができます。

void_t

しかし、decltype()

1 つの解決策は、マクロを使用することです:

#define SFINAE(Expr) decltype((Expr), int()) = 0

次のように使用できます:

template <typename Cont, typename Key>
void erase(Cont &c, const Key &value, SFINAE(c.erase(value)))
{
 ...
}

int 型の無名のデフォルト パラメータに展開されます。 コンマ演算子が原因です。

しかし、マクロを使用しない別の方法があります。この小さなエイリアス テンプレートです:

template <typename ... Ts>
using void_t = void;

これは単純に void になります 、型の任意の数に関係なく。

template <typename...>
struct voider
{
 using type = void;
};
template <typename ... Ts>
using void_t = typename voider<Ts...>::type;

目的は何ですか?

さて、void_t 任意の decltype() を消費できます 式を作成し、void にします :

template <typename Cont, typename Key>
auto erase(Cont &c, const Key &value) -> void_t<decltype(c.erase(value))>

これはここではあまり役に立ちませんが、SFINAE を使用してテンプレートの特殊化を制御する場合に特に役立ちます (今後のブログ記事のトピック)。

結論

SFINAE では、型が置換された場合に整形式でない式が署名に含まれている場合、特定の関数テンプレートをオーバーロードの解決から無視できます。

これにより、任意の条件 (メンバー関数の存在など) に基づいて実装を選択でき、非常に強力な機能です。

やや読みづらいので、タグディスパッチが使える場合(std::enable_ifで使う場合など)はお勧めしません ).

シリーズの次の投稿では、これまでに示したすべてを組み合わせて、非常に強力なものを実装します:memory::allocator_traits のデフォルトの特殊化 foonathan/記憶の。