C++ 模板簡介(一)—— SFINAE

SFINAE, 類型檢查, Concepts

​ SFINAE 機制是組成 C++ 模板機制及類型安全的相當重要的基礎。全稱是 Substitution failure is not an error。大概的意思就是隻要找到了可用的原型(比如函數模板、類模板等)就不會編譯錯誤。SFINAE 可以被用來進行模式匹配。在嘗試本篇代碼時請打開 C++17。

https://en.cppreference.com/w/cpp/language/sfinae

導入

​ 爲什麼我們需要類型安全?除了能夠保證用戶調用我們編寫的函數時傳錯參數之外,我們還可以避免這個情況:

struct A {};
vector<A> v;
sort(v.begin(), v.end());

你可以看到一大坨一大坨的信息(真的很多,你試着編譯一下就知道有多少(我可以告訴你就因爲沒有爲 A 添加小於號運算符,產生了 200 行的編譯錯誤信息)。

如果我們採用了這篇文章中的機制,我們可以將編譯錯誤信息限制到 30 行以內(友好多了)。

不過如果你打開了 C++20,那麼編譯錯誤信息就會相當好看,然後本篇博客就被廢掉了

SFINAE

純模板參數

我們看下面的一個例子:

template <typename T>
struct A;

template <>
struct A<int>
{
    typedef int value_type;
};

template <class T, class U = typename A<T>::value_type>
void func(T);

如果我們調用 func(int),那麼上面的代碼就可以編譯,但是調用func(double)時,就會報錯:

test.cpp: In function ‘int main()’:
test.cpp:18:13: error: no matching function for call to ‘func(double)’
     func(0.0);
             ^
test.cpp:11:6: note: candidate: template<class T, class U> void func(T)
 void func(T t)
      ^
test.cpp:11:6: note:   template argument deduction/substitution failed:
test.cpp:10:20: error: invalid use of incomplete type ‘struct A<double>’
 template <class T, class U = typename A<T>::value_type>
                    ^
test.cpp:2:8: note: declaration of ‘struct A<double>’
 struct A;
        ^

意思是我們在調用了 func(double) 時,func的完整的類型其實是func<double, typename A<double>::value_type>,也就是說 func 的類型依賴於 A<double>::value_type,但是我們知道我們只定義了 A<int>::value_type,而並未對其他的模板參數特化,也就是說 A<double> 其實是一個不完整的類型,顯然我們不可以調用不完整的類型,因此編譯失敗。

函數參數(模板相關)

我們再來看另一種例子:

struct A { typedef int typeA; };
struct B { typedef int typeB; };
struct C { typedef int typeC; };
template <typename T> void func(typename T::typeA) { cout << 1; }
template <typename T> void func(typename T::typeB) { cout << 2; }
template <typename T> void func(T) { cout << 3; }

int main()
{
    func<A>(1); // 輸出 1,匹配到了第一個 func(只要找到一個匹配的即可)
    func<B>(2); // 輸出 2,由於第一個 func 不能匹配,看第二個 func,匹配到了
    func<C>(3); // 編譯失敗,因爲 C 既沒有 typeA,也沒有 typeB,兩個 func 都不能匹配,編譯失敗
    func<int>(4); // 輸出 3
}

看到上面的例子中 func 能匹配到相應的函數,這是因爲匹配條件是唯一不衝突的(因此定義的順序是沒有關係的,因爲不會產生歧義),我們再來看:

template <typename T> void func(typename T::typeA) { cout << 1; }
template <typename T> void func(typename T::typeB) { cout << 2; }
template <typename T> void func(int) { cout << 3; }

如果我們的func函數是這麼定義的,那麼可以讓func<C>(3)編譯通過。但是func<A>(1)func<B>(2)都將會編譯失敗,因爲這兩個函數調用既可以匹配前兩條,又可以匹配第三條。所以會產生歧義從而編譯失敗。

其他

下面是 C++ Reference 上提到的一個例子:

template <int I> void div(char(*)[I % 2 == 0 ? 1 : -1] = 0) {
    // this overload is selected when I is even
}

template <int I> void div(char(*)[I % 2 == 1 ? 1 : -1] = 0) {
    // this overload is selected when I is odd
}

這個例子很有趣,div 函數分成了兩份,一份只在 I爲偶數的情況下調用,一份只在I爲奇數的情況下調用。首先這兩個函數利用了函數參數類型的不一致從而避免了調用的歧義,其次再利用兩個鍾必有一個參數需要維度爲負數的數組會編譯失敗的性質,根據 SFINAE 原則,選取那個不會編譯失敗的函數進行調用。從而區分開來了兩個函數。多說一下:

​ 所以你可能會想我們爲什麼要費這麼大勁這麼寫兩個 div 來區分 I 的奇偶性?而不是用 if 判斷?這就涉及零開銷的問題了,因爲 I 的奇偶性我們在編譯期就可以知道,那麼判斷 I 的時間如果能在編譯時完成,如果再到運行時每次判斷一下,就會造成運行時的額外開銷。(很多 C++ 程序編譯的時候都有跑編譯的服務器集羣跑的)

​ 我們可以抽取一下使得這段代碼更加易於閱讀(需要啓用 C++11):

template <int I> void div(typename std::enable_if<(I % 2 == 0)>::type * = 0) {
    
}
template <int I> void div(typename std::enable_if<(I % 2 == 1)>::type * = 0) {
    
}

​ 我們在參數中使用了 enable_if 這個結構體代替了聲明一個數組。enable_if 的模板參數爲真時 type 才存在,否則不存在(就像之前的 A::value_type 是否存在一樣)。然後參數中我們定義了 type 的指針,省略了這個參數的參數名,並且爲其添加了默認值 0 使得我們不需要爲其傳值。至此你應該也能理解之前我們聲明 char 數組時後面的 =0 是什麼意思。我們之後詳細介紹 enable_if 的內容。

​ 我們可以使用模板的偏特化來模仿上面的例子(函數不支持模板的偏特化,所以只能用結構體內的靜態函數代替)

template <int I, bool = I % 2>
struct div;

template <int I, true>
struct div
{
    static void work() {
        
    }
};

template <int I, false>
struct div
{
    static void work() {
        
    }
};

應用

限定參數是特定的類型

我們花了很多篇幅介紹了 SFINAE 是什麼,那麼它能做什麼?我們瞭解一下 <type_traits> 模板庫中的函數。

假如我們現在有如下需求:

template <typename T> T div(T t) { return t / 2; }

我們希望這個函數的參數是整型(bool, int, long, unsigned 等),而不希望是浮點型或者其他類型的變量傳入(否則就不是向下取整的除以 2)。要怎麼做呢?一種簡單的想法是利用函數重載:

int div(int i) { return i / 2; }
long div(long i) { return i / 2; }
float div(float i) { return floor(i / 2); }
double div(double i) { return floor(i / 2); }

可是,如果要覆蓋所有的基本類型,無疑要爲每一個基本類型都寫一遍重載才能實現完整的類型覆蓋,有沒有更簡單的方法呢?

template <typename T>
typename std::enable_if<std::is_integral<T>::value, T>::type div(T t) {
    return t / 2;
}

template <typename T>
typename std::enable_if<std::is_floating_point<T>::value, T>::type div(T t) {
    return std::floor(t / 2);
}

// 對於既不是整型,又不是浮點數的類型,就會因爲匹配不到兩個函數從而編譯失敗

首先我們先介紹一個幫助模板 is_integral<T>,其模板參數爲整型時,is_integral<T>::value 爲真,否則爲假。由於我們要利用 SFINAE 來實現類型檢查,所以我們要在函數的某個地方插入一些代碼使得模板參數 T 不爲整型時這個函數將會編譯失敗從而讓編譯器不選擇這個函數。縱觀函數,我們發現返回值相當適合用來判斷,我們之前介紹了 enable_if 的用法,其原理就是當模板參數中的布爾值爲假時其 type 不存在從而導致編譯失敗進而阻止編譯器採用該函數。那如果布爾值爲真呢?type 就是 T。也就是說我們繞了一圈,最後回到了 T。最後我們只要將其模板參數中的布爾值令爲 is_integral_v<T> ,就可以在 T 爲整型的情況下 type 存在且爲 T 從而不改變該函數的真實返回類型。

enable_if

我們之前不斷地提到了 enable_if 這個模板,怎麼實現的呢?相信你通過之前的描述能夠自己想出來怎麼實現的,這裏給出一種普通的實現方式:

template <bool Cond, class T = void> struct enable_if {};
template <class T> struct enable_if<true, T> { typedef T type; };

那麼如何利用這個 enable_if 就要發揮你的想象啦

判斷是否存在某個函數

你可能想在 C++ 中使用類似接口的東西,比如這樣:

struct counter_base { virtual void count() = 0; };
struct counter : public counter_base {
    virtual void count() override {
        // do something
    }
};

然後你就可以這麼幹:

void count(counter_base &i) { i.count(); }

這樣如果我們調用了 foo(counter()),那麼 i.count() 將會調用 counter::count。同時我們可以確保傳進來的變量 i 確實有 count() 這個函數。

但是!如果 foo 這個函數調用的地方實在是太多了,多到居然虛函數居然會影響程序性能,以至於你被迫不這麼幹的時候,你要怎麼做呢?大概就是:

template <typename T> void count(T &i) { i.count(); }

這樣完全可以,我們確保了 T 確實有 count,否則會編譯失敗。

但是如果我們哪天添加了一個需求:允許 count<int>(var),然後使 var++來表示一次計數,你現在的程序就失效了。那麼我們要怎麼辦呢?解決提出問題的人

那麼我們就需要使用 SFINAE 了,考慮如何判斷一個函數是否存在。我們只能通過調用對象實例的 count 函數才能知道是否存在,那麼這個並不能使用類型的 SFINAE 檢查,因爲我們目前還沒有一個工具可以以布爾值的形式得到一個函數是否存在,也就是說 enable_if 還無法使用。考慮 decltype 關鍵字,我們知道 decltype 關鍵字能得到一個表達式的類型,同時在表達式不合法時編譯失敗。那麼我們可以考慮通過表達式檢查模板類型 T 是否有 count 函數。比如一個函數的參數類型或返回類型中使用 decltype(i.count()),就可以出現這個函數編譯失敗的情況。那麼接下來我們如何判斷函數是否編譯失敗?答案就是 SFINAE。參考之前 div 參數的寫法,我們就可以得到如下的程序:

#include <bits/stdc++.h>
using namespace std;

struct counter {
    void count() {
        std::cout << "count";
    }
};

template <typename T>
struct has_count {
    
    template <typename K>
    static std::true_type test(decltype(std::declval<K>().count()) *);

    template <typename K>
    static std::false_type test(...); // 使用 ... 就可以區分開兩個函數而不會產生歧義

    using type = decltype(test<T>(nullptr)); // 通過獲得函數的返回類型來判斷使用了哪個函數
};

template <typename T>
enable_if_t<has_count<T>::type::value> foo(T &t) {
    t.count();
}

template <typename T>
enable_if_t<is_integral_v<T>> foo(T &i) {
    std::cout << "int";
}

int main() {
    counter c; foo(c);
    int i; foo(i);
}

test 利用了 SFINAE,declval<K>() 表示拿到一個編譯期的 K 的實例,這樣我們就可以調用 count 函數,由於我們調用 count 函數是在編譯期(decltype 的計算是在編譯期,所以括號內的值是編譯期計算的)調用的,所以可能產生一個 SFINAE 的編譯錯誤,如果 K 沒有 count 函數,那麼編譯期就會選中第二個 test 函數。那麼我們怎麼知道編譯器選擇了哪一個函數呢?我們可以通過函數返回值得到。首先第二個 test 函數的返回類型就是 false_type,而第一個 test 函數的返回類型就是 true_type。這樣我們通過 decltype(test<T>(blablabla)) 就可以得到 true_type 或者 false_type 從而區分開兩個函數。

注意 test 函數必須要有模板 K 才能啓用 SFINAE,如果 declval<K> 寫成 declval<T> 是不行的,因爲依賴了 test 本身以外的模板參數。

判斷是否存在運算符

我們最開始提到了 sort 函數默認情況下將調用 less 比較器進而調用比較對象的小於運算符,如果小於運算符不存在將會造成大量的編譯錯誤信息。那麼我們如何實現判斷運算符是否存在?或者判斷比較器是否可用?和函數判斷一樣的:

template <typename A, typename B, typename OperT>
struct has_operator {

    template <typename X, typename Y, typename Oper>
    static std::true_type test(decltype(std::declval<Oper>()(std::declval<X>(), std::declval<Y>())) *);

    template <typename X, typename Y, typename Oper>
    static std::false_type test(...);

    using type = decltype(test<A, B, OperT>(nullptr));
    static constexpr bool value = type::value;
};

static_assert(has_operator<int, int, std::less<>>::value, "failed");

我們知道了如何判斷是否存在某種運算符,那麼就可以做很多事情了:判斷一個模板參數類型是不是 callable 的,或者判斷 T 是不是迭代器(支持 ++ 等)。

上面的例子還能被修改成檢查運算符範圍類型的。你可以想想怎麼做。

判斷是否是基類

std::is_base_of<Base, Derived> 可以判斷 Derived 是不是 Base 的子類。如何實現呢?和判斷是否有函數、運算符一樣,我們使用兩個函數來表示。我們可以利用的性質是:DerivedBase 的子類,所以 Derived* 可以傳進 Base* 參數的函數中,那麼事情就變得簡單了:

template <typename Base, typename Derived>
struct is_base_of {

    template <typename X>
    static std::true_type test(Base *);

    template <typename X>
    static std::false_type test(...);

    using type = decltype(test<Base>(std::declval<Derived*>()));
    static constexpr bool value = type::value;
};

Concepts (C++20)

Concepts 真正地將我們從以上晦澀難懂拐彎抹角的代碼(而且編譯器也很累啊)中解救出來,我們看一些例子:

template <typename T>
concept bool EqualityComparable = requires(T a, T b) {
    { a == b } -> bool
};

void f(EqualityComparable);

template <typename T>
void f(T) requires EqualityComparable<T>;

上面幾行代碼就表示 f 需要一個具有相等運算符,而且運算符返回類型爲 boolHasCount 就可以檢查 T 是否有 count 函數。

下面是一些其他的例子:

template <int T> concept Even = T % 2 == 0;
template <int T> concept Odd = T % 2 == 1;
template <Even I> void div();
template <Odd I> void div();


template <typename T>
concept bool HasCount = requires(T a) {
    { a.count() } -> void
};

if constexpr (C++17)

我們之前介紹了 div 函數:

template <typename T>
typename std::enable_if<std::is_integral<T>::value, T>::type div(T t) {
    return t / 2;
}

template <typename T>
typename std::enable_if<std::is_floating_point<T>::value, T>::type div(T t) {
    return std::floor(t / 2);
}

這樣可以實現區分整數除 2 和浮點數除 2。可是 enable_if_t 不管怎麼看都不直觀,所以 C++17 爲我們帶來了 if constexpr

template <typename T>
T div(T t) {
    if constexpr (std::is_integral_v<T>)
        return t / 2;
    else if constexpr (std::is_floating_point_v<T>)
        return std::floor(t / 2);
}

如果前兩個 if 都沒有匹配會直接編譯失敗。所以是不是代碼變得簡單了很多呢?

爲什麼我們需要 if constexpr?因爲這種情況下由編譯器直接計算條件表達式的值從而 if 語句將被直接替換成條件滿足的語句塊從而減小運行開銷,同時也解決了一些編譯的問題:

template <int N, int... Ns>
int sum()
{
    if (sizeof...(Ns) == 0)
        return N;
    else
        return N + sum<Ns...>();
}

這是一個計算模板參數中的數字的和的函數,比如 sum<1, 2, 3>() == 6,但是你會發現上面的函數編譯失敗了,原因是如果 Ns 參數包爲空時,sum<Ns...> 就相當於 sum<>,而我們並沒有 sum<> 這個函數,從而因爲找不到函數而編譯失敗。實際上因爲我們並不能定義 sum<> 這種模板參數爲空的函數,所以並不能通過函數重載的方式實現 sum 函數,所以我們要繞一圈:

template <int... Ns>
int sum()
{
    return [](const std::array<int, sizeof...(Ns)>& a)
    {
        return std::accumulate(a.begin(), a.end(), 0);
    }({Ns...});
}

通過將 Ns 擴展成一個數組從而計算這個數組的和。

但是如果使用 if constexpr 就不一樣了:

template <int N, int... Ns>
int sum()
{
    if constexpr (sizeof...(Ns) == 0)
        return N;
    else
        return N + sum<Ns...>();
}

由於編譯器能在編譯器計算布爾表達式的值,因此當我們調用 sum<N> 時就並不會繼續調用 sum<>,因爲 sizeof...(Ns) == 0,所以編譯器不會嘗試調用 sum<>() 從而避免了上述的問題。

事實上 C++17 還有更簡單的實現方法:

template <typename... Ns>
auto sum(Ns... ns) {
    return (ns + ...);
}
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章