摘要訊息 : 在 C++ 中哪些情況會使用 SFINAE?

0. 前言

在上一篇《【C++ Template Meta-Programming】函式多載與 SFINAE 初步》文章中, 我們講述了一種 SFINAE 替換失敗的示例. 在本篇文章中, 我們將把剩餘的替換失敗的示例呈現出來, 並且將 SFINAE 運用到實際的編碼中.

更新紀錄 :

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

1. 與型別相關的 SFINAE

如若嘗試建立一個 void [] 型別的陣列, 參考的陣列, 函式的陣列, void 型別的參考, 指向參考的指標, 參數列表含有 void 參數的函式型別, 回傳陣列型別或者函式型別的函式和回傳型別或者參數列別中含有抽象類別的函式, 那麼在替換的時候就會產生失敗, 含有這些情況的函式將從候選函式中移除.

如若嘗試建立一個大小為負的陣列, 非整型型別的陣列和大小為 0 的陣列, 也會導致替換失敗 :

#include <iostream>

template <size_t N>
void func(int [N % 2 == 0 ? N : 0 /* 此處的 0 可以改成負數或著非整型 */ ] = nullptr) {
    std::cout << "even" << std::endl;
}
template <size_t N>
void func(char [N % 2 == 1 ? N : 0.1f] = nullptr) {
    std::cout << "odd" << std::endl;
}

int main(int argc, char *argv[]) {
    func<2>();      // 輸出結果 : even
    func<3>();      // 輸出結果 : odd
}

如若試圖對非類別型別或者非列舉型別使用作用範圍運算子 ::, 會導致替換失敗 :

#include <iostream>

struct Foo {
    using type = int;
};

template <typename T>
void func(typename T::type) {
    std::cout << "T is class or enum" << std::endl;
}
template <typename T>
void func(T) {
    std::cout << "T isn't class or enum" << std::endl;
}

int main(int argc, char *argv[]) {
    func<char>(0);      // 輸出結果 : T isn't class or enum
    func<Foo>(0);       // 輸出結果 : T is class or enum
}

其實我們可以使用這個方法來區分內建型別和非內建型別. 因為可視範圍運算子 :: 不能用於所有內建型別.

試圖使用某一個不存在的成員型別, 會導致替換失敗. 這個就不舉例了, 因為《【C++ Template Meta-Programming】函式多載與 SFINAE 初步》第 3 節中的實例就是最好的例子.

試圖將某個不是型別的成員作為型別使用, 會導致替換失敗 :

struct Foo {
    static int i;
};
int Foo::i {0};

template <typename T>
void func(typename T::i);

int main(int argc, char *argv[]) {
    func<Foo>(0);       // Error, candidate template ignored: substitution failure [with T = Foo]: typename specifier refers to non-type member 'i' in 'Foo'
}

試圖在樣板樣板參數處使用非樣板的型別, 會導致替換失敗 :

template <template <typename> class>
class Foo {};

template <typename T>
void func(Foo<T::template T> * = nullptr);

struct Bar {
    template <typename U>
    struct T {};
};
struct Baz {
    using T = int;
};

int main(int argc, char *argv[]) {
    func<Bar>();        // OK
    func<Baz>();        // Error, candidate template ignored: substitution failure [with T = Baz]: 'T' following the 'template' keyword does not refer to a template
}

要求非型別處給定了型別, 會導致替換失敗 :

template <int N>
struct Foo {};

struct Bar {
    using num = int;
};

template <typename T>
void func(Foo<T::num> * = nullptr) {}

int main(int argc, char *argv[]) {
    func<Bar>();        // Error, candidate template ignored: substitution failure [with T = Bar]: missing 'typename' prior to dependent type name 'Bar::num'
}

試圖建立指向 T 成員的指標, 但是 T 不是類別型別的話, 會導致替換失敗 :

#include <iostream>

template <typename T>
void func(int T::*) {
    std::cout << "is class" << std::endl;
}
template <typename>
void func(...) {
    std::cout << "not class" << std::endl;
}

struct Foo {};

int main(int argc, char *argv[]) {
    func<Foo>(0);       // 輸出結果 : is class
    func<int>(0);       // 輸出結果 : not class
}

試圖在樣板引數中進行型別轉換, 會導致替換失敗 :

#include <iostream>

template <int, int *>
void func() {
    std::cout << "int and int *" << std::endl;
}
template <int, int>
void func() {
    std::cout << "int and int" << std::endl;
}

int main(int argc, char *argv[]) {
    func<0, 0>();       // 輸出結果 : int and int
    func<0, nullptr>();     // 輸出結果 : int and int *
    func<nullptr, nullptr>();       // Error, candidate template ignored: invalid explicitly-specified argument for 1st template parameter
}

試圖將非法型別放入非型別樣板參數中, 會導致替換失敗 :

#include <iostream>

template <typename T, T = T()>
struct S {};

template <typename T>
void f(S<T> * = nullptr) {
    std::cout << "S<T> *" << std::endl;
}

struct Foo {};

int main(int argc, char *argv[]) {
    f<int>();       // 輸出結果 : S<T> *
    f<Foo>();       // Error, candidate template ignored: substitution failure [with T = Foo]: a non-type template parameter cannot have type 'Foo'
}

2. 與表達式相關的 SFINAE

若函式參數列表或者樣板參數列表中含有病式的表達式, 會導致替換失敗. 下面以 std::add_lvalue_referencestd::is_assignable 的實作為例.

2.1 std::add_lvalue_reference

std::add_lvalue_reference 的作用是放入一個型別, 然後回傳這個型別的左值參考. 但是, 我們從前面可以看到, 當為 void 型別添加參考的時候, 也就是 void & 以及 void && 都是不合法的型別, 因此我們要為實作一個有這樣行為的超函式 : 當放入的型別可以被添加參考的時候, 那麼為其添加左值參考; 否則, 回傳原來的型別. 有了以上思路, 我們首先要知道, 要想實作這樣一個超函式, 需要借助函式回傳型別以及 decltype 的幫助. 這個關鍵字和 auto 相比看似沒什麼作用, 因為有 auto 更加方便. 但是 decltype 在樣板超編程中的作用十分強大, 樣板超編程中的很多函式是不需要被實作的, 它們只需要被宣告即可. 但是要想實際獲得這個函式的回傳型別, 需要呼叫它才行. 但是實際上它們沒有被實作, 所以並沒有辦法進行實際運作, 那麼 decltype 就允許我們在編碼期就獲得函式呼叫的回傳型別.

《【C++ Template Meta-Programming】函式多載與 SFINAE 初步》第 1 節我們已經講過, 在替換的時候產生一個不存在的型別會導致替換失敗, 編碼器會另外尋找合適的函式去匹配. 因此, 我們可以實作兩個函式, 一個函式用來添加參考, 另外一個函式專門用來匹配無法添加參考的情況 :

template <typename T>
T &f(int);
template <typename T>
T f(long);

現在我們就不能讓參數列表為空了, 因為像 intfloat 這樣一般的型別, 都是可以添加參考的, 如果參數列表為空, 就會產生編碼錯誤. 對於 f<T>(0), 如果 T 可以添加參考成為 T &, 那麼就會優先匹配 T &f(int); 因為 0int 的匹配是精確匹配, 優先級別比 long 要高. 只有當 T 不能添加參考的時候, 才會通過隱含型別轉換匹配 T f(long);. 如果要用到的多載多於兩個, 那麼我們還可以使用 long long, char, unsigned 等這些整形型別. 除了整型型別之外, 浮點數型別和指標型別都是可選用的, 甚至還可以使用 T f(...);.

現在, 我們可以通過 decltype(f<T>(0)); 來推導回傳型別了 :

template <typename T>
T &f(int);
template <typename T>
T f(long);

template <typename T>
struct add_lvalue_reference {
    using type = decltype(f<T>(0));
};

2.2 std::is_assignable

std::is_assignable 的作用是測試指派操作是否合法, 例如 int a = 'a'; 是合法的, int *p = 1; 就是不合法的. 這個超函式的實作方式和上面的有一些不太一樣, 我們不但需要借助 decltype 的幫助, 還需要另外一個樣板超編程的重要成員 declval 的幫助. declval 是一個函式, 它也是不建議被明確呼叫的 (它通常也是僅宣告不實作的), 它產生某個型別對應的右值物件, 不管某個型別是否有建構子或者它是否可以被建構. 我直接展示出 declval 的實作方式 :

template <typename T>
T &&declval_impl(int);
template <typename T>
T declval_impl(long);

template <typename T>
decltype(declval_impl<T>(0)) declval();

要檢測一個指派是否合法, 我們可以寫成 declval<T>() = declval<U>(). 其中, TU 既可以是一般型別, 也可以是參考型別. 如果一個指派不合法, 那麼 declval<T>() = declval<U>() 的型別是無法推導的, 所以將其放入 decltype 中作為函式的回傳型別就可以了. 現在, 我們還需要兩個標記, 用於回傳指派的合法性. 由於 std::is_assignable 中有一個 bool 型別的 value 成員, 所以這兩個標記也必須可以轉型為 bool 型別. 因此我們借助

struct true_type {
    constexpr operator bool() const noexcept {
        return true;
    }
};
struct false_type {
    constexpr operator bool() const noexcept {
        return false;
    }
};

來表示指派的合法性, true_type 表示指派可以成功, false_type 表示指派會失敗.

除了這些之外, 我們還需要實作一個輔助的超函式. 因為直接將 decltype(declval<T>() = declval<U>()) 作為回傳型別的話, 我們就無法獲得成功與否的標記, 所以必須實作一個接受兩個樣板參數的超函式, 並將 decltype(declval<T>() = declval<U>()) 放在其中一個樣板參數中.

#include <type_traits>

template <typename T>
T &&declval_impl(int);
template <typename T>
T declval_impl(long);

template <typename T>
decltype(declval_impl<T>(0)) declval();

struct true_type {
    constexpr operator bool() const noexcept {
        return true;
    }
};
struct false_type {
    constexpr operator bool() const noexcept {
        return false;
    }
};

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

template <typename T, typename U>
typename SFINAE_helper<true_type, decltype(declval<T>() = declval<U>())>::type f(int);
template <typename, typename>
false_type f(long);

template <typename T, typename U>
struct is_assignable {
    constexpr static bool value {decltype(f<T, U>(0)) {}};
};

static_assert(is_assignable<int, int>::value);       // Error
static_assert(is_assignable<int &, int>::value);     // OK
static_assert(is_assignable<char &, int>::value);        // OK
static_assert(is_assignable<void *&, decltype(nullptr)>::value);        // OK

這裡我要解釋一下為什麼 is_assignable<int, int>::valuefalse. declval<int>() 產生的物件是 int && 型別, 是一個右值. 對一個 int 右值指派就相當於 1 = 2 這種操作, 顯然是不可行的, 只有左值才可以指派.