Item 1: 理解模板类型推导

心血来朝的就想翻译一下Effective Modern C++,非严谨翻译,大伙儿凑合着看吧。
记得刚接触C++是考上大学那会儿,为了更好的融入大学生活,特意在那个开学前的暑假骑了一小时的自行车到隔壁镇,风尘仆仆的进到当时镇上唯一的一家网吧。战战兢兢的打开电脑,学会了CS(Counter Strike_)。学校第一学期就开了C++,刚摸上电脑就接触这么高深莫测的语言真事有够胆战心惊的,这C++的课一开就是一年,结果一年下来,面向对象啥的听起来依旧如天书。
先来看看历史吧

  • C++98(哥当年学的)只有一种类型推导:函数模板
  • C++11修改了这个规则,添加了两个新的:auto以及decltype
  • C++14继续扩展了auto及decltype的应用语境

自打上C++14,这语言就越发的灵活了,有太多的场景会出现类型推导,理解类型推导是怎么进行的就变的尤为重要。
这一章节会解释一下模板类型推导是遵循怎么个规则,auto/decltype又怎么在这个基础上进行类型推导。除此之外,我们还会教你怎么诱骗编译器按你想要的结果去工作,是不是很牛!

Item 1: 理解模板类型推导

无数的码农们每天都在愉快的使用着模板类型推导(“理所应当嘛,你你编译器当然得知道我说的是什么类型了,是吧”),然而对于内部怎么工作就全然不必去关心。
如果你正好是这些愉快码农中的一员,那我这里又一个好消息还有一个坏消息(你要先听哪一个?)。好消息是模板的类型推导和auto的类型推导系出同宗,如果你之前用C++98的template用的很愉悦,那么等你切换到C++11以后,auto的类型推导似乎是一样一样的。坏消息是模板类型推导规则被应用在auto类型推导的场景中时,往往不如模板类型推导那么直观,所以有必要去真正理解一下类型推导的规则。

先来看一段伪代码吧,思考这样的一个函数模板

  template
  void f(ParamType param);

可以这样调用这个函数

  f(expr); // call f with some expression

编译过程中,编译器依据expr来推导两个类型,一个是T,另一个是ParamType. 这两个类型经常是不一样的,因为ParamType经常会有一个比如说const的修饰符。
来看个例子,如下的定义模板函数

  template
  void f (const T& param); // param type is const T&

并且这样调用这个函数

  int x = 0;
  f(x); //call f with an int

T被推导成int,ParamType推导成int&
这个例子里面T的类型就是函数入参expr的类型,x是int,自然能推导出T的类型也是int。但是并不是所有的时候都这样,T的类型推导有时候不单单取决于函数入参expr的类型,它还依赖于ParamType的类型。有如下三种场景

  • ParamType是一个指针或者引用类型,但不是一个全局引用(全局引用在item24中有描述。这会儿你只要知道有这样一种引用,它区别于左值引用或右值引用)
  • ParamType是一个全局引用
  • ParamType既不是指针也不是引用

我们这里有三种类型推导的场景,都以如下的方式定义函数模板并调用

  template
  void f (ParamType param);
  f(expr); // deduce T and ParamType from expr

Case 1: ParamType是一个引用或指针,但不是全局引用

最简单的场景是ParamType是一个引用或者指针类型,但不是全局引用。这个时候类型推导是这么工作的

  1. 如果函数参数expr的类型是引用,忽略参数的引用特性
  2. 通过匹配expr的类型,获取ParamType的类型进而确定T的类型
    例如,这是我们的函数模板
  template 
  void f(T& param); // param is a reference 

我们定义如下的变量

  int x = 27;             // x is an int
  const in cx = x;    // cx is an const int
  const int& rx = x; //rx is a reference to a const int

当函数被调用时,类型推导成下面这样

  f(x);     // T is int, param`s type is int&
  f(cx);   // T is const int, param`s type is const int&
  f(rx);    // T is const int, param`s type is const int&

第二个和第三个函数调用中,由于cx和rx指定成const类型,推导出T是const int,从而产生了参数类型是const int&。 这一点对于函数调用者来说很重要。当传 递一个const对象给一个引用类型的参数,函数调用者期望这个对象维持const特性(不可以修改)。例如,期望这个函数参数是一个const引用。这就是为什么传递一个const对象给这样的模板函数(携带T&参数)是安全的,对象常量性也成为了类型T推导的一部分了。
第三个例子中,即使rx的类型是一个引用,推导出来T的类型依旧是一个非引用类型(non-reference)。这是由于在类型推导过程中,rx的引用性(reference-ness)被忽略了。
上述的这些例子都是左值引用类型,但是类型推导的规则对于右值引用参数同样有用。当然,只有右值实参能传递给右值引用参数,但是这个限制不会影响类型推导

如果我们修改了函数f的参数,从T&变成constT&,这时候发生了一点点改变。cx和rx的常量性依旧会得以保留。但是这个时候类型T就不会再有const特性了(不需要推导成const类型了)。

  template 
  void f(const T& param);   // param is now a ref-to const

  int x = 27;                        // as before
  const int cx = x;              // as before
  const int& rx = x;            // as before
  
  f(x);           // T is int, param`s type is const int&
  f(cx);         // T is int, param`s type is const int&
  f(rx);          // T is int, param`s type is const int&

和之前一样,rx的引用性(reference-ness)在类型推导的过程中忽略了

如果参数是变成了指针(或是一个指向const的指针),类型推导依旧遵循同样的规则

  template
  void f (T* param);        //param is now a pointer

  int x = 27;                    // as before 
  const int *px = &x;      // px is a ptr to x as a const int 

  f(&x);         // T is int, param`s type is int*
  f(px);         //  T is const int, param`s type is const int*

开始打盹儿了吧,因为c++的类型推导规则看起来是那么的理所应当的,大家会很自然的认为类型推导不就应该是那样的么。但当我们真正的一条条罗列出来所以然的时候一下就变的好枯燥了。

Case 2: ParamType是一个全局引用

对于模板函数参数是全局引用的场景(T&&),类型推导就不是那么显而易见了。这些参数往往被声明称右值引用(例如,一个函数模板的入参类型T,一个全局引用的声明方法是T&&),当左值参数传递进来时,这两种函数模板的行为是不一样的。在Item24中会详细描述,这里概述一下

  • 如果expr是一个左值,T和Paramtype都被推导成为左值引用
  • 如果expr是一个右值,使用通常情况下的推导规则
    举个例子
  template
  void f(T&& param);      // param is now a universal reference

  int x = 27;                // as before
  const int cx = x;      // as before
  const int& rx = x;    // as before

  f(x);       // x is lvalue, so T is int &
               // param`s type is also int&

  f(cx);    // cx is lvalue, so T is const int &
              // param`s type is also const int &

  f(rx);     // rx is lvalue, so T is const int&
              // param`s type is also const int&

  f(27);    // 27 is rvalue, so T is int
              // param`s type is int&&

很明显当使用全局引用的时候,类型推导区分左值参数和右值参数。对于non-universal引用来说,这是从未有过的,Item 24会详细的解释这个原因。

Case 3: ParamType既不是指针也不是引用

这里我们来说说传值调用

  template
  void f(T param);     //param is now passed by value

这里param是传入值的一个拷贝,一个全新的对象。param是一个全新对象的事实驱动T的类型推导规则

  1. 和之前一样,如果expr是引用类型,忽略入参的引用特性(reference-ness)
  2. 而后如果expr是const类型,一并忽略。如果是volatile类型,继续忽略(volatile不常用,详细参看Item40)
    所以
  int x = 27;              // as before
  const int cx = x;    //  as before
  const int& rx = x;  //  as before
  
  f(x)          // T`s and param`s type are both int
  f(cx)        // T`s and param`s type are again both int 
  f(rx)        // T`s and param`s type are still both int

注意到这里cx和rx虽然代表const值,但param是全新的对象(cx或rx的一个拷贝),它不是const,这就说的通了。这就是为啥expr的这些特性(constness/volatileness/etc.)在类型推导的过程中都被忽略了

这里要记住只有传值参数的时候才会忽略这些const等等。但是当考虑这么个case,expr是一个指向const对象的const指针,然后expr按值传递进函数。如下,

  template
  void f(T param);        // param is still passed by value

  const char* const ptr = "Fun with pointers"  // ptr is const pointer to const   object

  f(ptr);  //  pass arg of the type const char* const

ptr这里是一个const指针,不能指向别的地方了,同样也不能设置成null。当ptr作为函数调用参数时,指针自身(ptr)会按值传递,指针(string的地址)复制到了param。ptr的常量性(constness)会被忽略掉,这时候param的类型推导出来是const char*,新的指针param可以指向不同的位置了,但是当前param指向的内容是不能改变的(这也很显而易见的)

数组作为参数

数组类型有别于指针类型,虽然它们有时候看起来可以互换。造成这种假象的原因是,很多场景下,数组会退化成指向数组头的指针。正因为有这种退化,使得下面代码能编译通过

  const char name[] = "J.P.Briggs"    //  name`s type is const char[13]
  
  char char * ptrToName = name;    //  arrary decays to pointer

这里的ptrToName被初始化成name,name是一个const类型的数组。

但是当传递一个数组给传值调用的模板函数的时候会发生些啥?参看下面的伪代码。

  template
  void f(T param);    // template with by-value parameter
  
  f(name);      // what types a deduced for T and param?

发现了没,函数的参数好像并没有数组类型嘛!
我们来看一个看起来有点儿像数组类型作为入参的例子。下面的函数定义就是合法的。

  void myFunc(int param[]);

但是这里的参数param是被认作为一个指针的,意味着myFunc和下面定义的函数是等价的

  void myFunc(int* param);    // same function as above

正是有上述例子的存在,才使得数组和指针等价这个假象得以被很多人接受。
由于数组参数声明退化成指针参数,当数组作为一个值传递给一个模板函数,推导出来的类型应该是指针类型,意味着下面的代码中T被推导成const char*。

  f(name);    // name is array, but T deduced as const char*

接下来我们有一种曲线救国的方法(见证奇迹的时刻),虽然函数不能声明一个数组类型的参数,但是可以声明一个数组的引用类型参数。

  template
  void f(T& param);    // template with by-reference parameter

然后传递一个数组给这个函数

  f(name);      // pass array to f

这个时候T的类型就真正的变成一个array。这个类型还隐含了数组的大小。这个例子里面f的参数类型是const char(&)[13].

有意思的是,声明一个指向数组的引用使得我们可以创建这样一个模板函数,这个模板可以推导一个数组包含的元素个数。

  // return size of an array as a compile-time constant. (The
  // array parameter has no name, because we care only about
  // the number of elements it contains.)
  template                        
  constexprstd::size_t arraySize(T (&) [N]) noexcept 
  {                          
      // see info below on constexpr and noexcept
      return N
  }

正如Item15中描述的,声明这样的函数constexpr,使得在编译过程中就能获得函数运行结果。所以下面的代码实现就变的可行了,我们可以定义一个新的数组,这个数组的大小和另一个数组一样。

  int keyVals[] = { 1, 3, 7, 9, 11, 22, 35};  //keyVals has 7 elements

  int mappedVals[arraySize(keyVals)];  // so does mappedVals

当然,你可能更加喜欢std::array来定义数组。

  std::array mappedVals;// mappedVals size is 7

函数作为参数

C++里面,函数同样也可以退化成函数指针,前面讨论的那些类型推导规则这里同样适用 。

  void someFunc(int, double);    // someFunc is a function;
                                                  // type is void(int, double)
  template
  void f1(T param) ;      // in f1, param is passed by value

  template
  void f1(T& param);   //  in f2, param passed by ref

  f1(someFunc);    // param deduced as ptr-to-func
                             // type is void(*)(int, double)
  f2(someFunc);    // param deduced a ref-to-func;
                             // type is void(&)(int, double)

到这儿你就知道这些模板类型推导的规则了,所以吧,这些规则看上去就是这么的简单直接。唯一的污点就是universal references场景下的左值参数,还有退化为指针的规则。那能不能更简单点儿,抓住编译器然后命令它“你都给我推导成啥啥类型?”,看看Item 4吧,你会找到答案的

记住以下几点

  • 模板类型推导时,忽略引用类型参数的引用性(reference-ness)
  • 给universal reference参数进行类型推导时,左值要特别对待
  • 传值参数的类型推导,入参的诸如所有const /volatile的特性都会忽略
  • 模板板类型推导过程中,数组或函数做微参数时会退化成指针,除非模板函数的参数是引用类型

你可能感兴趣的:(Item 1: 理解模板类型推导)