《EffectiveModernC++》第一章:类型推导
第一章 类型推导
item1:理解模板类型推导
考虑这样一个函数模板:
1 |
|
我们可以给T加上一些修饰,如下:
1 |
|
在编译期间,编译器使用expr
进行两个类型推导:一个是针对T
的,另一个是针对ParamType
的。这两个类型通常是不同的,因为ParamType
包含一些修饰,比如const
和引用修饰符。
我们期望传入参数的类型和T是一致的(就像上面的传入的int参数,T也会被推导出int),但有时情况并非总是如此,T
的类型推导不仅取决于expr
的类型,也取决于ParamType
的类型。这里有三种情况:
- 情景一:
ParamType
是一个指针或引用,但不是通用引用
最简单的情况是ParamType
是一个指针或者引用,但非通用引用。在这种情况下,类型推导会这样进行:
- 如果
expr
的类型是一个引用,忽略引用部分 - 然后
expr
的类型与ParamType
进行模式匹配来决定T
1 |
|
后两个的调用中,变量cx
是一个const value
,rx
是a reference to a const value
,也就是都不允许改变变量的值,这两者T推导出来的信息也包含const
,都是const int
,这是十分有道理的,考虑一下当你传入一个const value
的值, 这个值/对象也是期望不可以被改变,那么T理应推导出常量性的信息,即也包含constness
常量性。
这也是为什么将一个const
对象传递给以T&
类型为形参的模板安全的:对象的常量性const
ness会被保留为T
的一部分。
在第三个例子中,注意即使rx
的类型是一个引用,T
也会被推导为一个非引用 ,这是因为rx
的引用性(reference-ness)在类型推导中会被忽略。
如果我们将f
的形参类型T&
改为const T&
1 |
|
cx
和rx
的const
ness依然被遵守(param
变量依然是一个constness
的)。但是因为现在我们假设param
是reference-to-const
,const
不再被推导为T
的一部分.
如果param
是一个指针(或者指向const
的指针)而不是引用,情况本质上也同理:
1 |
|
- 情景二:
ParamType
是一个通用引用
- 如果
expr
是左值,T
和ParamType
都会被推导为左值引用。 - 如果
expr
是右值,就使用正常的(也就是情景一)推导规则。
1 |
|
Item24
详细解释了为什么这些例子是像这样发生的。这里关键在于通用引用的类型推导规则是不同于普通的左值或者右值引用的。尤其是,当通用引用被使用时,类型推导会区分左值实参和右值实参,但是对非通用引用时不会区分。
- 情景三:ParamType既不是指针也不是引用
当ParamType
既不是指针也不是引用时,我们通过传值(pass-by-value)的方式处理:
1 |
|
这意味着无论传递什么param
都会成为它的一份拷贝——一个完整的新对象。事实上param
成为一个新对象这一行为会影响T
如何从expr
中推导出结果。
- 和之前一样,如果
expr
的类型是一个引用,忽略这个引用部分 - 如果忽略
expr
的引用性(reference-ness)之后,expr
是一个const
,那就再忽略const
。如果它是volatile
,也忽略volatile
(volatile
对象不常见,它通常用于驱动程序的开发中。
考虑这样的一个情况:
expr
是一个const
指针,指向const
对象,expr
通过传值传递给param
:
1 |
|
在这里,解引用符号(*)的右边的const
表示ptr
本身是一个const
:ptr
不能被修改为指向其它地址,也不能被设置为null。
当ptr
作为实参传给f
,组成这个指针的每一比特都被拷贝进param
。像这种情况,ptr
自身的值会被传给形参,根据类型推导的第三条规则,ptr
自身的常量性const
ness将会被省略,所以param
是const char*
,也就是一个可变指针指向const
字符串。 这一条看似有些奇怪,但是我们观察第三条中的
expr
是一个const
,那就再忽略const
。
这里强调的是expr的const,这里expr是一个指针 ,忽略的是指针的const,即忽略指针的常量性,指针指向的内容的常量性不应该被忽略 。
现在我们再来讨论数组实参:
上面的内容几乎覆盖了模板类型推导的大部分内容,但这里还有一些小细节值得注意,比如数组类型不同于指针类型,虽然它们两个有时候是可互换的。
在很多上下文中数组会退化为指向它的第一个元素的指针。这样的退化允许像这样的代码可以被编译:
1 |
|
在这里const char*
指针ptrToName
会由name
初始化,而name
的类型为const char[13]
,这两种类型(const char*
和const char[13]
)是不一样的,但是由于数组退化为指针的规则,编译器允许这样的代码。
但要是一个数组传值给一个模板会怎样?会发生什么?
1 |
|
但是现在难题来了,虽然函数不能声明形参为真正的数组,但是可以接受指向数组的引用!所以我们修改f
为传引用:
1 |
|
T
被推导为了真正的数组!这个类型包括了数组的大小,在这个例子中T
被推导为const char[13]
,f
的形参(对这个数组的引用)的类型则为const char (&)[13]
。
这也是一个小trick知识。
有趣的是,可声明指向数组的引用的能力,使得我们可以创建一个模板函数来推导出数组的大小:
1 |
|
在Item15提到将一个函数声明为constexpr
使得结果在编译期间可用。这使得我们可以用一个花括号声明一个数组,然后第二个数组可以使用第一个数组的大小作为它的大小,就像这样:
1 |
|
当然作为一个现代C++程序员,你自然应该想到使用std::array
而不是内置的数组:
1 |
|
至于arraySize
被声明为noexcept
,会使得编译器生成更好的代码,具体的细节请参见Item14
再来讨论函数实参:
在C++中不只是数组会退化为指针,函数类型也会退化为一个函数指针,我们对于数组类型推导的全部讨论都可以应用到函数类型推导和退化为函数指针上来。结果是:
1 |
|
这里你需要知道:auto
依赖于模板类型推导。正如我在开始谈论的,在大多数情况下它们的行为很直接。在通用引用中对于左值的特殊处理使得本来很直接的行为变得有些污点,然而,数组和函数退化为指针把这团水搅得更浑浊。
请记住:
- 在模板类型推导时,有引用的实参会被视为无引用,他们的引用会被忽略
- 对于通用引用的推导,左值实参会被特殊对待,即推导出左值引用
- 对于传值类型推导,
const
和/或volatile
实参会被认为是non-const
的和non-volatile
的 - 在模板类型推导时,数组名或者函数名实参会退化为指针,除非它们被用于初始化引用(即
T&
作为实参时会被理解为数组/函数的引用)
item2:理解auto
类型推导
auto在绝大多数情况是和模板一致的,T和auto等价。
1 |
|
这里x
的类型说明符是auto
自己,另一方面,在这个声明中:
1 |
|
类型说明符是const auto
。另一个:
1 |
|
类型说明符是const auto&
。在这里例子中要推导x
,rx
和cx
的类型,编译器的行为看起来就像是认为这里每个声明都有一个模板,然后使用合适的初始化表达式进行调用:
1 |
|
正如我说的,auto
类型推导除了一个例外(我们很快就会讨论),其他情况都和模板类型推导一样。
因此Item1描述的三个情景稍作修改就能适用于auto:
- 情景一:类型说明符是一个指针或引用但不是通用引用
- 情景二:类型说明符一个通用引用
- 情景三:类型说明符既不是指针也不是引用
我们早已看过情景一和情景三的例子:
1 |
|
情景二像你期待的一样运作:
1 |
|
Item1讨论并总结了对于non-reference类型说明符,数组和函数名如何退化为指针。那些内容也同样适用于auto
类型推导:
1 |
|
就像你看到的那样,auto
类型推导和模板类型推导几乎一样的工作,它们就像一个硬币的两面。
下面说一下auto和模板类型推导的一些小区别:
如果你想声明一个带有初始值27的int
,C++98提供两种语法选择:
1 |
|
C++11由于也添加了用于支持统一初始化(uniform initialization)的语法:
1 |
|
把上面声明中的int
替换为auto
,我们会得到这样的代码:
1 |
|
这些声明都能通过编译,但是他们不像替换之前那样有相同的意义。前面两个语句确实声明了一个类型为int
值为27的变量,但是后面两个声明了一个存储一个元素27的 std::initializer_list<int>
类型的变量。
如果这样的一个类型不能被成功推导(比如花括号里面包含的是不同类型的变量),编译器会拒绝这样的代码:
1 |
|
对于花括号的处理是auto
类型推导和模板类型推导唯一不同的地方。当使用auto
声明的变量使用花括号的语法进行初始化的时候,会推导出std::initializer_list<T>
的实例化,但是对于模板类型推导这样就行不通:
1 |
|
然而如果在模板中指定T
是std::initializer_list<T>
而留下未知T
,模板类型推导就能正常工作:
1 |
|
对于C++11故事已经说完了。但是对于C++14故事还在继续,C++14允许auto
用于函数返回值并会被推导(参见Item3),而且C++14的lambda函数也允许在形参声明中使用auto
。但是在这些情况下auto
实际上使用模板类型推导的那一套规则在工作,而不是auto
类型推导,所以说下面这样的代码不会通过编译:
1 |
|
同样在C++14的lambda函数中这样使用auto也不能通过编译:
1 |
|
item3:理解decltype
decltype
是一个奇怪的东西。给它一个名字或者表达式decltype
就会告诉你这个名字或者表达式的类型。通常,它会精确的告诉你你想要的结果。但有时候它得出的结果也会让你挠头半天,最后只能求助网上问答或参考资料寻求启示。
我们将从一个简单的情况开始,没有任何令人惊讶的情况。相比模板类型推导和auto
类型推导(参见Item1和Item2),decltype
只是简单的返回名字或者表达式的类型:
1 |
|
看见了吧?没有任何奇怪的东西。
这是我们写的第一个版本,使用decltype
计算返回类型,这个模板需要改良,我们把这个推迟到后面:
1 |
|
在这种声明中,authAndAccess
函数返回operator[]
应用到容器中返回的对象的类型,这也正是我们期望的结果。
C++11允许自动推导单一语句的lambda表达式的返回类型, C++14扩展到允许自动推导所有的lambda表达式和函数,甚至它们内含多条语句。甚至它们内含多条语句。对于authAndAccess
来说这意味着在C++14标准下我们可以忽略尾置返回类型,只留下一个auto
。使用这种声明形式,auto标示这里会发生类型推导。更准确的说,编译器将会从函数实现中推导出函数的返回类型。
Item2解释了函数返回类型中使用auto
,编译器实际上是使用的模板类型推导的那套规则。如果那样的话这里就会有一些问题。正如我们之前讨论的,operator[]
对于大多数T
类型的容器会返回一个T&
,但是Item1解释了在模板类型推导期间,表达式的引用性(reference-ness)会被忽略。基于这样的规则,考虑它会对下面用户的代码有哪些影响:
1 |
|
在这里d[5]
本该返回一个int&
,但是模板类型推导会剥去引用的部分,因此产生了int
返回类型。函数返回的那个int
是一个右值,上面的代码尝试把10赋值给右值int
,C++11禁止这样做,所以代码无法编译。
要想让authAndAccess
像我们期待的那样工作,我们需要使用decltype
类型推导来推导它的返回值,即指定authAndAccess
应该返回一个和c[i]
表达式类型一样的类型。C++期望在某些情况下当类型被暗示时需要使用decltype
类型推导的规则,C++14通过使用decltype(auto)
说明符使得这成为可能。我们第一次看见decltype(auto)
可能觉得非常的矛盾(到底是decltype
还是auto
?),实际上我们可以这样解释它的意义:auto
说明符表示这个类型将会被推导,decltype
说明decltype
的规则将会被用到这个推导过程中。因此我们可以这样写authAndAccess
:
1 |
|
decltype(auto)
的使用不仅仅局限于函数返回类型,当你想对初始化表达式使用decltype
推导的规则,你也可以使用:
1 |
|
再看看C++14版本的authAndAccess
声明:
1 |
|
容器通过传引用的方式传递非常量左值引用(lvalue-reference-to-non-const),因为返回一个引用允许用户可以修改容器。但是这意味着在不能给这个函数传递右值容器,右值不能被绑定到左值引用上(除非这个左值引用是一个const(lvalue-references-to-const),但是这里明显不是)。
公认的向authAndAccess
传递一个右值是一个edge case(译注:在极限操作情况下会发生的事情,类似于会发生但是概率较小的事情)。一个右值容器,是一个临时对象,通常会在authAndAccess
调用结束被销毁,这意味着authAndAccess
返回的引用将会成为一个悬置的(dangle)引用。但有时候用户可能只是想简单的获得临时容器中的一个元素的拷贝,比如这样:
:
1 |
|
要想支持这样使用authAndAccess
我们就得修改一下当前的声明使得它支持左值和右值。
1.重载是一个不错的选择(一个函数重载声明为左值引用,另一个声明为右值引用),但是我们就不得不维护两个重载函数。
2.另一个方法是使authAndAccess
的引用可以绑定左值和右值,Item24解释了那正是通用引用能做的,所以我们这里可以使用通用引用进行声明:
1 |
|
在这个模板中,我们不知道我们操纵的容器的类型是什么,那意味着我们同样不知道它使用的索引对象(index objects)的类型,对一个未知类型的对象使用传值通常会造成不必要的拷贝,对程序的性能有极大的影响,还会造成对象切片行为(参见item41),以及给同事落下笑柄。但是就容器索引来说,我们遵照标准模板库对于索引的处理是有理由的(比如std::string
,std::vector
和std::deque
的operator[]
),所以我们坚持传值调用。
然而,我们还需要更新一下模板的实现,让它能听从Item25的告诫应用std::forward
实现通用引用:
1 |
|
这样就能对我们的期望交上一份满意的答卷,但是这要求编译器支持C++14。如果你没有这样的编译器,你还需要使用C++11版本的模板,它看起来和C++14版本的极为相似,除了你不得不指定函数返回类型之外:
1 |
|
decltype还有很多奇怪的规则,比如:
1 |
|
注意不仅f2
的返回类型不同于f1
,而且它还引用了一个局部变量!这样的代码将会称为未定义行为,这是一种非常不好的情况。
其实本质是 (x)会被认为是一个表达式,而x会被认为是一个变量,更多推导规则可以看:
我们这里只是像教给你怎么用decltype写一个合理的用法。
请记住:
decltype
总是不加修改的产生变量或者表达式的类型(表达式为右值正常返回,表达式是左值会返回左值引用)。- 对于
T
类型的不是单纯的变量名的左值表达式,decltype
总是产出T
的引用即T&
。 - C++14支持
decltype(auto)
,就像auto
一样,推导出类型,但是它使用decltype
的规则进行推导。
item4:学会查看类型推导结果
第一种方法就是通过IDE来查看
第二种通过编译器报错来查看:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16using FunType = int(const int&,int);
int fun(const int& x,int y)
{
cout<<x<<" "<<y<<endl;
return x+y;
};
template<typename T>
class TD;
int main()
{
TD<decltype(fun)>xtype;
return 0;
}
Copy报错:
error: aggregate 'TD<int(const int&, int)> xtype' has incomplete type and cannot be defined|
,可以得知:fun
是int(const int&, int)
类型。
本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!