SFINAE

SFINAE(Substitution Failure IS Not An Error)技术是模板引入的一种特性,替代失败不是错误,这种特性允许了模板中尽管发生不匹配也不会导致编译直接失败,而是由编译器继续去匹配其他类型,这是泛型编程得以实现基础;再者,它允许泛型更灵活地控制和管理各种数据类型行为,为萃取、特化做了铺垫。

任意的模板都体现了SFINAE,例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include <iostream>
using namespace std;

struct Test{
typedef int foo;
};

template<typename T> //#1
void func(typename T::foo){
cout<<"void f(T::foo)"<< endl;
}

template<typename T> //#2
void func(T){
cout<<"void func(T)"<<endl;
}

int main(){
func<Test>(10);
func<int>(10);

return 0;
}
此处显然的,Test使用的是函数#1,int调用的是函数#2,然而对于编译器是无法直接匹配到对应的函数签名,因此有一个过程必须发生:func<int>(10)匹配void func(typename T::foo)时发生failure,这种failure不是error,因此编译器得以继续执行;

SFINAE的核心概念就是如此简洁,然而其相关的特性却让代码显得巧妙,再更深入记录一些我力所能及的、能检索到的相关内容前,我们需要先看另一个C++ template中听上去讳莫如深的名词——Type Traits,即类型萃取

C++ Type Traits

在我们谈论模板、特化、SFINAE,很难绕开Traits这个东西,模板定义了泛型,但是总不能每个类型都总能通过一个泛型解决吧?你可能需要单独针对某种类型定义函数行为,就像你会定义虚函数来定义子类行为(但总有东西使用特化会比继承、重载更加炫技方便,那么如何判断传入类型是不是目标类型呢,traits的一部分工作就完成了这个目标,例如它可以进行类型的比较、判断是否指针类型、引用类型、整数类型、浮点数类型等,再者,它还可以去除某个变量的指针特性、引用特性,等等,Traits更多用法定义在<type_traits>头文件中,以下通过代码一一体会常见用法;

类型判别

is_same<T1,T2>::value会返回比较结果:

1
2
3
4
5
6
7
template<typename T>
void func(T input){
if(is_same<T,bool>::value)
cout<<"This is bool"<<endl;
if(is_same<T,int>::value)
cout<<"This is int"<<endl;
}
std::is_same只是一个配角,通常它会和SFINAE的主角std::enable_if一起使用:
1
2
3
4
5
6
7
8
9
10
template <typename T>
typename std::enable_if<std::is_same<int,T>::value, void>::type func1(T input){
//.......
}

//or 通过C++14以上:使用std::enable_if_t
template <typename T>
std::enable_if_t<std::is_same<int,T>::value, void> func1(T input){
//.......
}
enable_if<bool,T>::type的逻辑很简单,只要第一个参数成立,则类型为第二个参数;即仅当T是类型int时,返回类型为void,假如T不是int,则无返回类型,即该函数不会被编译,规避了未定义数据类型执行函数带来的未定义行为;

此事在Qt亦有用处,例如,Qt存在一种强大数据类型,可以装载任何内置/自定义的数据类型的结构QVariant,当然并非无条件,循规蹈矩是为了让元对象系统认识自定义的数据类型,而你可能没有进行规范声明,导致无效数据传输,因此你的泛型应该这样来规避,以确保你传入的类型满足QMetaTypeId2定义才装载到QVariant中;

1
2
3
4
5
template <typename T>
typename std::enable_if<QMetaTypeId2<T>::Defined, void>::type func(const T& t){
QVariant var = QVariant::from(t);
...
}

整数型/浮点型/指针/引用

因为有了std::is_samestd::is_void等基本数据类型萃取就不再讨论了,C++ Traits还可以萃取出以上几种常用类别:

1
2
3
4
5
6
7
8
9
10
11
template<typename T>
void func(T input){
if(std::is_integral<T>::value) //整数型,bool/char/uchar/long/int等
cout<<"std::is_integral<T>"<<endl;
else if(std::is_floating_point<T>::value) //浮点型
cout<<"std::is_floating_point"<<endl;
else if(std::is_pointer<T>::value) //指针
cout<<"std::is_pointer"<<endl;
else if(std::is_reference<T>::value) //引用
cout<< "std::is_reference" <<endl;
}

左值引用推导规则

这里的误区也挺多,例如另外一种算术类型std::is_arithmetic<T>::value仅支持整数型浮点型(char/bool/int等),然后你放心地不去对指针和引用等进行筛选,于是:

1
2
3
4
5
template<typename T>
void func(T input){
if(std::is_arithmetic<T>::value)
cout<<"std::is_arithmetic<T>"<<endl;
}
当类似这样调用:
1
2
3
int a = 5;
int& b = a;
func(b);
你发现本应该是引用类型的b触发了输出,相似的情况发生在使用std::is_reference时,你写下:
1
2
3
4
5
template<typename T>
void func(T input){
if(std::is_reference<T>::value)
cout<<"std::is_reference<T>" <<endl;
}
此时尽管你传入引用类型的b,也不会触发输出;真见鬼了!,应该输出没有输出、不应该输出却输出了。

其实这里真的不好解释,先理解另一种情况当写成void func(T& input)时,会否输出"std::is_reference<T>"呢?

答案是不会因为T&正好匹配int&,T就是int,不是引用,因为引用折叠规则的存在,这种逻辑不能应用到T接受引用参数这种情况上面;上一次提引用折叠还是在万能引用,并没有深入了解,引用折叠的理解很简单,对于形参/实参的左右值引用来说,只要形参和实参其中一者为左值引用,那么最后的推导就是左值引用,只要均为右值引用时,推导才可能是右值引用;而针对形参/实参的左值/左值引用来说,只有形参参数本身也是引用时,才有价值讨论其引用推导情况(形参本身都是值传递,传入引用意义是什么呢?),以代码呈现最直观:

1
2
3
4
5
6
int a = 5;
int& b = a;
void func(T input); func(a); //非引用
void func(T input); func(b); //b虽然是引用,相当于无引用
void func(T& input); func(a);
void func(T& input); func(b);
这四种情况,后两种的T都将被推导成T&;这里可能有人要骂自我矛盾了,既然T都被推导了T&,为什么std::is_reference<T>还是返回false,所以我们的推断是这个引用属性是属于变量的!这个解释能够适配上述现象,引入了decltype后,解释引用属性是归属泛型还是变量也变得容易验证:
1
2
3
4
5
6
7
template<typename T>
void func(T& input){
if(std::is_reference<decltype(input)>::value) //变量
cout<<"is_reference<decltype(input)" <<endl;
if(std::is_reference<T>::value) //泛型
cout<<"std::is_reference<T>"<<endl;
}
对于四种情况的前两种,两个条件都不成立,对于后两种,第一条件成立而第二条件仍不成立;所以一个重要结论当形参是左值引用时,T只可能被推导成非引用(无论实参是左值/左值引用/右值引用);

值得一提的是我没有从任何地方看到类似的标准,因此不敢断言这一定正确,如果此处误解欢迎指正。

万能引用的推导规则

再引入万能引用情况,以下代码:

1
2
3
4
5
6
7
template<typename T>
void func(T&& input){
if(std::is_reference<decltype(input)>::value)
cout<<"is_reference<decltype(input)" <<endl;
if(std::is_reference<T>::value)
cout<<"std::is_reference<T>"<<endl;
}
注意万能引用时,仍然使用引用折叠规则进行判断,但是此时的T是有可能被推断成引用的,具体而言:当传入实参为左值/左值引用/右值引用等左值类型时,T就是引用类型变量本身属性也是引用类型:所以这些调用都是满足两个条件,同时输出is_reference<decltype(input)std::is_reference<T>
1
2
3
4
5
6
int a = 5;
int& b = a;
int&& c = 10;
func(a);
func(b);
func(c);
传入实参是右值时:T&&的T被推导成int右值类型&&划归到变量,因此仅输出is_reference<decltype(input):
1
2
3
4
func(5);
func(std::move(a));
func(std::move(b));
func(std::move(c));
再验证一下上述结论,这次区分输出左值和右值,以下:
1
2
3
4
5
6
7
8
9
10
11
template<typename T>
void func(T&& num){
if(std::is_lvalue_reference<T>::value)
cout<<"is_lvalue_reference<T>"<<endl;
if(std::is_rvalue_reference<T>::value)
cout<<"is_rvalue_reference<T>"<<endl;
if(std::is_lvalue_reference<decltype(num)>::value)
cout<<"decltype is_lvalue_reference"<<endl;
if(std::is_rvalue_reference<decltype(num)>::value)
cout<<"decltype is_rvalue_reference"<<endl;
}
结论是相同的:当传入实参左值/左值引用/右值引用,输出is_lvalue_reference<T>decltype is_lvalue_reference,当传入右值时仅输出decltype is_rvalue_reference

完美转发T的推导特性

结论已经全部验证完了,如果你还能看懂,看最后一个游戏,我想表达关于完美转发forward的推导特性

1
2
3
4
5
6
7
8
9
10
11
template<typename T>
void funcc(T& num){
if(std::is_lvalue_reference<T>::value)
cout<<"is_lvalue_reference<T>"<<endl;
if(std::is_rvalue_reference<T>::value)
cout<<"is_rvalue_reference<T>"<<endl;
if(std::is_lvalue_reference<decltype(forward<T>(num))>::value)
cout<<"decltype is_lvalue_reference"<<endl;
if(std::is_rvalue_reference<decltype(forward<T>(num))>::value)
cout<<"decltype is_rvalue_reference"<<endl;
}
这里的T&是左值引用版本,因此只需要接受左值实参这里就没有右值情况了。根据第一个结论,T只可能被推导出非引用类型,换言之forward<T>将被推导出forward<int>,记住:当T是非引用类型时forward会返回一个右值引用类型,因此num是一个右值引用,所以以下输出均为decltype is_rvalue_reference:
1
2
3
4
5
6
7
int a = 5;
int& b = a;
int&& c = 10;

funcc(a);
funcc(b);
funcc(c);
我想对于任何了解左右值基本定义、而没有去深究的都会对这个输出感到些许诧异,因为我们使用均为左值类别的形参和实参,得到了右值版本的输出!这个特性来自forward而并非引用折叠,如果没有forward存在,T被推导成非引用普通类型,变量成为左值引用而并非右值引用,输出的是decltype is_lvalue_reference;

最后一点冗余讨论:当在万能引用版本上面使用forward,加深对第二个结论的记忆,即当传入左值时,T会被推导成左值引用,变量也被推导出左值引用,所以输出is_lvalue_referencedecltype is_lvalue_reference

当传入右值时,T推导的是非引用普通类型,变量是右值引用类型,因为普通类型T返回一个右值引用,所以仅输出decltype is_rvalue_reference

1
2
3
4
5
6
7
8
9
10
11
template<typename T>
void func(T&& num){
if(std::is_lvalue_reference<T>::value)
cout<<"is_lvalue_reference<T>"<<endl;
if(std::is_rvalue_reference<T>::value)
cout<<"is_rvalue_reference<T>"<<endl;
if(std::is_lvalue_reference<decltype(num)>::value)
cout<<"decltype is_lvalue_reference"<<endl;
if(std::is_rvalue_reference<decltype(num)>::value)
cout<<"decltype is_rvalue_reference"<<endl;
}

数组

C++语义中,数组特指的是内建数组,最直观的方法是对象定义出现方括号,最常见的是裸数组stl数组(std::vector<int> vp[2]等),STL如vector、deque等一些行为很像数组,但它们本身是一种类对象,因此它们不能被划归到数组

数组作为参数传递,最大的易错点在于值传递导致数组退化成指针传递,即你以为传入的是一个数组对象,实际上只是数组首元素地址指针:

1
2
3
4
5
6
7
8
9
10
11
12
13
template<typename T>
void func(T input){
if(std::is_array<T>::value)
cout<<"is_array<T>"<<endl;
if(std::is_pointer<T>::value)
cout<<"is_pointer<T>"<<endl;
}

//调用:
int vp1[2] = {1,2};
vector<int> vp2[2] = {{1,2},{2,3}}; //注意这是STL数组而非vector本身
func(vp1);
func(vp2);
均输出is_pointer<T>,如果需要准确判断,需要使用void func(T& input)保留数组本身特性,此时才会输出is_array<T>

当如果仅需对数组参数进行特化处理,也可以不直接使用萃取std::is_array,可以直接将参数特化成数组

1
2
3
4
5
6
7
template<typename T,size_t N>
void func(T (&input)[N]){
if(std::is_same<T,int>::value)
cout<<"std::is_same<T,int>"<<endl;
if(std::is_same<T,vector<int>>::value)
cout<<"std::is_same<T,vector<int>>"<<endl;
}

还有其他判断是否const、虚函数、函数指针、成员函数指针、有符号、无符号、类、Union、抽象类、是否平凡可复制(该定义参考本站C++11新特性)等,不再一一验证。

类型转换

const/指针/引用

Traits能够添加/移除某些C++类型的属性,例如其const、指针、引用属性等:

1
2
3
4
5
6
7
std::remove_const<T>
std::add_const<T>
std::remove_reference<T>
std::add_lvalue_reference<T>
std::add_rvalue_reference<T>
std::remove_pointer<T>
std::add_pointer<T>
去除这些特性使得类型判别更加方便,能够节省掉大量if(is_pointer...)等判别:
1
2
3
4
5
template<typename T>
void func(T&& value){
if(std::is_same<typename std::remove_reference<T>::type,int>::value)
cout<<"std::is_same<std::remove_reference<T>::type,int"<<endl;
}
由上文,万能引用的所有左值都会推导出左值引用T&,这个特性恰好帮我们去掉。

引用不存在叠加问题,因为不存在引用的引用,注意,这里不是说一个引用不存在另一个引用:

1
2
int& b = a;
int& c = b;
这样是ok的,这样并不会产生int&& c,可能你认为因为右值引用占了这个坑,但是引用折叠告诉我们int&&&int&&&&都是错误的;来到指针情况就不一样了,多重指针是可能存在的,而remove_pointer只能移除一层指针,例如将int**变成int*

common_type

std::common_type可以返回列表(可以多于2个)的公共类型,所谓公共类型,是指可以隐式转换到的类型,例如int可以隐式转换到double、char可以隐式转换到int,那么typename std::common_type<int,double>::type就是指double;

所以当引入第三个泛型、自动推导以外,可以考虑common_type:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <iostream>
#include <type_traits>
#include <typeinfo>

using namespace std;

template<typename T1, typename T2>
typename std::common_type<T1,T2>::type func(T1 a, T2 b){
return a+b;
}

int main(){
const char*name = typeid(func(1,2)).name();
cout<<name<<endl; //i
name = typeid(func(1,2.22)).name();
cout<<name<<endl; //d
return 0;
}

std::decltype与std::declval

std::declval的作用是返回一个临时对象(右值引用),特殊的是这个构造不会真正调用构造函数,所以它并不能返回对象的值特性,只能返回类型特性,因此常常和类型推导std::decltype一起使用;这对现代C++是可能的,因为当一个类或者函数定义,编译期C++就能够通过编译和语义分析获知这个类、函数的成员对应类型或者返回类型(它们是不随对象改变的),因此无需真正构造这个对象。

因此,你可以写这样的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <iostream>
#include <typeinfo>
using namespace std;

struct Test {
Test(int) {}
double foo() const { return 3.14; }
};

int main(){
//const char* name = typeid(decltype(Test().foo())).name(); ///错误!
const char* name = typeid(decltype(declval<Test>().foo())).name();
cout << name<<endl;

return 0;
}
Test不存在默认构造函数,因此不能空参数构造临时对象,std::declval可以代替这样的工作;

未完待续...