Effective Modern C++ Item 1
条款1:理解模板类型推导
当一个复杂的系统的用户不知系统是如何工作的,仍对其所做的感到满意。按照这样的标准,C++中的模板类型推导是非常成功的。数以百万的程序员向模板函数传递参数并得到完全满意的结果。尽管许多程序员很难解释这些函数是如何推导类型的。
如果上述的程序员也包括你,我有一个好消息和一个坏消息。好消息是模板的类型推导是基于现代C++最引人注目的特性:auto。如果你对C++98的模板类型推导满意的话,那么你也会喜欢C++11的auto类型推导。坏消息是当模板类型推导规则应用到auto的情况下,它有些时候不如应用到模板下易理解。为此,真正的了解作为auto基础的模板类型推导的各方面就很重要了。这条条款覆盖了你所需要知道的内容。
如果你不想看些伪代码,我们可以认为函数模板看起来是这样的:
template <typename T>
void f(ParamType param);
调用像这样:
f(expr); //用一些表达式来调用f
在编译期间,编译器使用expr
来推导两个类型,一个是T
,另一个是ParamType
。这些类型通常是不同的,因为ParamType
经常是含有修饰词的,例如:const
和引用限定符。举个例子,如果模板像这样声明:
template <typename T>
void f(const T& param); //ParamType是const T&类型
我们有这样的调用:
int x = 0;
f(x); //用一个int来调用f
T
被推导为int类型,但ParamType
被推导为const int&
类型。很自然的会期望T
的类型推导与传入函数参数的类型相同。换句话说,T
是expr
的类型,在上面的例子是这样的情况,x
是int类型,T
也被推导为int类型,但并不总是以这样的方式。对T
的推导不仅依赖于expr
的类型,也依赖于ParamType
的类型,这里有三种情况:
-
ParamType
是一个指针或引用类型,但不是一个通用引用。(通用引用在条款24中进行说明。此时,你只需要知道它存在且与左值引用或右值引用不同。) -
ParamType
是一个通用引用。 -
ParamType
既不是指针与不是引用。
我们因此有三种类型推导场景要检查。每一个都基于我们通用的模板模式:
template <typename T>
void f(ParamType param);
f(expr); //从expr来推导T和ParamType的类型
Case1: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&类型
在各种调用中推导param
和T
的类型,如下:
f(x); //T是int类型,param的类型为int&
f(cx); //T是const int类型,param的类型为const int&
f(rx); //T是const int类型,param的类型为const int&
在第二个和第三个调用中,注意到cx
和rx
指定为const
值,T
会推导为const int
,由此产生参数类型为const int&
。这对调用者来说很重要。当它传一个const
对象给引用参数时,它期望那个对象也不可修改。换句话说,参数应为常量引用。这就是为什么传一个const
对象给T&
参数模板是安全的:对象的constness
会成为T
类型推导的一部分。
在第三个示例中,注意到尽管rx
的类型是引用,T
被推导为无引用,这是因为rx
的reference-ness
在类型推导期间被忽略了。
这些例子全部展示的是左值引用参数,但是右值引用参数的情况也一样。当然,只有右值参数可以传递给右值引用参数,但是这个限制与类型推导无关。
如果我们将f
的参数类型从T&
改为const T&
,情况稍微有点改变,但并不令人吃惊。cx
和rx
的constness
会继续保持,但是因为我们现在是param
是const
引用,所以不再需要推导const
作为T
的一部分:
template <typename T>
void f(const T& param); //param现在是const T&
int x = 27; //和之前一样
const int cx = x; //和之前一样
const int& rx = x; //和之前一样
f(x); //T是int类型,param的类型为const int&
f(cx); //T是int类型,param的类型为const int&
f(rx); //T是int类型,param的类型为const int&
和之前一样,rx
的reference-ness
在类型推导中被忽略。
如果param
是一个指针(或指向常量的指针)而不是一个引用,和在引用的的情况下是一样的:
template <typename T>
void f(T* param); //param现在是一个指针
int x = 27; //和之前一样
const int *px = &x; //px是一个指向x的const int指针
f(&x); //T是int类型,param的类型是int*
f(px); //T是const int类型,param的类型是const int*
到目前为止,你可能发现自己在打哈欠和打盹,因为C++对引用和指针参数的类型推导规则是如此的自然,看它的书面形式真的很沉闷。所有的事都很明显!这就是你想要的类型推导系统。
Case2:ParamType是一个通用引用
通用引用参数模板的情况就不那么明显了。这样的参数的声明像右值引用(换句话说,在类型参数T
的函数模板中,通用引用声明的类型为T&&
),但当左值参数传入时的行为不一样。完整的情形在条款24中讨论,但这里有先行版本:
- 如果
expr
是一个左值,T
和ParamType
都会被推导为左值引用。这很不寻常。第一,这是模板类型T
被推导为引用的唯一情形。第二,尽管ParamType
是使用右值引用语法声明的,但其推导类型为左值引用。 - 如果
expr
是一个右值,使用“正常”(换句话说,Case1)规则。
例如:
template <typename T>
void f(T&& param); //param现在是一个通用引用
int x = 27; //和之前一样
const int cx = x; //和之前一样
const int& rx = x; //和之前一样
f(x); //x是左值,因此T是int&类型,
//param的类型也是int&
f(cx); //cx是左值,因此T是const int&类型,
//param的类型也是const int&
f(rx); //rx是左值,因此T是const int&类型,
//param的类型也是const int&
f(27); //27是右值,因此T是int类型,
//param的类型因此是int&&
条款24解释了为什么这些例子是以这些方式运作的。这里的关键点是对通用引用参数的推导规则与左值引用参数或右值引用参数不同。尤其是当你使用通用引用时,对于左值参数和右值参数的类型推导不同,这不会发生在非通用引用上。
Case3:ParamType既不是指针也不是引用
当ParamType
既不是指针也不是引用时,我们通过传值来解决:
template <typename T>
void f(T param); //param现在传值
这意味着param
会复制任何传入的对象——一个完全新的对象。事实上,param
将是新的对象激发控制T
从expr
推导的规则:
- 像之前一样,如果
expr
的类型是引用,忽略引用部分。 - 如果,在忽略
expr
的reference-ness
后,expr
是const
,将同样忽略const-ness
。如果它是volatile
,同样忽略。(volatile
对象是罕见的,它通常只用在实现设备驱动的时候,详情见条款40。)
因此:
int x = 27; //和之前一样
const int cx = x; //和之前一样
const int& rx = x; //和之前一样
f(x); //T和param的类型均为int
f(cx); //T和param的类型还是int
f(rx); //T和param的类型仍是int
注意到尽管cx
和rx
是const
值,param
不是const
。这是有意义的,param
是完全独立于cx
和rx
的对象——是cx
或rx
的拷贝。事实上cx
和rx
不能修改与param
是否能修改没有关系。这就是为什么expr
的constness
(和volatilness
,如果有)在推导param
类型时可以被忽略:只是因为expr
不能被修改并不以为着其拷贝不能。
认识到const
(和volatile
)只能在传值参数下被忽略很重要。就如我们已经看到的,常量引用参数和指向常量的参数,expr
的constness
在类型推导中被保留。但考虑到如下示例,expr
是一个指向常量的常量指针且expr
传值给param
:
template <typename T>
void f(T param); //param仍传值
const char* const ptr =
"Fun with pointers"; //ptr是指向常量的常量指针
f(ptr); //传递类型为const char* const参数
这里星号右边的const
声明ptr
是常量:ptr
不能指向不同的地址,也不能设为null。(星号左边的const
说明ptr
指向的是什么—字符串—是const
,因此不可被修改)当ptr
传给f
,组成指针的内存的bit被拷贝进param
。同样的,指针本身(ptr
)将以值传递。和对传值的类型推导规则一样,ptr
的constness
将被忽略,对param
的类型推导为const char*
,换句话说,一个可被修改的指针,指向const
字符串,ptr
所指向的对象的constness
在类型推导期间被保留,但当ptr
拷贝给新指针param
时,会忽略ptr
自身的constness
。
数组参数
这几乎包括了主流的模板类型推导,但有一个合适的例子值得我们了解。那就是数组类型不同于指针类型。尽管它们有些时候看起来可以互换。会有这种错觉的主要原因是,在许多情景下,一个数组可退化为指向其第一个元素的指针。这个退化允许代码像下列一样去编译:
const char name[] = "J. P. Briggs"; //name的类型为const char[13]
const char * ptrToName = name; //数组退化成指针
这里const char*
指针ptrToName
被name
给初始化,name
是const char[13]
。这些类型(const char*
和const char[13]
)不相同,但是因为数组转化成指针退化规则,代码可以编译。
但是如果数组传入一个传值参数模板,那时会发生什么?
template <typename T>
void f(T param); //传值参数模板
f(name); //T和param会被推导为什么类型?
首先我们观察到这里没有函数参数,这是一个数组,是的,是的,这个语法是合法的。
void myFunc(int param[]);
但数组声明像对待指针声明一样,这意味着myFunc
像这样的声明:
void myFunc(int* param); //同上面函数一样
数组参数和指针参数相等来源自基于C++的C语言,这就造成了数组类型和指针类型相同的错觉。
因为有指针参数的情况数组参数声明会当成指针一样对待,所以将数组类型传给传值模板函数会被推导为指针类型。这意味着调用模板f
时,参数T
被推导为const char*
:
f(name); //name是数组,但T被推导为const char*
但现在来了一个难题。尽管函数不能声明参数为真正的数组,但你可以声明参数为数组引用!因此我们修改模板f
的参数为引用,
template <typename T>
void f(T& param); //参数类型为引用的模板
然后我们传入数组,
f(name); //传数组给f
对T的类型推导的结果是真正的数组!这个类型包括数组的大小,在这个例子中,T
被推导成const char[13]
,且f
的参数类型(这个数组的引用)是const char(&)[13]
。是的,这个语法看起来很奇怪,但知道这些能在在意这些的人前加分。
有趣的是,声明数组引用能创建推导数组包含多少元素的模板:
//返回数组的大小作为编译时常量
//(数组参数没有名字,因为我们
//只关心包含多少元素)
template <typename T, std::size_t N> //参考以下对
constexpr std::size_t arraySize(T (&)[N]) noexcept //constexpr
{ //和noexcept
return N; //的说明
}
就像条款15所解释的,声明函数为constexpr
能在编译期得到结果。这让声明一个数组,这个数组的元素数量由其他花括号初始化的数组计算来成为了可能:
int keyVals[] = {1, 3, 7, 9, 11, 22, 35}; //keyVals有7个元素
int mappedVals[arraySize(keyVals)]; //mappedVals也有7个元素
当然,作为一个现代C++开发者,你更自然地使用std::array
来创建数组:
std::array<int, arraySize(keyVals)> mappedVals; //mappedVals的大小是7
至于arraySize
被声明为noexcept
,这是帮助编译器产生更好的代码,详情见条款14。
函数参数
数组并不是唯一一个在C++中会退化成指针的东西。函数类型可退化成函数指针,且所有我们讨论的,认为对数组的类型推导适用于对函数的类型推导,他们都退化成指针。所以:
void someFunc(int, double); //someFunc是一个函数;
//类型是void(int, double)
template <typename T>
void f1(T param); //在f1中,param传值
template <typename T>
void f2(T& param); //在f2中,param传引用
f1(someFunc); //param推导为函数指针;
//类型为void(*)(int, double)
f2(someFunc); //param推导为函数引用
//类型为void(&)(int, double)
这实际上很少有不同,但如果你了解了数组退化成指针,你也会知道函数退化成指针。
因此你知道了这些:auto相关的模板推导法则。我一开始就说过它们很简单,在很大程度上,也确实是这样。在通用引用中对于左值要特殊处理,而且有些混乱,但是对于数组和函数退化成指针更容易混淆。有的时候你几乎想抓着你的编译器和命令,喊道:“告诉我你的类型!”当这种情况发生时,你应该去条款4,它会让编译器告诉你是如何处理的。
该记住的事:
- 在模板类型推导期间,对待引用参数就和非引用参数一样,换句话说,它们的
reference-ness
将被忽略。 - 当对通用引用参数进行类型推导时,左值参数有特殊对待。
- 当对传值参数进行类型推导时,对待
const
和/或volatile
参数和non-const
和non-volatile
一样。 - 在模板类型推导期间,数组参数或函数参数退化为指针,除非是用于初始化引用类型上。
上一篇: 反射学习笔记
下一篇: java中的引用类型和值类型的区别
推荐阅读
-
Effective C++ item01 尽量以const enum inline替换#define
-
Effective C++ item 5
-
Effective Modern C++ Item 1
-
Effective Modern C++: Item 12 -> 声明覆盖函数override
-
C++必备基础知识(x+1) -《Effective C++必懂条款2》
-
C++备忘录058:Template Type Deduction gist "Effective Modern C++"
-
C++备忘录062:Corner Cases for std::move/std::forward gist of "Effective Modern C++"
-
Effective Modern C++
-
C++备忘录063:Overloading on Universal References gist of "Effective Modern C++"
-
modern effective C++ 18