[译] Item 1: Understand Template Type Deduction

在使用一个复杂的系统时,我们可以不用知道具体的细节。从这个方面来说,C++ 的模板类型推导是很成功的。数以百万的程序员都使用过模板,即使他们可能很难描述清楚这些类型是如何推导的。

如果你是其中的一员,我有一个好消息,也有一个坏消息。好消息是,模板类型推导是最引人注目的 C++11 新特性 auto 的基础。如果你很清楚 C++98 中的模板类型推导,那么你会很容易明白 C++11 中的 auto。坏消息是,当使用 auto 时,有些类型推导会变得没有那么直观。因此完全掌握类型推导的规则是非常有必要的。Item 1 会介绍你必须知道的类型推导规则。

我们从一小段伪代码开始:

1
2
template <typename T>
void f(ParamType param);

调用如下:

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

在编译期间,编译器使用表达式 expr 来推导两个类型:一个是 T 另一个是 ParamType。这两个类型通常是不一样的,因为 ParamType 通常是含有修饰的,比如 const 或者引用。举个例子,如果模板的声明如下:

1
2
template <typename T>
void f (const T& param);    // ParamType is const T&

然后调用如下:

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

类型 T 会被推导为 int,而 ParamType 被推导为 const int&

将类型 T 推导为传入的参数类型是很自然的,即 T 的类型就是 expr 的类型。在上面的例子中,xint 类型,T 被推导为 int 类型。但是有时候却不是这样的。类型推导不仅仅依赖于表达式 expr 的类型,同时还依赖于 ParamType 的形式。有下面三种情况:

  • ParamType 是一个指针或者引用类型,但是不是全局引用。(全局引用将会在 Item 24 中讲述。现在你只需要知道他不同于引用,也不同于右值引用即可)。
  • ParamType 是一个全局引用。
  • ParamType 即不是指针也不是引用

因此我们需要考虑上面描述的三种场景来一一讲述。每个都基于下面的伪代码,

1
2
3
4
template <typename T>
void f(ParamType param);

f(expr);    // deduce T and ParamType from expr

情况1:ParamType 是一个指针或者引用,但不是右值引用

这是最简单的一种情况,在这种情况下,类型推导的工作如下:

  1. 如果 expr 是一个引用类型,忽略引用部分。
  2. 然后模式匹配 expr 的类型,根据 ParamType 的类型来决定 T 的类型。

举个例子,如果我们的模板是下面这样,

1
2
template <typename T>
void f(T& param);    // param is a reference

然后我们有如下的变量声明,

1
2
3
int x = 27;           // x is an int
const int cx = x;     // cx is a const int
const int& rx = x;    // rx is a reference to x as a const int

paramT 的类型推导结果如下,

1
2
3
4
5
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&

在第二个以及第三个调用中,注意由于 cxrxconst 值,所以 T 被推导为 const int 类型,这对调用者来说是至关重要的。当调用者传入一个 const 对象给一个引用参数时,参数仍然是不可更改的。比如,参数是一个 const 引用。这也是为什么传入一个 const 对象给一个接受 T& 类型的模板参数是安全的:const 约束直接变为了 T 类型的一部分。

在第三个调用中,注意虽然 rx 的类型是引用,T 仍然被推导为非引用。这是由于类型推导过程中 rx 的引用修饰被忽略掉了。

上面的例子都是左值引用参数,但其实右值引用的类型推导和上面是完全一样的。当然,只有右值类型的参数才能传递给右值引用,但他对类型推导完全没有影响。

如果我们将 f 的参数类型由 T& 更改为 const T&,情况会有一些变化,但非常好理解。cxrxconst 修饰仍然有用。但由于现在假定 param 的类型是一个 const 引用,现在不再需要将 const 推断为类型 T 的一部分了:

1
2
3
4
5
6
7
8
9
10
11
12
template <typename T>
void f(const T& param);    // param is now 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 的引用修饰在类型推导时会被忽略。

如果 param 是一个指针(或者 const 指针),类型推导基本是一样的。

1
2
3
4
5
6
7
8
9
template <typename T>
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*

到目前位置,你可能已经哈欠不断了,因为对于指针和引用的类型推导,规则这么简单,所有的结果都是显而易见的,就和你想象的完全一样。

情况2:ParamType 是全局引用

当模版参数是全局引用时,类型推导就没有那么明显了。声明的形式有点像右值引用(全局引用的类型声明形式是 T&&),但是当传入的参数是左值类型时行为是不一样的。具体细节在 Item 24 中讲述,现在我们来看一个简单的版本:

  • 如果 expr 是一个左值,那么 TParamType 都会推导为左值引用。有两个不寻常的地方。第一,这是仅有的模板类型推导会将类型 T 推导为引用类型的情形。第二,尽管 ParamType 的类型声明使用了右值引用的符号,但却推导为了左值引用。
  • 如果 expr 是一个右值,会使用“正常的”(情况1)规则。

例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
tempalte <typename T>
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 therefore int&&

在 Item 24 中会详细解释为什么在上面的情形中类型推导是这样的。在这里我们的重点是,左值参数和右值参数对于全局引用来说,类型推导的规则是不一样的。这很特别的,当使用全局引用时,类型推导会区别对待左值参数和右值参数,这是在其他类型中不会发生的。

情况3:ParamType 即不是引用也不是指针

ParamType 既不是引用也不是指针时,我们来按值传递,

1
2
template <typename T>
void f(T param);          // param is now passed by value

这意味着 param 会做一个拷贝 —— 产生一个全新的对象。param 的类型推导遵循如下规则:

  1. 像上面一样,如果 expr 的类型是一个引用,那么引用类型会被忽略。
  2. 如果忽略了 expr 的引用修饰之后,expr 是一个 const,那么将 const 也忽略。如果有 volatile 修饰,也同样忽略。(volatile 修饰并不常用,通常只是在实现设备驱动时会用到。更多细节,参见 Item 40)。
1
2
3
4
5
6
7
int x = 27;              // as before
const int cx = x;        // as before
const int& rx = x;       // as before

f(x);                    // T's and param's types are both int
f(cx);                   // T's and param's types are again both int
f(rx);                   // T's and param's types are still both int

注意,即使 cxrx 都是 const 类型,param 的类型也不是 const。这是有道理的。param 是一个与 cxrx 无关的对象 —— 是一份拷贝。因此, cxrxconst 属性,不会影响 param。这就是为什么在类型推导过程中 exprconst(以及 volatile)修饰会被忽略的原因:expr 不可更改并不意味着它的拷贝不可以。

意识到 const(以及volatile)修饰仅仅在传值参数中会被忽略,是非常重要的。像前面看到的,对于 const 引用,或者 指向 const 对象的指针参数,exprconst 修饰是保留到类型推导中的。但是考虑以下情况,expr 是一个指向 const 对象的 const 指针,且 expr 是值传递给 param 的:

1
2
3
4
5
6
template <typename T>
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 type const char * const

在这里,右边(第二个)的 const 表示 ptr 不可更改。左边(第一个)表示 ptr 指向的常量字符串不可更改。当 ptr 传递给 f 时,ptr 值传递会产生一个拷贝,根据类型推导规则,ptrconst 修饰会被忽略掉,最终 param 类型为 const char*,即指向常量字符串的指针。ptr 所指向的内容的 cosnt 修饰被保留,而 ptr 本身的 const 修饰在值传递时被忽略。

数组参数

几乎所有主流的模板类型推导都包含它,但是还是有一些值得注意的地方。数组类型与指针类型是不同的,尽管他们有时可以转换。在很多时候数组是可以退化成指针的。下面的代码可以编译通过:

1
2
3
const char name[] = "J. P. Briggs";   // name's type is const char[13]

const char* ptrToName = name;         // array decays to pointer

在这里,ptrToName 作为一个 const char* 类型,使用了 const char[13] 类型的 name 来初始化。但是这两个类型是不同的,由于数组到指针的退化规则才使得代码能够编译通过。

但当我们将一个数组类型传递给一个值传递的模版参数时,会发生什么呢?

1
2
3
4
template <typename T>
void f(T param);               // template with by-value paramter

f(name);                       // what types are deduced for T and param ?

我们使用没有模板的情况先来观察,对,没错,下面的语法是合法的,

1
void myFunc(int param[]);

但是数组参数的声明会被退化成指针,这意味着它和下面的声明是完全一致的:

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

上面的退化是从 C 语言中继承而来的,他给我们造成了数组类型与指针类型是一样的这种幻觉。

由于数组参数的声明会被当作指针来看待,那么当模版参数是数组并且按值传递时,参数会被推导为指针类型。这意味着模板函数 f 的模板参数 T 会被推导为 const char *:

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

但是现在问题出现了,尽管参数不能是一个真正的数组类型,但是我们可以声明一个数组的引用。因此当我们更改模板函数 f 使他接受引用参数时,

1
2
template <typename T>
void f(T& param);        // template with by-reference parameter

然后我们传入有一个数组,

1
f(name);                 // pass array of f

类型推导会将 T 推导为数组类型,这个类型包括了数组的长度,在这个例子中,T 的类型被推导成为 const char[13],函数 f 的参数类型为 const char(&)[13]

有趣的是,它提供了一种推导数组元素个数的方式:

1
2
3
4
5
6
7
8
// 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 <typename T>                                // info
constexpr std::size_t arraySize(T(&)[N]) noexcept    // below on
{                                                    // constexpr
  return N;                                        // and
}                                                    // noexcept

与 Item 15 中解释的一样,声明一个 constexpr 函数,那么它的返回值在编译期就确定了。这就让用一个已知的数组去声明一个同样大小的数组成为可能:

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

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

当然,作为一个现代的 C++ 程序员,你会很自然的习惯于用 std::array 来代替内建的数组类型,

1
std::array<int, arraySize(keyVals)> mappedVals;   // mappedVals' size is 7

arraySize 声明时带着 noexcept,这帮助编译器生成更好的代码。详细内容,参见 Item 14。

函数参数

在 C++ 中,不是只有数组才会退化成指针。函数类型可以退化成函数指针,上面我们讨论的关于数组的类型推导规则也适用于函数,只是退化成函数指针而已。

1
2
3
4
5
6
7
8
9
10
11
12
13
void someFunc(int, double);    // someFunc is a function;
                               // type is void(int, double)

template<typename T>
void f1(T param);              // in f1, param passed by value

template<typename T>
void f2(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 as ref-to-func;
                               // type is void (&)(int, double)

在日常的使用中,这几乎没有什么区别,但是如果你已经知道了数组会退化成指针,那么你也会很自然的知道函数到指针的退化。

这就是模板类型推导的规则。大部分情况下它非常简单。左值在传递给全局引用时的推导有些稍微不同,但是数组和函数类型的退化更让人迷惑。有时候你会想抓住编译器问,“告诉我你在推导什么类型!” 这个时候,你需要 Item 4 中的内容,它会告诉你如何查看正在推导的类型。

需要记住的

  • 在模板类型推导时,引用类型的参数会被当作非引用,即引用限定会被忽略。
  • 当推导类型是全局引用时,左值会被特殊对待。
  • 当推导值传递参数时,const, volatile 会被忽略。
  • 在类型推导时,数组以及函数名会退化为指针,除非它们被用来初始化引用变量。

Comments