类型推导
C++98 只有一套类型推导规则,即针对函数 template
的规则。
C++11 对该规则进行了微调,并新增了两套规则:一套针对
auto,另一套针对 decltype。
随后,C++14 扩展了 auto 和 decltype
的适用场景。
类型推导的普及,不但将我们从繁复的类型拼写中解放出来,也增强了 C++ 软件的适应性,因为只需在源码某处修改类型,该变更就能通过类型推导自动传播到其他相关位置。
但是,类型推导也会使代码的逻辑判断更加困难,并且有些时候编译器推导出的类型和你认为的类型并不相同。
如果不理解 C++ 编译器类型推导的机理,就无法高效地进行现代 C++
编程,因为现代 C++ 涉及类型推导的地方太多了:函数 template
的调用,auto 的使用以及 decltype
的使用等。
接下来我们会介绍 template、auto 以及
decltype
类型推导的原理,以及一些常见的类型推导”失效”的原因。
template 类型推导
template 类型推导可以说是 C++
历史上最成功的设计之一,无数程序员即使不清楚其原理,也可以利用它写出正确合理的代码。
同时,template 类型推导也是 C++11 引入的
auto 的基石。
但是 template
类型推导的结果有些时候和我们的直觉并不相同,因此我们需要理解
template 类型推导的原理。
一个函数模板的代码大概是这样的:
1 | |
调用代码:
1 | |
在编译过程中,编译器会利用 expr 推导 T 和
ParamType 的类型。这两种类型通常不同,因为
ParamType 通常包含修饰符,例如 const
或引用限定符。例如:
1 | |
调用代码:
1 | |
此时 T 的推导结果是 int,但是
ParamType 的推导结果是 const int&。
在上面的例子中,T 的类型取决于 x
的类型,但是事实上会更复杂一些。
T 的类型不只与 expr 相关,也取决于
ParamType,总体来说分为三种情况:
ParamType是一个指针或者引用类型,但不是万能引用(universal reference,C++17 起官方定名为 forwarding reference,是一种特殊的引用类型)ParamType是万能引用ParamType既不是指针也不是引用类型
情况
1:ParamType 是指针或者引用类型,但不是万能引用
这种情况下,类型推导遵从两条规则:
如果
expr是引用类型,忽略其类型的引用部分然后将
expr的类型与ParamType进行模式匹配,进而确定T的类型
例如:
1 | |
声明变量:
1 | |
此时调用的推导结果如下:
1 | |
在第一次调用中,x 是普通的 int
类型,T 与 ParamType 的推导结果显而易见。
在第二次和第三次调用中,由于 cx 和 rx
指定的是 const 值,T 被推导为
const int,从而使参数类型变为
const int&。
在第三次调用中,虽然 rx
是引用类型,但是推导结果没有引用,这与规定所述一致。
当然,如果是右值引用参数,推导过程完全相同,只是需要用右值引用参数来接收右值引用实参。
如果我们把 f 的参数类型从 T& 改为
const T&,此时的推导结果并没有什么特殊变化,只不过对于
cx 和 rx,由于此时 param
已被声明为”对 const 的引用”,因此不再需要将
const 属性推导为 T 的一部分:
1 | |
如果 ParamType
是指针而不是引用类型,推导方式也是一样的:
1 | |
情况 2:ParamType
是万能引用
万能引用(universal reference),在 C++17 及后续标准中官方称为 forwarding reference(转发引用),是 C++11 引入的一项关键特性,主要用于实现高效且保持实参属性的完美转发(perfect forwarding)。
万能引用看起来和普通右值引用(T&&)一模一样,但它既能绑定左值,也能绑定右值:
- 如果传入的是左值,万能引用就会折叠并绑定为左值引用(
T&)。 - 如果传入的是右值,万能引用就会绑定为右值引用(
T&&)。
并不是所有写成 T&&
的地方都是万能引用,它必须同时满足以下两个条件:
- 必须存在类型推导:
T的类型必须是在调用时由编译器动态推导出来的。 - 形式必须精确为
T&&:不能带有const或其他修饰符(例如const T&&就只是普通的右值引用)。
这种工作机制基于 C++11 的引用折叠(reference collapsing)规则,当
ParamType 是万能引用时,推导结果也会遵循引用折叠规则。
这种情况下,类型推导也遵循两条规则:
- 如果
expr是左值,则T和ParamType都会被推导为左值引用 - 如果
expr是右值,则遵循情况 1 的推导规则
例如:
1 | |
引用折叠发生了作用:
1 | |
可见,当 ParamType
为万能引用时,对于左值与右值的推导规则有着显著差异,并且是唯一会导致
T 的推导类型为引用的情况。
情况
3:ParamType 既不是指针也不是引用
当 ParamType
既不是指针也不是引用时,我们处理的是按值传递:
1 | |
这意味着,无论调用函数时传入的参数类型是什么,param
都会发生拷贝,成为一个新的对象。这也引出了该种情况下的类型推导规则:
- 如果
expr是引用类型,忽略其类型的引用部分 - 如果忽略
expr的引用性后,expr仍然是const,也忽略。如果它是volatile的,也忽略
例如:
1 | |
可见,虽然 cx 和 rx 代表 const
常量,但是其推导出的 param 并非 const,因为
param 是一个完全独立于 cx 和 rx
的副本,我们认为 param 的修改不会影响 cx 和
rx 不能修改的事实,因此在类型推导时会忽略其常量性。
现在,请考虑这样一种情况:
1 | |
此时 ptr 左边的 const 意味着指针
ptr 是常量,不能指向其他位置,也不能为
null。char* 左边的 const 意味着
ptr 指向的字符串内容是常量。
当 ptr 传递给 f() 时,ptr
本身会拷贝给 param,此时 ptr 的
const 被忽略,但是 ptr 指向内容的
const 不会被忽略,因此最终的推导结果 T 和
ParamType 都为 const char*。
数组与函数类型
在 C++ 的模板推导中,数组和函数在大多数情况下会退化为指针,但在特定引用类型下会保持原类型。这是来源于 C 语言中数组和函数名会退化为指针的特性。
数组类型的类型推导
1 | |
当 ParamType 为值或指针时,数组会退化为指针。
1 | |
当 ParamType 为引用时,数组不会退化,T
会被推导为数组类型。
函数类型的类型推导
函数类型(如
void(int, double))与数组遵循完全相同的退化规则:按值传递时退化为函数指针,按引用传递时保留函数引用。
1 | |
auto 类型推导
auto 是 C++11 引入的核心特性之一(在 C++14、C++17 和
C++20 中得到了持续增强)。它的主要作用是自动类型推导(automatic type
deduction),即让编译器在编译期根据初始化的表达式自动推导出变量的类型,而不会带来任何运行时开销。
auto 的类型推导规则与 template
的类型推导规则几乎一样。虽然 auto 没有
template、function 和 parameters,但是 auto 和
template 类型推导之间有一套直接的映射规则。
当使用 auto 声明变量时,auto 扮演了
template 类型推导中 T 的角色,而
auto 的整体修饰类型扮演了 template 类型推导中
ParamType 的角色。例如:
1 | |
其中 auto 扮演了 T
的角色,auto、const auto 和
const auto& 扮演了 ParamType 的角色。
auto 类型推导也遵循与 template
推导一样的三种情况:
- 类型说明符是指针或引用类型,但不是万能引用
- 类型说明符是万能引用
- 类型说明符既不是指针也不是引用
情况 1 和 3 正如刚才的例子:
1 | |
情况 2 例如:
1 | |
template 类型推导对于数组和函数的性质在
auto
类型推导依旧适用,对于指针或者值类型的类型说明符,数组和函数名会退化为指针,对于引用则遵循普通引用和万能引用的引用折叠规则。例如:
1 | |
auto 类型推导与 template
类型推导在有些代码中会得到不同的结果:
C++11 引入了对统一初始化(uniform
initialization)的支持,它允许使用花括号 {}
统一对基本类型、结构体、类对象以及容器进行初始化。
但是进行 auto 类型推导时,会得到意料之外的类型:
1 | |
对于带赋值号的花括号列表初始化,auto 会将其硬编码推导为
std::initializer_list<T>
类型(注意:std::initializer_list
内部仅持有临时数组的指针,本质是一个轻量级的代理类)。
为了避免误导,在 C++17 中:
- 带赋值号的拷贝列表初始化:
auto x3 = { 27 };→std::initializer_list<int> - 不带赋值号的直接列表初始化:
auto x4{ 27 };→int(如果写auto x4{1, 2}则直接编译报错)
但是 template 类型推导会报错:
1 | |
这是为了避免泛型函数重载时产生模棱两可的歧义。如果模板需要接收花括号,必须显式声明形参为
std::initializer_list<T>:
1 | |
C++14 允许使用 auto
来指定函数的返回类型应由编译器推导,同时也允许在 lambda
表达式的形参声明中使用 auto。然而,这些 auto
的用法实际上采用的是 template 类型推导机制,而非
auto 类型推导机制。因此,如果一个返回类型为
auto 的函数试图返回一个花括号初始化列表(braced
initializer),代码将无法通过编译:
1 | |
同样的事情也发生在 auto 作为 lambda
表达式的形参类型时:
1 | |
decltype 类型推导
与 template 类型推导或 auto
类型推导不同,decltype
类型推导结果是变量或表达式最真实、未经修改的类型:
1 | |
在 C++11 中,我们可以利用 decltype
支持容器/数组随机访问的函数模板 authAndAccess:
1 | |
使用尾置返回类型(trailing return type)decltype(c[i])
是因为:在函数头写 decltype(c[i]) 时,参数 c
和 i 还没有被声明,只有放到参数列表后面的
-> decltype(c[i]) 处,c 和 i
才是合法的标识符。
在 C++14 中允许省略尾置返回类型,直接写 auto:
1 | |
但是这样存在一个问题:这里的 auto 使用的是
template
类型推导规则,返回值的引用类型会被忽略,导致返回的是一个右值。比如
authAndAccess(v, 5)
返回了一个临时对象的拷贝(右值),会导致
authAndAccess(v, 5) = 10 无法通过编译!
为了解决这个问题,C++14 引入了 decltype(auto):
1 | |
但是这样虽然可以正确返回引用类型,仍然存在问题:
- 无法接收右值容器(如临时容器对象)
- 无法正确转发右值容器的元素(缺乏万能引用与完美转发)
下面给出优化后的写法:
1 | |
decltype(auto)
的使用并不局限于函数返回类型。当你希望对初始化表达式应用
decltype 类型推导规则来声明变量时,它同样非常方便:
1 | |
最后需要注意的是,C++
规定:给变量名加一对括号,会将其从”变量名”变成”左值表达式”,但是
decltype 对于变量名和左值表达式的推导结果并不相同:
1 | |
第二种写法会使得函数返回一个局部变量的引用,属于未定义行为。
这次的插图来自画师 Fangpeii
图片地址:https://www.pixiv.net/artworks/120409457
文章内容参考:Effective Modern C++ 42 Specific Ways to Improve Your Use of C++11 and C++14 (Scott Meyers) Item1-Item3