天天看點

C++ 模闆簡介(一)—— SFINAESFINAE, 類型檢查, Concepts

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>

模闆庫中的函數。

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

我們希望這個函數的參數是整型(

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
    }
};
           

然後你就可以這麼幹:

這樣如果我們調用了

foo(counter())

,那麼

i.count()

将會調用

counter::count

。同時我們可以確定傳進來的變量

i

确實有

count()

這個函數。

但是!如果

foo

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

這樣完全可以,我們確定了

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

的子類。如何實作呢?和判斷是否有函數、運算符一樣,我們使用兩個函數來表示。我們可以利用的性質是:

Derived

Base

的子類,是以

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

需要一個具有相等運算符,而且運算符傳回類型為

bool

HasCount

就可以檢查

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 + ...);
}
           

繼續閱讀