如果你正在使用一套复杂的系统,在没有明白其原理的情况下也能够正常使用,你可以认为这套系统设计的非常不错。按照这个思路,C++的模版类型推导绝对是巨大的成功。虽然这些使用模版的大部分程序员都很难描述出这些类型是怎么推导的,但是仍有数百万的程序员已经在使用模版来给函数传递参数并且得到正确的结果。
如果你也是其中之一,我将给你带来一个好消息和坏消息。好消息是C++的模版编程是现代C++编程中最酷炫的一个特性的基础:auto 关键字。如果你很喜欢C++98中模版类型推导,那么你也将同样喜欢 C++ 11 中 auto 的推导。而坏消息就是,auto 的推导规则看上去不如模版来的直观。因此,深刻理解 auto 所建立的模版类型推导十分的重要,它涵盖了你需要了解的知识点。
下面的伪代码就是我们所想要的模版函数的样子
template<typename T>
void f(ParamType param);
函数的调用看上去是这样的:
f(expr); // 调用 f 函数并传递 expr 参数
在编译过程中,编译器会将 expr 推导出两种类型,一种是 T,一种是 ParamType。大多数情况下,这两种类型是不相同的,因为 ParamType 经常会用到一些修饰符,比如:const 或者 引用符。例如,如果一个模板声明如下:
template<typename T>
void f(const T& param); // ParamType 是 const T&
int x = 0; // 我们需要这样调用它
f(x) // 调用 f 函数并传递 int 型的参数
T 被推导成了 int, 但是 ParamType 被推导成了 const int&
一般很自然的会认为 T 类型的推导和函数形参的类型是一样的,就像最上面那个例子 T 的类型就是 expr 的类型。而从上面那个范例来看,x 是 int 类型,并且 T 也被推导成了 int 类型,但其实不是每次都是这样的。T 类型的推导不仅依赖于 expr 的类型,而且也依赖于 ParamType。下面给出3个事例,事例的代码环境如下:
template <typename T>
void f(ParamType param);
f(expr); // 通过 expr 的类型推导 T 和 ParamType 的类型
Case 1:参数类型是指针或者引用类型,但不是 Universal Reference
首先,这里先解释一下通用引用类型(Universal Reference),通用引用,反正我是这么叫它的,一般我会叫英文名。这里的 Universal Reference 就是 && ,一般表示右值引用,但是有时候它也能表示左值引用,鉴于这种不稳定的状态,所以起名为 Universal Reference,具体的解释会在 Item 24 中详细解释。
最简单的情况就是当 ParamType 是引用或者指针类型,但不是 Universal Reference,那么推导规则如下:
- 如果 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& 类型
那么调用了函数 f 以后类型的推导如下
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 value),T 自然就被推导成 const int,然后 param 顺理成章推导成了 const int&,这非常重要。当把一个常量对象传递给一个引用类型时,他们肯定希望这个对象不能被修改,比如,将参数定义为引用常量。所以传递 const 对象给 T& 的模板参数是安全的,在推导成 T 的过程中,对象的常量部分(const)成了 T 的一部分。
在第三个例子中,虽然 rx 的类型是一个引用,T 却没有被推导成引用,因为 rx 的引用部分在推导过程中被忽略了。
如果我们将 f 函数的参数从 T& 修改为 const T&,小小的变动却会带来大大的变化。cx 和 rx 的常量部分和上面一样,没有改变。但是我们现在假设 param 是引用常量,那么 const 就不再是 T 的一部分了,详情看下方代码:
template <typename T>
void f(const T& param); // param 现在是引用常量了
int x = 27; // x 是 int 类型
const int cx = x; // cx 是 const int 类型
const int& rx = x; // rx 是 const int& 类型
f(x); // T 是 int,param 是 const int&
f(cx); // T 是 int,param 是 const int&
f(rx); // T 是 int,param 是 const int& 由于函数参数已经定义了 const,因此 T 不再需要包含 const
和之前一样,rx 的引用部分在 T 类型的推导过程中被忽略了。
如果 param 是一个指针(或者是一个指向 const 对象的指针),而不是一个引用,那么本质上还是一样的。
template <typename T>
void f(T* param); // param 现在是指针了
int x = 27; // x 是 int 类型
const int *px = &x; // px 是 指向 const int 的指针
f(&x); // T 是 int,param 是 int*
f(px); // T 是 const int,param 是 const int*
看到这里,你可能觉得想打个哈欠,因为C++的类型推导对于引用和指针工作起来就是这样,规则就是这么呆板无趣,一切都是这么显而易见,而这就是我们想要的类型推导。
Case 2:参数类型是一个 Universal Reference
函数参数使用 Universal Reference,推导的结果就没有那么明显了。比如参数定义成右值引用(函数参数一般是 T ,Universal Reference 会定义成 T&&),但是当传入的参数是一个左值行为就不一样了。完整的解释在 Item 24,这里先粗浅的看一下规则:
- 如果 expr 是一个左值,那么 T 和 param 都会推导成左值引用。这个双重规则不太容易理解。首先,在模版类型推导过程中,只有一种情况会把 T 推导成引用。其次,虽然 param 的类型通过语法定义成一个右值引用,但是却推导成了左值引用。
- 如果 expr 是一个右值,那么事例 1 中的规则将适用在这里。
请看下面的代码范例:
template <typename T>
void f(T&& param); // 参数现在是 Universal Reference
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&&
Item 24 会详细的解释这些规则,这里的重点是类型的推导在参数是 Universal Reference 的情况下,输入左值或者右值所推导出来的类型是不一样的,区别仅仅在于传入的参数是左值还是右值,而这种区别在非 Universal Reference 引用情况下是不会发生的。
Case 3:参数类型既不是指针也不是引用
当函数参数类型既不是指针也不是引用的时候,传递方式就是值传递。
template <typename T>
void f(T param); // param 现在是值传递
这意味着首先要构建一个对象并从实际参数中拷贝一份数据,而传入的参数将会是一个新对象,而这个新对象将会影响如何通过 expr 来推导 T 类型的过程:
- 和以前一样,如果 expr 的类型是引用,那么忽略引用部分。
- 在忽略引用部分以后,如果 expr 是 const,同样忽略掉。如果是 volatile ,一样忽略掉。(volatile 不太常用,这个功能常用在实现类似驱动之类很底层的功能,具体内容,看 Item 40),因此:
int x = 27;
const int cx = x;
const int& rx == x;
f(x); // T 是 int, param 也是 int
f(cx); // T 是 int, param 也是 int
f(rx); // T 是 int, param 也是 int
虽然 cx 和 rx 都是常量,但是 param 却不是,这是因为 param 是从 cx 或者 rx 处完全拷贝过来的,与 cx 或者 rx 是独立的,事实上 cx 或者 rx 是不会因为 param 的改变 而发生改变的,因此 cx 和 rx 仍然是常量(和volatile),所以在类型推导中可以忽略掉。
在值传递过程中,const(和 volatile)是要被忽略掉的,这点很重要。而不像引用常量(reference to const)或者指向常量的指针(pointer to const)那样保留常量部分。思考以下情况,如果 expr 是指向常量的常量指针(const pointer to const object),expr 通过值传递,请看下面代码:
template <typename T>
void f(T param); // 值传递参数
const char* const ptr = "Fun with pointers"; // 指向常量的常量指针(const pointer to const object)
f(ptr); // 传入的是 const char * const
这里,星号(*)右边的 const 定义 ptr 是常量,ptr 是一个指针类型,因此它不能再指向其他的对象,也不能被制空(nullptr)。星号左边的 const 表明 ptr 指向的对象是一个常量,因此你不能改变上面那个字符串里面的内容。当 ptr 传递给 f,指针就被复制了一份,也就是指针部分其实是值传递。它遵守值传递的规则,类型将被推导为 const char*,一个可以修改指向的指针。指向的内容部分的 const 被保留下来了,所以仍然不能修改指针指向的对象里的内容。
数组参数
在模版参数推导中,大部分情况在前面已经提到了,不过这里还有一种情况需要关注以下,那就是数组类型,虽然有些时候数组和指针是同样的东西,但是数组还是不同于指针。造成这种情况的一个主要原因是在许多情况下,数组可以退化成第一个元素的地址,编译器允许这种退化,他的代码表现如下:
const char name[] = "J. P. Bridggs; // 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 会被推导成什么类型呢?
数组作为函数参数时类型的定义是这样的
void myFunc(int param[]);
但是,数组的定义也可以看作是指针,这也意味着我们可以这样定义,两者是一样的
void myFunc(int* param); // 和上面的定义是一样的。
这种数组和指针的等效是从 C 语言哪里传承过来的,它给人一种错觉就是数组和指针是一样的东西。
因为数组的声明被看作是指针类型,函数模版在传入数组类型时会推导成指针的值传递,所以调用函数 f 时,T 的类型将推导为 const char*。
f(name); // name 是一个数组,而 T 被推导成 const char*
这里又出现了新的状况,尽管函数参数不能声明成真正的数组,但是可以声明数组的引用!所以如果把参数类型改为引用会如何呢?
template <typename T>
void f(T& parame) // 模版加引用
f(name); // 调用函数并传递数组
这是,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;
}
如 Item 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,它的功能是让编译器生成更加高效的代码,详情查看 Item 14。
函数参数
数组不是唯一在 C++ 中会退化成指针的类型。函数类型也可以退化成函数指针,并且前面数组的指针退化方案也适用于函数的指针退化。如下方代码:
void someFunc(int, double); // someFunc 是一个函数,它的类型是 void(int, double)
template <typename T>
void f1(T param); // f1,参数是通过值传递的
template <typename T>
void f2(T& parame); // f2,参数是通过引用传递的
f1(someFunc); // param 推导成指向函数的指针,类型是 void(*)(int, double)
f2(someFunc); // param 推导成指向函数的引用,类型是 void(&)(int, double)
这在实际写代码过程中没有什么区别,但是如果你理解数组到指针的退化,那么你应该也能够明白函数到指针的退化。
因此到这里,你应该明白了模板类型自动推导的规则,并且就像我一开始所说的,这些规则都很直观。比较特殊的处理,像在 Universal Reference 的情况下却被推导成了左值引用,这点确实容易把人弄糊涂,然而,数组退化成指针和函数指针问题可能会弄得你更糊涂。有时候你很想揪住编译器问它,你到底推导出了什么类型?这时候请阅读 Item 4。
重点总结:
- 在模板类型的推导过程中,参数的引用会被忽略掉。
- Universal Reference 的推导过程中,左值参数会有特殊的操作。
- 当推导出的类型是值传递,参数的 const 和 volatile 属性将被忽略。
- 在模板类型推导过程中,参数如果是数组或者函数,将被退化成指针,除非用来初始化一个引用对象。