Effective Modern C++翻译(2)-条款1:明白模板类型推导

时间:2022-05-04 15:02:23

第一章 类型推导

C++98有一套单一的类型推导的规则:用来推导函数模板,C++11轻微的修改了这些规则并且增加了两个,一个用于auto,一个用于decltype,接着C++14扩展了auto和decltype可以使用的语境,类型推导的普遍应用将程序员从必须拼写那些显然的,多余的类型的暴政中解放了出来,它使得C++开发的软件更有弹性,因为在某处改变一个类型会自动的通过类型推导传播到其他的地方。

然而,它可能使产生的代码更难观察,因为编译器推导出的类型可能不像我们想的那样显而易见。

想要在现代C++中进行有效率的编程,你必须对类型推导操作有一个扎实的了解,因为有太多的情形你会用到它,在函数模板的调用中,在auto出现的大多数场景中,在decltype表达式中,在C++14,神秘的decltype(auto)构造被应用的时候。

这一章提供了一些每一个C++开发者都需要了解的关于类型推导的基本信息,它解释了模板类型推导是如何工作的,auto是如何在此基础上建立自己的规则的,decltype是如何按自己的独立的规则工作的,它甚至解释了你如何强迫编译器来使类型推导的结果可见,从而让你确定编译器的结果是你想要的。

条款1 明白模板类型推导

据说模仿是最诚恳的恭维之道,但是充满喜悦的无知也同样是可以衷心赞美的,当使用一个复杂的系统,忽视了它的系统是如何设计的,是如何工作的,然而对它的所完成的事情你依旧会感到很高兴,通过这种方式,C++中模板的类型推导成为了一个巨大的成功,数百万的程序员向模板函数中传递参数,并获得完全令人满意的答案,尽管很多程序员被紧紧逼着的去付出比对这些函数是如何被推导的一个朦胧的描述要更多。(even though many of those programmers would be hard-pressed to give more than the haziest description of how the types used by those functions were deduced.)

如果上面提到数百万的程序员中包括了你,我有一个好消息也有一个坏消息,好消息是对于auto声明的变量的类型推导规则和模板在本质上是一样的,所以当涉及到auto的时候,你会感到很熟悉,坏消息是当模板类型推导的规则应用到auto的时候,你很可能对发生的事情感到惊讶,如果你想要使用auto(相比精确的类型声明,你当然更应该使用auto,见条款5),你需要对模板类型推导规则有一个合理正确的认识,他们通常是直截了当的,所以这不会照成太大的挑战,和在C++98里工作的方式是一样的,你很可能不需要对此有过多的思考。

如果你愿意忽略少量的伪代码,我们可以直接对下面的模板函数的代码进行思考。

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 = ;
f(x); // 用一个int类型调用函数f

T被推导为int,但是ParamType被推导为const int&

我们很自然的去期待推导出的T的类型和传递给函数实参的类型是一致的,例如,T的类型就是expr的类型,在上面的例子中,就是这种情况,x的类型是int,T被推导为int,当时它并不总是这样是的,被推导出的T的类型,不仅仅取决于expr的类型,同样取决于ParaType的形式,总共有三种情形;

  • ParamType是一个指针或是一个引用,但不是一个万能引用(universal reference),(universal reference会在条款26中进行描述,现在你只要知道他是存在的即可)。
  • ParamType是一个万能引用(universal reference) 。
  • ParamType既不是一个指针也不是一个引用 。

因此,我们会有三种类型推导的情景,每一个调用都会以我们通用的模板形式为基础:

template<typename T>
void f(ParamType param);
f(expr); // 从expr中推导出T和ParamType的类型

第一种情况:ParamType是一个指针或是一个引用,但不是一个万能引用(universal reference)

最简单的情况是当ParamType是一个指针或是一个引用,但不是一个万能引用(universal reference),在这种情况下,模型推导的方式会像下面这样:

  • 如果expr的类型是一个引用,忽略引用的符号
  • 通过模式匹配expr的类型来决定ParamType的类型从而决定T的类型(Pattern-match expr's type against ParamType to determine T)

例如,如果我们的模板函数是这样的

template<typename T>
void f(T& param);

我们有这样的变量声明

int x = ;         // x是int类型
const int cx = x; // cx是const int
const int& rx = x; // rx是x的一个常量引用(rx is a read-only view of x)

函数调用时,推导出的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&参数的模板传递一个const对象是安全的,对象的常量性(constness)成为了推导出的类型T的一部分。

在第三个例子中,注意到尽管rs是一个引用类型,T被推导为一个非引用类型,这是因为rs的引用性(reference-ness)在推导的过程中被忽略了,如果不是这样的话(例如,T被推导为const int&),param的类型将会是const int&&,一个引用的引用,引用的引用在C++里是不允许的,避免他们的唯一方法在类型推导时忽略表达式的引用性(reference-ness)。

这些例子都是左值的引用参数,但是这些类型推导规则对于右值的引用参数同时适用,当然,只有右值的实参会被传递给一个右值类型的引用,但是这对类型推导没有什么影响。

如果我们把f的参数类型由T&改成const T&,事情会发送一点小小的改变,但不会太让人惊讶,cx和rx的常量性依旧满足,但是因为我们现在假定了param是一个常量的引用,const不在需要被推导为T的一部分了。

template<typename T>
void f(const T& param); // param现在是一个指向常量的引用
int x = ; // 和之前一样
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&

像之前一样,rs的引用性(reference-ness)在类型推导时被忽略了。

如果param是一个指针(或是一个常量指针(point to const))而不是一个引用,规则依旧适用

template<typename T>
void f(T* param); // param现在是一个指针
int x = ; // 和之前一样
const int *px = &x; // px是x的一个常量引用(rx is a read-only view of x)
f(&x); // T是int, param的类型是int*
f(px); // T是const int,
// param的类型是const int*,

此时此刻,你可能发现你自己在不断的打哈欠和点头,应为C++的类型推导规则对于引用和指针类型的参数是如此的自然,看见他们一个个被写出来是一件很枯燥的事情,因为他们是如此的显而易见,和你在类型推导中期待的是一样的。

第二种情况:ParamType是一个万能的引用(Universal Reference)

当涉及到万能引用(universal reference)作为模板的参数的时候(例如 T&&参数),事情变得不是那么清楚了,因为规则对于左值参数有着特殊的对待,完整的故事将在条款26中讲述,但这里有一个概要的版本。

  • 如果expr是一个左值,T和ParamType都被推导为一个左值的引用
  • 如果expr是一个右值,使用通常情况下的类型推导规则

例如

template<typename T>
void f(T&& param); // param现在是一个万能引用(universal reference)
int x = ; // 和之前一样
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是一个lvalue, 所以T是const int&,
// param的类型也是const int&
f(); // 27是一个rvalue, 所以T是int,
// param类型是int&&

条款26精确的介绍了为什么这些例子会是这样,但关键是类型推导对于模板的参数是万能引用(univsersal references)和参数是左值或右值时规则是不同的,当使用万能引用(univsersal references)的时候,类型推导规则会区别左值和右值,而这从来不会发生在非万能(例如,普通)的引用上。

第三种情况:ParamType的类型既不是指针也不是引用

当ParamType的类型既不是指针也不是引用的时候,我们是按照传值的方式进行处理的

template<typename T>
void f(T param); // param现在是按值传递的

这意味着param将会是传递过来的对象的一个拷贝,一个全新的对象,事实上,param是一个全新的对象控制导出了T从expr中推导的规则

  • 像之前一样,如何expr的类型是一个引用,忽略引用的部分
  • 如果在expr的引用性被忽略之后,expr带有const修饰,忽略const,如果带有volatile修饰,同样忽略(volatile对象是不寻常的对象,他们通常仅被用来实现设备驱动程序,更多的细节,可以参照条款42)

因此

int x = ;        // 和之前一样
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代表了常量的对象,param也并不是常量的,这是讲的通的,因为parm是和cx,rx完全独立的对象,它是cx和rx的一个拷贝,事实上cx和rx不能被修改和param是否能被修改没有任何的关系,这就是为什么expr的常量性在推导param类型的时候被忽略了,因为expr不能被修改并不意味着它的拷贝也不能被修改。

注意到const仅仅在按值传递的参数中被忽略掉是很重要的,像我们看到的那样,对于指向常量的引用和指针来说,expr的常量性在类型推导的时候是被保留的,但是考虑下面的情况,expr是一个指向const对象的常量指针,并且expr按值传递给一个参数,

template<typename T>
void f(T param); // param是按值传递的
const char* const ptr ="Fun with pointers";
// ptr是一个指向常量对象的常量指针ptr is const pointer to const object
f(ptr); // 实参类型是const char * const

这里,乘号右侧的const将ptr声明为const意味着ptr不能指向一个不同的位置,也不能把它设为null(乘号左侧的const指ptr指向的字符串是const,因此字符串不能被修改),当ptr别传递给f的时候,指针按位拷贝给param,因此,指针本身(ptr)将是按值传递的,根据按值传递的类型推导规则,ptr的常量性将被忽略,param的类型被推导为const char*,一个可以修改所指位置的指针,但指向的字符串是不能修改的,ptr所指的常量性在类型推导的时候被保留了下来,但是ptr本身的常量性在通过拷贝创建新的指针param的时候被忽略掉了。

数组参数

上面这些已经覆盖了模板类型推导的主流部分,但是还有一些边边角角的地方值得我们了解,数组的类型和指针的类型是有不同的,即使他们有的时候看起来是可以互相交换的,这个错觉的主要贡献来源于此,在很多环境中,数组会退化为指向数组第一个元素的指针,这种退化允许下面的代码通过编译。

const char name[] = "J. P. Briggs"; // name的类型是
// const char[13]
const char * ptrToName = name; // 指向数组的指针

这里,const *char的指针ptrToName被name实例化,而name的类型是const char[13],一个13个元素的常量数组,二者的类型(const char*和const char[13])是不同的,但是因为存在数组到指针间的退化规则,上面的代码是可以通过编译的。

但是如果数组通过传值的方式传递给一个模板的时候,会发生什么呢?

template<typename T>
void f(T param); // 模板是参数是按值传递的 f(name); // T的推导结果是?

我们首先应该注意到函数的参数中是不存在数组类型的参数的,是的,下面的语法是合法的

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],是的,这个语法看起来是有毒的(looks toxic),但是从有利的方面看,知道这些将会奖励你那些别人得不到的罕见的分数(knowing it will score you mondo points with those rare souls who care)。

有趣的是,声明一个指向数组的引用能够让我们创建一个模板来返回数组的长度。

template<typename T, std::size_t N>        // 在编译期间
constexpr std::size_t arraySize(T (&)[N]) // 返回一个数组
{ // 的大小
return N; // N是一个常量
}

注意到constexpr的使用(参见条款14)让函数的结果在编译期间就可以获得,这就可以让我们声明一个数组的长度和另一个数组的长度一样

int keyVals[] = { , , , , , ,  }; // keyVals有
// 7元素
int mappedVals[arraySize(keyVals)]; // mappedVals
// 也是这样

当然,作为一个现代C++的开发人员,你应该很自然的使用std::array而不是内置的数组

std::array<int, arraySize(keyVals)> mappedVals; // mappedVals'
// 大小是7

函数参数

数组不是C++中唯一一个可以退化为指针的实体,函数类型也可以退化为指针,我们讨论的任何一个关于类型推导的规则和对数组相关的事情对于函数的类型推导也适用,函数类型会退化为函数的指针,因此

void someFunc(int, double); // someFunc是一个函数;
// 类型是void(int, double)
template<typename T>
void f1(T param); // 在函数f1中,参数是按值传递的
template<typename T>
void f2(T& param); // 在函数f2中,参数是按引用传递的
f1(someFunc); // 参数被推导为指向函数的指针 // 类型是void (*)(int, double)
f2(someFunc); // 参数被推导为指向函数的引用
// 类型是void (&)(int, double)

事实上,这和数组并没有什么不同,但是如果你正在学习数组到指针的退化 ,你还是应该同时了解一下函数到指针退化比较好。

所以,到这里你应该知道了模板类型推导的规则,在最开始的时候我就说他们是如此的简单明了,事实上,对于大多数规则而言,也确实是这样的,唯一可能会激起点水花的是在使用万能引用(universal references)时,左值有着特殊的待遇,甚至数组和函数到指针的退化规则会让水变得浑浊,有时,你可能只是简单的抓住你的编译器,”告诉我,你推导出的类型是什么“,这时候,你可以看看条款4,因为条款4就是讲述如何劝诱你的编译器这么做的。

请记住:

  • 当模板的参数是一个指针或是一个引用,但不是一个万能引用(universal reference)时,实例化的表达式是否是一个引用将被忽略。
  • 当模板的参数是万能引用(universal reference)时,左值的实参产生左值的引用,右值的实参产生右值的引用。
  • 模板的参数是按值传递的时候,实例化的表达式的引用性和常量性将被忽略。
  • 在类型推导期间,数组和函数将退化为指针类型,除非他们是被实例化为相应的引用。