C++类型推断
对于静态语言来说,你一般要明确告诉编译器变量或者表达式的类型。但是庆幸地是,现在C++已经引入了自动类型推断:编译器可以自动推断出类型。在C++11
之前,类型推断只是用在模板上。而C++11
通过引入两个关键字auto
和decltype
扩展了类型推断的应用。C++14
更进一步扩展了auto
和decltype
的应用范围。明显地,类型推断可以减少很多无必要的工作。但是高兴之余,你仍然有可能会犯一些错误,如果你不能深入理解类型推断背后的规则与机理。因此,我们分别从模板类型推断、auto
和decltype
的使用三个方面深入讲解类型推断。
模板类型推断
模板类型推断在C++98
中就已经引入了,它也是理解auto
与decltype
的基石。下面是一个函数模板的通用例子:
template <typename T>
void f(ParamType param);
f(expr); // 对函数进行调用
编译器要根据expr
来推断出T
与ParamType
的类型。特别注意的是,这两个类型有可能并不相同,因为ParamType
可能会包含修饰词,比如const
和&
。看下面的例子:
template <typename T>
void f(const T& param);
int x = 0;
f(x); // 使用int类型调用函数
此时类型推断结果是:T
的类型是int
,但是ParamType
的类型却是const int&
。所以,两个类型并不相同。还有,你可能很自然地认为T
的类型与表达式expr
是一样的,比如上面的例子:两者是一样的。但是实际上这也是误区:T
的类型不仅取决于expr
,也与ParamType
紧紧相关。这存在三种不同的情形:
情形1:ParamType是指针或者引用类型
最简单的情况ParamType
是指针或者引用类型,但不是通用引用类型(&&)。此时,类型推断要点是:
- 如果
expr
是引用类型,那就忽略引用部分; - 通过相减
expr
与ParamType
的类型来决定T
的类型。
比如,下面是引用类型的例子:
template <typename T>
void f(T& param); // param是引用类型
int x = 27; // x是int类型
const int cx = x; // cx是const int类型
const int& rx = x; // rx是const int&类型
f(x); // 此时T为int,而param是int&
f(cx); // 此时T为const int,而param是const int&
f(rx); // 此时T为const int,而param是const int&
其中可以看到,const对象传递给接收T&
参数的函数模板时,const属性是能够被T
所捕获的,即const称为T
的一部分。同时,引用类型对象的引用属性是可以忽略的,并没有被T
所捕获。上面处理的其实是左值引用,对于右值引用,规则是相同的,但是右值引用的通配符T&&
还有另外的含义,会在后面讲。
如果param
是常量引用类型,推断也是相似的,尽管有些区别:
template <typename T>
void f(const T& param); // param是常量引用类型
int x = 27; // x是int类型
const int cx = x; // cx是const int类型
const int& rx = x; // rx是const int&类型
f(x); // 此时T为int,而param是const int&
f(cx); // 此时T为int,而param是const int&
f(rx); // 此时T为int,而param是const int&
指针类型也同样适用:
template <typename T>
void f(T* param); // param是指针类型
int x = 27; // x是int
int* px = &x; // px是int*
const int* cpx = &x; // cpx是const int*
f(px); // 此时T是int,而param是int*
f(cpx); // 此时T是const int,而param是const int*
显然,这种情形类型推断很容易。
情形2:ParamType是通用引用类型(&&)
这种情形有点复杂,因为通用引用类型参数与右值引用参数的形式是一样的,但是它们是有区别的,前者允许左值传入。类型推断的规则如下:
- 如果
expr
是左值,T
和ParamType
都推导为左值引用,尽管其形式上是右值引用(此时仅把&&匹配符,一旦匹配是左值引用,那么&&可以忽略了)。 - 如果
expr
是右值,可以看成情形1的右值引用。
规则有点绕,还是例子说话:
template <typename T>
void f(T&& param); // 此时param是通用引用类型
int x = 10; // x是int
const int cx = x; // cx是const int
const int& rx = x; // rx是const int&
f(x); // 左值,T是int&,param是int&
f(cx); // 左值,T是const int&,param是const int&
f(rx); // 左值,T是const int&,param是const int&
f(10); // 右值,T是int,而param是int&&
所以,只要区分开左值与右值传入,上面的类型推断就清晰多了。
情形3:ParamType不是指针也不是引用类型
如果ParamType
既不是引用类型,也不是指针类型,那就意味着函数的参数是传值了:
template <typename T>
void f(T param); // 此时param是传值方式
传值方式意味着param
是传入对象的一个新副本,相应地,类型推断规则为:
- 如果
expr
类型是引用,那么其引用属性被忽略; - 如果忽略了
expr
的引用特性后,其是const类型,那么也忽略掉。
下面是例子:
int x = 10; // x是int
const int cx = x; // cx是const int
const int& rx = x; // rx是const int&
f(x); // T和param都是int
f(cx); // T和param还是int
f(rx); // T和param仍是int
其实上面的规则不难理解,因为param
是一个新对象,不论其如何改变,都不会影响传入的参数,所以引用属性与const属性都被忽略了。但是有个特殊的情况,当你送入指针变量时,会有些变化:
const char* const ptr = "Hello, world"; // ptr是一个指向常量的常量指针
f(ptr);
尽管还是传值方式,但是复制是指针,当然改变指针本身的值不会影响传入的指针值,所以指针的const属性可以被忽略。但是指针指向常量的属性却不能忽略,因为你可以通过指针的副本解引用,然后就修改了指针所指向的值,原来的指针指向的内容也会跟着变化,但是原来的指针指向的是const对象。矛盾会产生,所以这个属性无法忽略。因此,ptr的类型是const char*
。
尽管前面三种情况已经包含了可能,但是对于特定函数参数,仍然会有特殊情况。第一情况是传入的参数是数组,我们知道如果函数参数是数组,其是当做指针来处理的,所以下面的两个函数声明是等价的:
void fun(int arr[]); // 数组形式
void fun(int* arr); // 指针形式
// 两者是等价的
所以,对于函数模板类型推断来说,数组参数推断的也是指针类型,比如传值方式:
template <typename T>
void f(T param); // 传值方式
const char[] name = "Julie"; // name是char[6]数组
f(name); // 此时T和param是const char*类型
但是如果是引用方式,事情就发生了变化,此时数组不再被当做指针类型,而就是固定长度的数组。所以:
template <typename T>
void f(T& param); // 引用类型
const char[] name = "Julie"; // name是char[6]数组
f(name); // 此时T是const char[6],而param类型是const char (&)[6]
显然与传值方式不同,很难让人理解,但是事实就是如此。但是这也暴漏了一个事实:数组的引用利用函数模板可以推导出数组的大小,下面是一个可以返回数组大小的函数实现:
template <typename T, std::size_t N>
constexpr std::size_t arraySize(T (&)[N]) noexcept
{
// 由于并不实际需要数组,只用到其类型推断,所以不需要参数
return N;
}
int arr[] = {1, 3, 7, 2, 9};
const int size = arraySize(arr); // 5
真实很神奇的一个函数,但是一切又合情合理!
另外一个特殊情况就是传递的参数是函数,其实也是当做指针,和数组参数类似:
template <typename T>
void f1(T param); // 传值方式
template <typename T>
void f2(T& param); // 引用方式
void someFun(int); // 类型为void (int)
f1(someFun); // T和param是 void (*) (int)类型
f2(someFun); // T是void (int)(不是指针类型),但param是void (&) (int)类型
// 尽管如此,实际使用时差别不大,用于回调函数时,一般不会去修改那个函数吧
auto类型推断
C++11
引入了auto
关键字,用于变量定义时的类型自动推断。从表面上看,auto
与模板类型推断的作用对象是不一样的。但是两者实际上是一致的,函数模板推断的任务是:
template <typename T>
void f(ParamType param);
f(expr); // 根据expr类型推导出T和ParamType的类型
编译器要根据expr类型推导出T和ParamType的类型。移植到auto
上是那么容易:把auto
看成函数模板中的T,而把变量的实际类型看成ParamType。这样我们可以把auto
类型推断转换成函数模板类型推断,还是例子说话:
// auto推断例子
auto x = 10;
const auto cx = x;
const auto& rx = x;
// 传化为模板类型推断
template <typename T>
void f1(T param);
f1(10);
template <typename T>
void f2(const T param);
f2(x);
template <typename T>
void f3(const T& param);
f3(x);
显然,很容易推断出各个变量的类型。前面说到,函数模板类型推断有三种情况,那么对于auto
来说,仍然有三种情形:
- 类型修饰符是一个指针或者引用,但是不是通用引用;
- 类型修饰符是一个通用引用;
- 类型修饰符不是指针,也不是引用。
下面是具体例子:
const int N = 2;
auto x = 10; // 情形3: int
const auto cx = x; // 情形3: const int
const auto& rx = x; // 情形1:const int&
auto y = N; // 情形3: int
// 情形2
auto&& y1 = x; // 左值:int&
auto&& y2 = cx; // 左值: const int&
auto&& y3 = 10; // 右值:int&&
可以看到,auto
与函数模板类型推断本质上是一致的。但是有一个特殊情况,那就是C++11
支持统一初始化方式:
// 等价的初始化方式
int x1 = 9;
int x2(9);
// 统一初始化
int x3 = {9};
int x4{9};
上面的4种方式都可以用来初始化一个值为9的int变量,那么你可能会想下面的代码是同样的效果:
auto x1 = 9;
auto x2(9);
auto x3 = {9};
auto x4{9};
但是实际上不是这样:对于前两个,确实是初始化了值为9的int类型变量,但是后两者确是得到了包含元素9的std::initialzer_list<int>
对象(初始化列表),这算是auto
的一个特例吧。但是这对函数模板类型推断并不适用:
auto x = {1, 3, 5} // 合法:std::initializer_list<int>类型
template<typename T>
void f(T param);
f({1, 3, 5}); // 非法,无法编译:不能推断出T的类型
// 可以修改成下面
template <typename T>
void f2(std::initializer_list<T> param);
f2({1, 3, 5}); // 合法:T是int,param是std::initializer_list<int>
上面讲的都是关于auto
用于变量定义时的类型推断。但是C++14
中auto
还可以用于函数返回类型的推断以及泛型lambda
表达式(其参数支持自动推断类型)。如下面的例子:
// C++14功能
// 定义一个判断是否大于10的泛型lambda表达式
auto isGreaterThan10 = [] (auto i) { return i > 10;};
bool r = isisGreaterThan10(20); // false
// auto用于函数返回类型自动推断
auto multiplyBy2Lambda(int x)
{
return [x] {return 2 * x;};
}
auto f = multiplyBy2Lambda(4);
cout << f() << endl; // 8
这些例子是auto
用于模板类型推断,不同于前面的定义变量时的类型推断,不能使用初始化列表来推断:
// 以下都是无法编译的
auto createList()
{
return {1, 3, 5};
}
auto f = [](auto v) {};
f({1, 3, 5});
总之,auto
与模板类型推断是一致的,除了要注意初始化列表这种特殊情况。
decltype关键字
decltype
用于返回某一实体(变量名与表达式)的类型。我们从最简单的例子开始:
const int x = 0; // decltype(x)是const int
struct Point {int x; int y;};
Point p{2, 5};
// decltype(Point::x)是int; decltype(p.x)是int
bool f(int x);
// decltype(f)是bool(int)
// decltype(f(2.0))是bool
vector<int> v{2, 5};
// decltype(v)是vector<int>
// decltype(v[0])是int&
大部分情况,decltype
按照你所预料的方式工作:decltype
用于一个变量名时,返回的正是该变量所对应的类型;用于函数返回值也正是函数返回值类型。但是当用于左值表达式时,decltype
推断出的类型却一定是一个引用类型,看下面的例子:
int x = 10;
// decltype(x)是int,但是decltype((x))确是int&
struct A {double x;};
const A* a = new A{2.0};
// decltype(a->x)是double,但是decltype((a->x))确是const double&
让人感觉非常奇怪。其实广泛的C++表达式(字面值,变量名,表达式等等)包含两个独立的属性:类型(type)和值种类(value category)。这里的类型指的是非引用类型,而值种类有三个基本类型:xvalue
,lvalue
和prvalue
。当decltype
作用于不同值种类的表达式上,其效果不一样。具体可以参考这里(反正有点复杂)。
上面的简单了解就好,因为用的并不是太多。而decltype
的一个很重要的应用是在函数模板中的返回值类型推断。这里举个例子:你想写一个函数,这个函数接收两个参数,一个支持索引操作符的容器对象,一个是索引参数;函数验证用户身份,然后返回值这个容器对象在该索引值处的元素,要求其返回类型与容器对象索引操作返回值类型一样。此时就可以使用decltype
,先看一下下面的实现:
// C++11
template <typename Container, typename Index>
auto authAndAccesss(Container& c, Index i)
->decltype(c[i])
{
// 验证用户
// ...
return c[i];
}
这种实现使用了C++11
中的“拖尾返回类型”:函数返回类型要在参数列表之后声明(使用->分割),使用“拖尾返回类型”,我们可以利用函数的参数来推断返回类型:上面就用了c[i]
来推断返回值类型。还有注意的是上面的auto
没有推断功能,仅仅是指明使用了“拖尾返回类型”。大家可能会想,为什么不把decltype(c[i])
直接替换auto
的位置?这样是不行的,因为此时函数参数还没有被创建!
但是C++14
允许你省略掉拖尾部分:
// C++14
template <typename Container, typename Index>
auto authAndAccesss(Container& c, Index i)
{
// 验证用户
// ...
return c[i];
}
此时仅留下auto
,此时auto
真正用于返回值类型推断:即根据返回值表达式c[i]
来推断返回类型。此时,问题来了。我们知道容器的索引操作返回的大部分是引用类型,但是auto
推导类型时,会忽略c[i]
的引用属性,那么函数返回值是一个右值(尽管我们希望它仍然是左值),下面的代码就存在问题:
vector<int> v{1, 2, 3, 4, 5};
authAndAccess(v, 2) = 10; // 无法编译:无法对右值赋值
我们知道decltype(c[i])
是可以正常推断的,所以,为了解决上面的问题,C++14
引入了decltype(auto)
标识符:auto
说明类型需要推断,decltype
说明类型推断要使用decltype
规则。所以,再次修改代码:
template <typename Container, typename Index>
decltype(auto) authAndAccesss(Container& c, Index i)
{
// 验证用户
// ...
return c[i];
}
此时,如果c[i]
的返回类型是引用类型,那么函数的返回类型也是引用类型。其实decltype(auto)
还可以用于声明变量:
int x = 10;
const int& cx = x;
auto y = cw; // 类型是int
decltype(auto) z = cw; // 类型是const int&
对于修改版本的authAndAccesss,一个问题你只能传递左值引用的容器对象,并且该对象不能是常量左值引用。但是我们想既可以传递左值又可以传递右值,这个时候你需要使用&&
通用引用:
template <typename Container, typename Index>
decltype(auto) authAndAccesss(Container&& c, Index i)
{
// 验证用户
// ...
return std::forward(c)[i];
}
其中std::forward
函数是专门处理通用引用类型参数的,基本上就是传入的参数是右值,转化的还是右值引用,如果是左值,那么转化的是左值引用,具体可以参考这里。
终于完了,本教程算是《Effective Modern C++》第一章的学习笔记,当然加入了自己的理解,有任何问题可以参考原书。