摘要訊息 : SFINAE 能被用在什麼地方?

0. 前言

《【C++ Template Meta-Programming】SFINAE》文章中, 我們介紹了 SFINAE 以及可能會導致 SFINAE 替換失敗的地方. 本篇文章將綜合地對 SFINAE 進行應用.

更新紀錄 :

  • 2022 年 6 月 2 日進行第一次更新和修正.

1. 可推導語境

《【C++ Template Meta-Programming】函式多載與 SFINAE 初步》文章 Code 6 中的一個實例 :

struct Foo {
    using type = int;
};

template <typename T>
void func(typename T::type) {}

int main(int argc, char *argv[]) {
    func(0);        // no matching function for call to 'func'
}

我們曾經說過, 這個實例產生編碼錯誤的原因是因為編碼器無法確定函式樣板中T的型別. 那麼如何修改函式 func 使得 Code 1 可以通過編碼呢? 這裡需要借用 std::enable_if. std::enable_if接受兩個樣板引數, 第一個樣板引數是布林型別, 第二個樣板引數由使用者來決定, 但是有一個預設樣板引數, 為 void. 通過引數的類別和這個樣板的名稱, 我們大致可以猜測到這個樣板的用處 : 若且唯若第一個樣板引數為true的時候, 開啟; 否則, 關閉. 開啟是指它被實作, 而關閉則是指它未被實作. 這樣, 通過未實作的型別, 它可以被用於 SFNIAE, 使得某些函式從多載函式候選集合中移除. 結合第二個樣板參數, 我們又可以猜到在開啟的情況下,std::enable_if的回傳結果將會是第二個樣板引數; 在關閉的情況下,std::enable_if僅僅被宣告, 未被實作 :

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

如果我們只希望 Code 1 中的函式 func 只接受 int 型別的引數, 那麼可能需要這樣寫 :

#include <type_traits>

struct Foo {
    using type = int;
};

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

template <typename T>
void func(typename enable_if<std::is_same<T, int>, int>::type) {}

int main(int argc, char *argv[]) {
    func(0);        // no matching function for call to 'func'
}

然而 Code 3 仍然存在編碼器錯誤, 並且和 Code 1 是一樣的編碼錯誤. 如果你閱讀過 C++ 標準樣板程式庫的程式碼, 你就會發現std::vector的建構子接受一個範圍的疊代器, 這個範圍的疊代器對應的參數型別使用了std::enable_if. 很顯然, 這個建構子是可以正常運作的. 因此, 我們也希望 Code 3 可以正常運作. 現在我們來探究std::vector接受疊代器的建構子, 它大概長這個樣子 :

template <typename InputIterator>
vector::vector(typename enable_if<is_input_iterator<InputIterator>::value, InputIterator>::type, InputIterator);

這個建構子的參數和函式 func 的參數唯一不同的是, vector::vector 多了一個 InputIterator 的參數, 那麼我們嘗試一下為 func 增加一個 T :

template <typename T>
void func(typename T::type, T) {}

結果發現 func(0, Foo {}); 可以通過編碼了. 問題就在第二個參數之上. 對於 void func(typename T::type); 這個宣告, 雖然我們希望編碼器可以通過尋找所有可見的類別, 找到 Foo 作為 T, 然後令 typename T::type 替換為 int, 使得 func(0); 可以通過編碼, 但是很遺憾這條路行不通. 因為既然 T 可以被替換為 Foo, 萬一有一個 Bar, 其也存在一個名稱為 type 的型別別名成員呢? 那編碼器應該用 Foo 還是 Bar 呢? 很顯然, 編碼器猜不准, 因此只能不猜, 從而產生了編碼錯誤. 對於 void func(typename T::type, T); 來說, 編碼器可以從第二個引數 Foo {} 去推導 T, 從而得知 typename T::typeint. 這樣的情況下, 不需要編碼器去猜測 T 的型別.

void func(typename T::type) 這樣的語境, 在 C++ 中被稱為不可推導語境 (non-deduced context), 而像 void func(typename T::type, T) 這樣的語境就是可推導語境 (deduced context). 在不可推導語境下, 函式的呼叫需要手動明確樣板參數的型別, 例如 func<Foo>(0); 這樣的程式碼就可以通過編碼.

2. 在參數中進行 SFINAE

現在我們知道, 對於類似於 Code 4 這樣的函式, 可以通過真正的原因就在於樣板參數型別是可推導的. 在這種語境下, 我們就可以通過 SFINAE 來限制函式所接受的引數的種類. 例如, 我希望某個函式只接受整型型別的引數, 那麼就可以借助 std::is_integral 先判斷給定的引數型別是否為整型型別, 如果是就讓 std::enable_if 回傳這個型別; 否則, 就讓 std::enable_if 產生替換失敗的情形 :

#include <type_traits>

template <typename T>
void f(T, typename std::enable_if<std::is_integral<T>::value, T>::type);

如果函式 f 只接受一個參數, 那麼應該怎麼辦呢? 有兩種解決方案, 一個是仍然把 SFINAE 產生的最終型別替換為指標放入函式參數當中, 另一種是把 SFINAE 產生的最終型別替換為指標放入樣板參數中 :

#include <type_traits>

template <typename T>
void f(T, typename std::enable_if<std::is_integral<T>::value, T>::type * = nullptr);

template <typename T, typename std::enable_if<std::is_integral<T>::value>::type * = nullptr>
void f(T);

這樣, 就可以通過 f(0); 這樣的形式去呼叫了.

3. 在特製化時進行 SFINAE

如果有一個類別樣板希望樣板參數 T 中的型別成員 typename T::type 進行特製化, 那麼也需要用到 SFINAE :

#include <iostream>

using std::cout;
using std::endl;

template <typename ...>
struct make_void {
    using type = void;
};

template <typename T, typename = void>
struct has_type_member {
    has_type_member() {
        cout << "T doesn't has a member named type" << endl;
    }
};
template <typename T>
struct has_type_member<T, typename make_void<typename T::type>::type> {
    has_type_member() {
        cout << "T has a member named type" << endl;
    }
};

struct Foo {
    using type = char [][3];
};
struct Bar {
    using type1 = char;
};
struct type_private {
private:
    using type = int;
};

int main(int argc, char *argv[]) {
    has_type_member<Foo> check1;        //輸出 : T has a member named type
    has_type_member<Bar> check2;        //輸出 : T doesn't has a member named type
    has_type_member<type_private> check3;       //輸出 : T doesn't has a member named type
}

首先對於超函式make_void, 給定了任何型別我們都讓回傳型別為void. 再來看has_type_member, 它是一個類別樣板, 第二個樣板參數給定了一個預設樣板引數void. 當第二個樣板引數沒有指定的時候, 預設為void, 而typename make_void<typename T::type>::typehas_type_member的偏特製化, 由於超函式make_void回傳的型別typename make_void<...>::typevoid本身就是一個型別, 所以預設會匹配到has_type_member<T, void>上. 但是這個匹配是有條件的, 因為void並不是直接的void, 而是一個位於類別make_void中的型別別名:typename make_void<typename T::type>::type. 一旦T中不存在一個名為type的型別別名, 那麼typename T::type就會失敗, 從而導致偏特製化並不能啟用. 因此, 只能啟用沒有偏特製化的那個版本, 也就是預設的版本. 對於type_private, 雖然類別中存在一個type的型別別名, 但是外界並無法訪問, 所以也相當於對外界來說, 不存在一個名為type的型別別名.

4. 使用 SFINAE 對繼承進行控制

template <typename ...>
struct make_void {
    using type = void;
};

template <typename T>
struct Foo;
template <>
struct Foo<void> {};
template <typename T>
struct Bar : Foo<typename make_void<typename T::type>::type> {};

struct has_type {
    using type = int;
};
struct has_no_type {};

int main(int argc, char *argv[]) {
    Bar<has_type> b;        // OK
    Bar<has_no_type> b2;        // Error : no type named 'type' in 'has_no_type'
}

很遺憾, SFINAE 並不能對繼承進行控制. 在 Code 9 中, 一旦無法看到T中存在一個名為type的型別別名的情況下, 編碼器會毫不猶豫地擲出編碼錯誤, 而並非捨棄繼承.