现代C++中的类型推导
C++起源于C语言,因此具有许多与C相同的特性,比如最重要的C++与C都是强类型语言,也就意味着每次在声明一个变量的时候需要明确该变量的类型,比如:int a = 1024;
,C语言由于语法相对简单,这样的声明对于程序员并不会造成太大的负担,但随着C++的发展,尤其是随着标准库中对于模版使用的推广,每次都要求对类型就行明确的指定会对程序员造成极大的负担,比如对于一个map的迭代器,如果不使用类型推导,则可能的形式是这样的
c
std::map<int, std::string> m = {{1, "a"}, {2, "b"}};
std::map<int, std::string>::const_iterator it = m.begin(); // 看起来就好累,而且并不直观
而如果使用类型推导,则上述代码形式可以变为:
c
std::map<int, std::string> m = {{1, "a"}, {2, "b"}};
auto it = m.begin();
可以看出来,对于现代C++中的一些类,使用确定的类型声明即麻烦还不清晰,反而使用auto
关键字声明变量会更清晰也更简单,比如对于上述的这个例子,我们都知道他就是m
的开始的迭代器,但如果进行类型声明,反而一开始并不知道它要表述的是什么意思,要阅读好长好长的代码才知道:噢,这是个迭代器。
而事实上,类型推导其实并不"现代",C++ 之父 Bjarne Stroustrup(B·S ) 早在 C++ 诞生之初就设计并实现了它,但因为与早期 C 语言的语义有冲突,所以一直没有正式作为C++的语言特性,而知道C99消除了兼容性问题,C++11才正式引入类型推导。而随着逐渐进入现代C++的时代之后,类型推导的应用愈加广泛,从简单的auto
到C++的模版编程,再到C++14引入的decltype
,因此对于类型推导进行详细的学习是非常有必要的。
理解模版的类型推导
auto
关键字的出现起源于模版,可以说,模版是auto
的基础,首先我们来回顾一下模版的基本形式:
arduino
// 模版函数的声明
template<typename T>
void f(ParamType param);
// 模版函数的调用
f(expr); // 通过某种表达式调用f
在编译期内,编译器会通过expr进行推导两个类型:一个是T
的类型,一个是ParamType
的类型,需要注意的是这两个类型大多数的时候并不一样 (因为大多数时候ParamType中会有对于T的一些限定,比如const
比如&
。
例如,对于下面这个例子T
和ParamType
就不相同:
arduino
template<typename T>
void f(const T& param);
在这个例子中,param的类型就是const T&
,在调用的过程中,比如使用int x = 0; f(x);
来对这个例子进行调用,那么T
就会被推导为int
而ParamType
则被推导为const int&
,在进行类型推导的时候,模版会根据f(expr)
中的expr
的情况以及**ParamType
的形式**分别推导出T
的类型以及ParamType
的类型。
这里,将它分为三种情况:
ParamType
具有指针或者引用型别,但不是万能引用ParamType
是一个万能引用ParamType
既非指针也非引用
情况一:ParamType
具有指针或者引用型别,但不是万能引用
在这种情况下,模版的类型推导运作机制如下:
- 若expr具有引用型别,先将引用部分忽略
- 然后对expr和ParamType的型别执行模式匹配,最终根据结果决定T的型别
在如下这个例子中,用三种不同的变量输入到f
中,得到的类型推导结果不相同:
arduino
template<typename T>
void f(T& param);
// 给出三个变量
int x = 0; // x是int类型
const int x2 = x; // x2是const int类型
const int& x3 = x; // x3是const int的引用类型
// 输入到f中
f(x); // T被推导为int,ParamType被推导为int&
f(x2); // T被推导为const int,ParamType被推导为const int&
f(x3); // T被推导为const int,ParamType被推导为const int&
通过这个例子,可以得出以下几条结论:
- 在
ParamType
具有指针或者引用的情况下,类型推导的过程先找到对应的expr
的形式,根据expr
的形式推导出来T
的格式,再根据模版中expr
所写的格式推导出paramType
的类型 - 在上述的过程中,有两个地方要注意,一个是向
T&
类型的模版传入const
对象是安全的,const
属性会被推导到T
的类型中;另一个就是传入的变量本身就是引用的话它的引用性会被忽略,T
不会被推导为引用 - 上述例子是左值引用形参,但右值引用形参的推导与左值完全相同,唯一的区别就是传给右值引用形参的变量只能是右值引用实参(不过这个限制其实跟类型推导没有什么关系,这是右值的特点)
情况二:ParamType是个万能引用
对于万能引用,情况表现如下:
- 如果
expr
是左值,则T
和ParamType
会被推导为左值引用,要注意这是在模版类型推导中**T
会被推导为引用的唯一情况**,另外虽然ParamType
的形式是右值引用语法,但最后推导的结果是左值引用; - 如果
expr
是右值,则应用情况一的规则即可
scss
template<typename T>
void f(T&& param); // param现在是万能引用
// 给出三个变量
int x = 0; // x是int类型
const int x2 = x; // x2是const int类型
const int& x3 = x; // x3是const int的引用类型
// 输入到f中
f(x); // x是左值,T被推导为int&,ParamType被推导为int&
f(x2); // x2是左值,T被推导为const int&,ParamType被推导为const int&
f(x3); // x3是左值引用,T被推导为const int&,ParamType被推导为const int&
f(0); // 0是个右值,T被推导为int&&,ParamType被推导为int&&
情况三:ParamType按值传递
当ParamType
既不是指针也不是引用的时候,那就是按照值传递,按照值传递就意味着无论怎么传递最终传入的都是一个副本,最终推导规则如下:
- 如果
expr
是引用,则忽略引用的部分 - 忽略引用性质之后,如果
expr
是const
则也忽略const
,另外如果是volatile
对象,也一同忽略volatile
性质;这就导致一个很重要的特点:不能因为expr
是一个常量则认为param
也是一个常量,常量的副本是可以修改的 - 对于指针,考虑到常量指针与指针常量的特殊性,如果
param
的类型是一个指针,则类型推导的时候会忽略param
指针本身的常量性而保留指针指向对象的常量性,换句话说常量指针会被正常推导,指针常量则会被推导为普通指针
数组实参
对于数组实参,情况略有不同,在C/C++中数组作为参数传递会有点特殊,因为数组会退化(数组会变成首元素的指针然后再作为参数进行传递),看一下下面这个例子:
rust
const char str[] = "hello, world"; // str的类型是const char[13]
const char* strToPtr = str; // 数组退化成指针
事实上,str和strToPtr这两个变量的类型并不统一,但能编译成功的原因就在于数组退化成了指针然后指针可以传递。而在模版推导中,不能把他俩完全的等价,这里有一个很重要的特性:尽管函数无法声明真正的数组类型的形参,但却能够将形参声明为数组的引用,比如对于下面这个例子:
arduino
template<typename T>
void f(T& param);
f(str); // 向f传递一个数组
这种情况下,T
会被推导为const char [13]
,而ParamType
则会被推导为const char (&)[13]
,这里就可以利用这个模版特性去做一些魔法🪄,比如可以在编译器确定数组的长度。
arduino
template<typename T, std::size_t N>
constexpr std::size_t arraySize(T (&)[N]) noexcept {
return N;
}
通过这种设计,就可以在编译器使用一个数组的大小来设定另一个数组的大小,比如:
ini
int nums[] = {1,2,3,4,5};
int nums2[arraySize(nums)]; // nums2的大小会与nums相同
函数实参
对于函数而言,函数传递给一个形参,函数也会退化为指针,函数的模版推导结果可以参考下面这个例子:
arduino
template<typename T>
void f1(T param);
template<typename T>
void f2(T& param);
void func(int, int); // func是一个函数,类型为void (int, int)
f1(func); // func退化为指针,T和ParamType类型均推导为void (*)(int, int)
f2(func); // func是函数引用,T的类型为void (int, int),而ParamType的类型则为void (&)(int, int)
模版推导总结:
- 在模版类型推导的过程中,具有引用类型的实参会被当作非引用类型来处理(引用的忽略性)
- 对万能引用形参进行推导时,左值实参会特殊处理
- 对按值传递的形参进行推导时,const和volatile会被忽略
- 在模版类型推导过程中,函数或数组的实参会退化为对应的指针,除非它们被用来初始化引用
理解auto的类型推导
auto
是现代C++中最常用的特性之一,它就像是一个占位符,写上它,就可以让编译器去自动填上它应具有的类型名称,举几个很简单的例子:
ini
auto i = 1024; // 自动推导为int类型
auto x = 1024.0; // 自动推导为double类型
auto str = "hello, world"; // 自动推导为const char*类型
auto f = bind1st(std::less<int>(), 2); // 自动推导出类型,具体是啥不知道,反正很复杂不好写
当然auto
也不是万能的,它也会有无法推导的情况存在:
c
std::map<int, std::string> mp = {{1,"a"}, {2,"b"}}; // 自动推导不出来
// auto mp = {{1,"a"}, {2,"b"}};
// 报错:Cannot deduce actual type for variable 'mp' with type 'auto' from initializer list
auto iter = mp.begin(); // 自动推导为map内部的迭代器类型
当存在一定的歧义的时候,编译器就会报错,避免进行错误的推导,就比如上面这个例子,如果不加以限制,那么把mp
的类型推导为std::vector<std::pair<int, std::string>>>
也是毫无问题的,但如果这样子推导了后面所有的代码都会以程序员意料之外的方式进行,这就会导致很多莫名其妙的问题,因此,通过有歧义直接不推导的方式也规避了许多的风险,但这并不意味着就可以随意去用auto
了,作为优秀的C++程序员仍然非常有必要了解一下auto
类型推导的规则。
auto
类型推导规则
从一定程度上来说,理解了最前面所讲的模版类型推导中的情况一就基本上覆盖了auto
类型推导99%的规则了,因为除了一种情况以外,auto
的推导规则与情况一的推导规则完全相同。接下来我们就来看看auto
与情况一之间的联系以及它在特殊的情况下的推导规则。
常规情况
在模版推导中,我们使用了例子:
arduino
template<typename T>
void f(ParamType param);
// 调用
f(expr); // 以某表达式来调用模版函数,进而推测出T和ParamType的类型
而当某变量使用auto
进行推导时,auto
就会扮演模版中T的这个角色,而变量的类型修饰词(特定修饰+auto
)则扮演了ParamType
的角色,比如在下面这个例子中:
vbnet
auto x = 1024; // x的类型修饰词就是auto本身
const auto x2 = x; // x的类型修饰词就是const auto
const auto& x3 = x2; // x的类型修饰词就是const auto&
而在推导上述的变量类型时,编译器就会像是进行了一次模版编程一样,会将auto
与类型修饰词对应到模版中进行相同规则的类型推导,就像如下展示的代码一样:
arduino
// 注意:⚠️仅仅是像这里展示的一样,并不是真的进行了这种编译
template<typename T>
void func_for_x(T param);
func_for_x(1024); // 推导出来x的类型是int
template<typename T>
void func_for_x2(const T param);
func_for_x2(x); // 推导出来x2的类型是const int
template<typename T>
void func_for_x3(const T& param);
func_for_x3(x2); // 推导出来x3的类型是const int&
按照情况一中的推导规则很容易得出结果,这个结果跟我们感性预想的也是相同的,同时也可以将auto
推导与模版推导中对ParamType
分类类似地进行一下分类:
- 类型修饰词是指针或者引用,但不是万能引用
- 类型修饰词是万能引用
- 类型修饰词即非指针也非引用
【1】和【3】在之前例子有见过:
ini
auto x = 1024; // 3.非指针也非引用
const auto x2 = x; // 3.非指针也非引用
const auto& x3 = x2; // 1.指针或引用但非万能引用
对于【2】,其运作方式也符合感性认知:
ini
auto&& uref1 = x; // x是int且是左值,uref1类型为int&
auto&& uref2 = x2; // x2是const int,也是左值,因此uref2类型为const int&
auto&& uref3 = x3; // x3是const int&,也是左值,因此uref3类型为const int&
auto&& uref4 = 1024; // 1024是int且是右值,因此uref4的类型为int&&
对于C风格的数组会退化成指针这种情况,auto
也完全符合模版推导中的规则:
arduino
const char str[] = "Hello, world"; // str的类型是const char[13]
auto arr1 = name; // arr1的类型是const char*
auto& arr2 = name; // arr2的类型是const char (&)[13]
void somFunc(int, double); // someFunc是函数,类型为void(int, double)
auto func1 = someFunc; // func1的类型为void (*)(int, double)
auto& func2 = someFunc; // func2的类型为void (&)(int, double)
特殊情况
接下来看一下auto
可能会导致出错的一种特殊情况,在传统的C++98中,如果我们要声明一个int
并进行初始化,我们有如下两种方法:
arduino
int x = 27; // 最标准的做法,C风格
int x2(27); // C++风格的初始化
而在现代C++中,从C++11开始,为了支持统一初始化,C++增加了下面的语法选项:
ini
int x3 = {27};
int x4{27};
截止目前为止,可以使用四种方式对一个变量进行初始化,而如果我们把int
替换成auto
,那么结果却会与想像中不一样
scss
auto x = 27; // 没问题,x是int类型
auto x2(27); // 没问题,x是int类型
auto x3 = {27}; // 出现问题,x3不是int类型
auto x4{27}; // 出现问题,x4不是int类型
使用C++98的语法进行auto
替换不会存在问题,但如果使用现代C++中新增的方法则推导出来的结果会是std::initializer_list<int>
,并且这个列表中有一个元素,值为27
这是
auto
类型推导中比较特殊的一种情况,当使用大括号括起来时,推导所属类型就会变成std::initializer_list
在这种情况下,就会对列表中的元素类型要求统一,因为std::initializer_list<T>
其实也是一个模版,因此如果大括号中的元素类型不一致,就无法推导出T
的类型。
同时需要单独记忆的一点就是,在类型推导中std::initializer_list
这个类型只会被auto
推导,模版无法推导:
arduino
auto x = {1, 2, 3}; // 推导没有问题,x类型是std::initializer_list<int>
template<typename T>
void func(T param);
f({1, 2, 3}); // 推导失败,无法推导出T的类型
void func2(std::vector<int> test);
func2({1, 2, 3}); // 可行,会通过std::initializer_list<int>初始化一个std::vector
template<typename T>
void func3(std::initializer_list<T> list);
func3({1, 2, 3}); // 可行,确定输入的类型是std::initializer_list<T>,推导T的类型是没有问题的
一些全新特性
C++11的特性就全部结束了,但在C++14中,C++委员会又赋予了auto
更多的能力
-
C++14允许使用
auto
来说明函数返回值需要推导:arduino// 可以推导返回的类型,推导结果为int auto func() { return 0; } // 推导返回的类型失败,这里auto不会执行它的特殊情况,而是与模版推导的结果相同,需要单独记忆比较烦 auto func2() { return {1, 2, 3}; }
-
C++14中
lambda
表达式也允许形参中用到auto
,同时执行推导的规则也是按照模版推导的规则来的:arduinostd::vector<int> v; /* ... */ auto lambda1 = [&v](const auto& value) {v.push_back(value);}; // 没有问题 // lambda1的类型推导为void (const int&) const // value的类型推导为const int& /* ... */ auto lambda2 = [&v](const auto& value) {v = value;}; // 重置v的函数 lambda2({1, 2, 3}); // 类型推导失败,无法推导出{1, 2, 3}的类型
以上就是auto
的全部内容啦,然后最后我们来看一下C++的新特性:decltype
理解delctype
的类型推导
delctype
是用来获得某个变量其类型的方法,但对于这个方法使用的时候要慎重,虽然大多数时候都可以获得正确的结果,但有时候获得的结果也会有点让人迷惑不太正确,下面我们就来具体看看delctype
的运行规则。
delctype
的一般情况
和模版以及auto
的类型推导过程相反,delctype
不太可以归属到类型推导中去,它更像是鹦鹉学舌,返回给定的名字或表达式的确切类型,这里直接使用《Effective Modern C++》中的例子:
arduino
const int i = 0; // decltype(i) 是const int
bool f(const Widget& w); // decltype(w) 是 const Widget&
// decltype(f) 是 bool(const Widget&)
struct Point {
int x, y; // decltype(Point::x) 是 int
}; // decltype(Point::y) 是 int
Widget w; // decltype(w) 是 Widget
if (f(w)) ... // decltype(f(w)) 是 bool
template<typename T> class vector { // std::vector的简化版
public:
...
T& operator[](std::size_t index);
...
};
vector<int> v; // decltype(v) 是 vector<int>
...
if (v[0] == 0) // decltype(v[0]) 是 int&
...
C++11中,
decltype
的主要用途大概就在于声明那些返回值类型依赖于形参类型的函数模版
举个例子,同时也补充一个auto
的用法,这个auto
与类型推导就没有关系,只是为了符合返回值类型尾序语法规则 ,这种语法使得返回值在形参列表之后,它的好处就在于可以配合decltype
来使用模版推导中形参的类型结果作为返回类型:
arduino
template<typename Container, typename Index>
auto process(Container& c, Index i) -> decltype(c[i]) {
/* 处理逻辑 */
return c[i];
}
而到C++14中后,这个语法变得更加简单了,可以去掉尾序的部分:
arduino
template<typename Container, typename Index>
// 错误版本:因为模版推导中,初始化表达的引用性会被忽略,返回的类型会是int而不是我们期待的int&
auto process(Container& c, Index i) {
/* 处理逻辑 */
return c[i];
}
// C++14中使用decltype(auto)解决了这个问题,本质上就是auto指定了想要实施自动推导返回类型,然后decltype表明使用的规则是decltype的推导规则
// 修订版本⬇️
template<typename Container, typename Index>
decltype(auto) process(Container& c, Index i) {
/* 处理逻辑 */
return c[i]; // 返回类型是int&
}
// decltype(auto)这种方式也可以在普通语句中使用
int t = 1;
const int& t2 = t;
auto t3 = t2; // t3是auto类型推导,是int类型
decltype(auto) t4 = t2; // t4是decltype类型推导,是const int&类型
decltype
的特殊情况
主要考虑一种特殊情况,首先再回忆一下decltype
的工作方式,在decltype
后面的括号中填入变量名称,然后会根据这个变量的定义返回一个类型,但这个变量的名称其实是有一定可能会出现一些问题的。
主要是因为,decltype(x)
中的x
是一个变量名称,而x
和(x)
在我们的感性认知中应该是相同,同时在C++中(x)
也被定义为一个左值,因此也就支持decltype((x))
这种写法,这就会导致出错,因为x
和(x)
其实是不同的:
scss
int x = 1024;
decltype(x); // 返回结果是int,符合预期
decltype((x)); // 推导的是(x)的类型,最终推导结果是int&,不符合预期,一定要规避这个问题
包括在使用decltype(auto)
时,也一定要注意这个问题
arduino
decltype(auto) f1() {
int x = 1024;
/* ... */
return x;
}
decltype(auto) f2() {
int x = 1024;
/* ... */
return (x); // !!!非常危险⚠️,最终返回的是一个局部变量的引用,当去使用的时候会造成UB
}