C++之参数类型推导

  参数类型推演算是Modern C++中的重点内容,他们会在函数模板、auto、decltype中被用到,不过这东西确实很隐晦,想要搞透他们也的确不容易,经常被弄得头晕晕的。但是这种硬骨头不啃也不得行,绕过他想在Modern C++中进行简洁、高效编程几乎是不可能的,尤其当你想偷懒进行某些通用编程的时候。

一、参数推演概述

  参数推演指的是编译器根据实参推导出相关模板参数的行为,这种行为不同于其他脚本类动态语言发生在运行时执行推演,而是在编译器编译的期间发生的。例如我们常见的对于函数模板的情形:

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

  上面为模板函数定义形式,下面为实际调用表达式,在编译时候编译器需要推导出TParamType的具体类型,这里用两个名字表示是因为两者的类型通常是不相同的,因为ParamType通常还会带有一些额外的修饰符,比如:const、volatile、reference、pointer等。

1.1 模板参数推演规则

  (1) ParamType是指针或者引用类型,但是不是universe reference类型

此时如果expr是引用类型,则将引用的部分忽略掉;然后将上一步去除引用的剩余部分和ParamType进行匹配,以确定T的类型。
这里也就是意味着底层的const、volatile修饰符将会被保留。

  例如对于如下使用:

1
2
3
4
5
6
template<typename T>
void f(T& param){}
int x = 27; f(x); // T -> int, param -> int&
const int cx = x; f(cx); // T -> const int, param -> const int&
const int& crx = x; f(crx); // T -> const int, param -> const int&

  在面的例子里,即使模板参数的类型是T&,也可以传递给他一个const对象来调用,此时const的属性被推导至T参数中;最后一个调用在推导的时候首先会将引用剔除掉,所以得到的T类型和第二个调用是一样的。
  如果将T&改为const T&,则得到的情况也是类似的:

1
2
3
4
5
6
template<typename T>
void f(const T& param){}
int x = 27; f(x); // T -> int, param -> const int&
const int cx = x; f(cx); // T -> int, param -> const int&
const int& crx = x; f(crx); // T -> int, param -> const int&

  因为此时的模板参数已经是const T&了,所以实参的const属性就不需要再被推导为T的组成部分,因此T都直接被推导为int。
  如果将引用换成指针类型,情况同引用情形是类似的:

1
2
3
4
5
template<typename T>
void f(T* param){}
int x = 27; f(&x); // T -> int, param -> int*
const int* px = &x; f(px); // T -> const int, param -> const int*

  (2) ParamType是universe reference类型
  universe reference的形式如T&&(注意区别于右值引用,这是在T未知时候需要推导的上下文中),则:

如果expr是左值类型,则T和ParamType都被推导为左值引用类型;如果expr是右值类型,则进行上述Case 1的类型进行正常推导。

1
2
3
4
5
6
7
template<typename T>
void f(T&& param){}
int x = 27; f(x); // T -> int&, param -> int&
const int cx = x; f(cx); // T -> const int&, param -> const int&
const int& crx = x; f(crx); // T -> const int&, param -> const int&
f(28); // T -> int, param -> int&&

  上述结果的产生,主要是C++中一个引用折叠机制的作用导致,只有右值的universe reference才会折叠成右值引用。

  (3) ParamType既不是指针,也不是引用类型

如果ParamType既不是指针也不是引用,则当做pass-by-value的方式处理,所有传递进来的参数都会做一个按值拷贝的方式产生一个同源对象相独立的新的实体对象;
此时如果调用的expr是一个引用,则其引用部分也被忽略掉;上述忽略引用处理后的结果如果还有const和/或volatile,则也将其忽略掉,即顶层的cv修饰符会被忽略掉。

比如:

1
2
3
4
5
6
7
8
9
template<typename T>
void f(T param){}
int x = 27; f(x); // T -> int, param -> int
const int cx = x; f(cx); // T -> int, param -> int
const int& crx = x; f(crx); // T -> int, param -> int
const char* const ptr = "hello world!";
f(ptr); // T-> const char* , param -> const char*

  所以无论调用的参数是否是const的,其const属性都被移除了,因为实际参数是一个按值拷贝的结果,跟调用实参是相互独立的对象。对于最后一个情形,ptr本身的修饰符被忽略了,但是其所指对象的const属性(底层cv)被保留了下来。

1.2 数组类型的模板参数

  在很多情况下,数组类型和指向数组首个元素地址的指针两种身份是可以互换的,这种情况被称之为类型退化。该特性在C和C++中是相同的,甚至对于没有引用类型的函数模板也是成立的。但是一旦模板参数包含引用类型,而将数组类型当做实参用于函数模板,情况就不一样了:其模板参数将会被推导为数组类型,而且包含数组的维度这类的完整信息!

1
2
3
4
5
6
7
8
9
template<typename T>
void f1(T param){}
template<typename T>
void f2(T& param){}
const char name[] = "BB";
f1(name); // T -> const char*, param -> const char*
f2(name); // T -> const char[3], param -> const char(&)[3]

  因为通过引用可以推导出数组的完整类型,甚至包含了维度信息,所以利用这个小特性可以求取任意数组的长度信息(虽然感觉也没多大用处):

1
2
3
4
template<typename T, std::size_t N>
constexpr std::size_t array_size(T(&)[N]) noexcept {
return N;
}

二、auto类型推导

  auto类型推导和上面的模板推导相似,此处的auto扮演了上面的T,而整个类型指示部分则扮演了上面的ParamType。auto声明的变量必须立即给出初始化式,以便编译器正确推导出它的实际类型,并在编译的时候讲auto占位符替换为真正的类型。和上面类似的是,整个类型描述符也可以分为三种情况:

(a). 类型描述符是一个指针或者引用,但是不是universe reference,底层cv修饰符会被保留下来;
(b). 类型描述符是一个universe reference;
(c). 类型描述符既不是指针也不是引用类型;

  所以我们根据上的分类,我们同样可以得到如下推导结果:

1
2
3
4
5
6
7
auto x = 27; // x -> int
const auto cx = x; // cx -> const int
const auto& crx = x; // crx -> const int&
auto&& uref1 = x; // uref1 -> int&
auto&& uref2 = cx; // uref2 -> const int&
auto&& uref3 = 23; // uref3 -> int&&

  类似的,对于比较麻烦的数组类型,我们可以得到:

1
2
3
const char name[] = "BB";
auto arr1 = name; // arr1 -> const char*
auto& arr2 = name; // arr2 -> const char(&)[3]

  好吧,基本是文字上的转换而已。不过和之前统一初始化语法提到的,当{}初始化式用于auto推导的时候,得到的将会是std::initializer_list的类型:

1
2
3
4
auto x1 = 27; // x1 -> int
auto x2 (27); // x2 -> int
auto x3 = {27}; // x3 -> std::initianlizer_list<int>
auto x4 {27}; // x4 -> std::initianlizer_list<int>

  auto推导列表类型是个例外需要注意的,而对于函数模板,其模板参数不支持列表初始化式子的推导,除非函数声明中显式指明接受列表初始化式:

1
2
3
4
5
6
7
8
template<typename T>
void f1(T param);
template<typename T>
void f2(std::initializer_list<T> param);
f1({11, 23, 9}); // Error
f2({11, 23, 9}); // OK

  虽然不一致,但是更加严格的参数类型要求显然对使用者来说更加的不容易出错了。

三、decltype关键字

  decltype的特性是给他一个名字或者表达式,他可以告知该名字或表达式的详细类型。decltype的推导过程是在编译期间完成的,该过程是不会对推导表达式进行求值运算的,所以也不会有副作用。
  decltype有能力推导得到表达式的真实类型,这包含表达式的引用及const、volatile限定符信息。decltype的推导和auto推导在某些情况下有特定的差异性:
  (1). 如果expr是一个名字,则decltype(expr)就返回其具体类型,这个类型包含cv限制符和引用(而auto会忽略cv修饰符和引用)。

1
2
3
4
5
6
const int ci = 0;
const int& cr = ci;
decltype(ci) x = 0; // x -> const int
decltype(cr) y = x; // y -> const int&
decltype(cx) z; // Error, const int&声明就必须初始化

  (2). 如果expr是一个函数调用,则decltype得到的类型和该函数的返回类型一致。但是需要注意的是,如果函数返回的是纯右值,但是是非类类型,则其cv修饰符通常会被丢弃掉:

1
2
3
4
5
const int func1();
const Widget func2();
decltype(func1()) a1{}; // a1 -> int
decltype(func2()) a2{}; // a2 -> const Widget

  (3). 如果expr是一个表达式,则decltype(expr)返回表达式结果对应的类型。而对于指针解引用得到的类型,其为其引用类型。

1
2
3
4
5
6
7
const int ci = 43;
const int& cr = ci;
const int* ptr = &ci;
decltype( ci + 1) x; // x -> int
decltype( cr + 1) y; // y -> int
decltype( *ptr) z = ci; // z->int&

  (4). 如果推导的表示带有额外的一对括号,形如decltype((expr)),则其类型一定是一个引用类型。

  在Modern C++中,关键字decltype最为广泛使用的情形莫过于和auto结合组成尾置返回类型,用于函数返回类型依赖于参数类型的时候,在模板通用编程中尤为常见:

1
2
3
4
template<typename Container, typename Idx>
auto auto(const Container& c, Idx i) -> decltype(c[i]) {
return c[i];
}

本文完!

参考