条款1 :理解模板型别推导

ZaynPei Lv6

如果一个复杂系统的用户对于该系统的运作方式一无所知,然而却对其提供的服务表示满意,这就充分说明系统设计得好。如果从这样的角度来看, C++的模板型别推导取得了极大的成功。

模板的型别推导,是现代C++最广泛应用的特性之一 ———— auto的基础。也就是说,如果你对C++98 推导模板型别的运作方式感觉满意,那么你也会自然而然地愉快接受C++11 中推导 auto 型别的运作方式。而坏消息则是,当模板型别推导规则应用于auto语境时,它们不像应用于模板时那么符合直觉。出于这个缘故,了解作为auto 基础的模板型别推导的方方面面就变得相当重要了。

不过在学习类型推导之前, 我们有必要复习一下const的基础用法

const 的基本用法

最简单的形式是定义一个常量。一旦初始化,它的值就不能再被改变。这个概念很简单,但当 const 和指针、引用以及模板结合时,情况就变得复杂了。

1
2
const int MAX_SIZE = 100;
// MAX_SIZE = 200; // 编译错误!不能修改 const 变量

const与指针

const 与指针的结合有两种关键形式,理解它们的区别是核心。我们可以通过 const 的位置来判断它的作用对象(距离最近的或者左侧的那个)。

  1. 首先是指向常量的指针,也称为“底层 const” (low-level const)。
1
2
3
4
5
6
7
8
9
10
11
12
const int* ptr;   // 距离最近的是int, 所以const修饰的是int
// 或者 (效果完全相同)
int const* ptr; // 左侧的是int, 所以const修饰的是int


int a = 10;
int b = 20;

const int* ptr = &a;

// *ptr = 15; // 编译错误!不能通过 ptr 修改 a 的值
ptr = &b; // 正确。ptr 可以指向另一个地址

含义:你不能通过这个指针 ptr 来修改它所指向的数据。但是,你可以让这个指针 ptr 指向别处。

  1. 常量指针 (Constant Pointer), 也称为“顶层 const” (top-level const)。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    int* const ptr = &a; // 必须在声明时初始化; 左侧是int*, 所以const修饰的是int*


    int a = 10;
    int b = 20;

    int* const ptr = &a;

    *ptr = 15; // 正确!可以通过 ptr 修改 a 的值,现在 a 变成 15
    // ptr = &b; // 编译错误!不能修改 ptr 指向的地址

含义:指针 ptr 本身的值(即它存储的地址)是不能被修改的。它必须永远指向初始化时的那个地址。但是,你可以通过它来修改它所指向的数据(前提是数据本身不是 const)。

此外, 指向常量的常量指针 (Constant Pointer to Constant Data)是上面两种情况的结合。指针 ptr 本身的指向和它指向的数据都不能被修改。

1
const int* const ptr = &a;

const 与引用

const 与引用的关系比较简单,因为引用本身就像一个别名,它不能被“重新绑定”到另一个对象,所以只有类似“指向常量的指针”也就是底层调用这一种情况。

1
2
3
4
5
int a = 10;
const int& ref = a; // 距离最近的是int, 所以const修饰的是int

// ref = 20; // 编译错误!不能通过 const 引用修改数据
a = 30; // 正确。原始变量可以修改,ref 的值也会跟着变为 30

const int& 是一个非常重要的编程惯用法。当按引用传递函数参数时,如果函数不需要修改这个参数,强烈建议形参使用 const 引用。这有两个好处:首先是安全, 防止函数内部意外修改了外部数据。其次还高效:避免了按值传递时创建对象的拷贝开销。同时也具有灵活性, 可以接受临时对象(右值)。

模板型别推导

所谓 C++ 模板参数推导的核心目标是:编译器必须为模板参数(如 T)找到一个(唯一的)类型,使得当这个 T 被替换回函数签名后,形成的形参(Parameter)类型能够合法地接收(绑定)我们传入的实参(Argument)

模板函数在推导类型 T 时,参数的 const 属性是否被保留,取决于函数参数 param 的声明方式。下列讨论针对的一般形式如下

1
2
3
template<typename T> 
void f(ParamType param);
f(expr); // 从 expr 来推导 T 和 ParamType 的型别

情况 1:按值传递 (T param)

首先, 将实参的引用部分忽略.

接着, 当函数参数是按值传递时,实参的 const 属性会被忽略。这是因为按值传递会创建一个新的对象,而新对象的 const 属性与实参无关。

若实参是个volatile 对象,同忽略之(volatile 对象不常用,它们一般仅用于实现设备驱动程序)。

1
2
3
4
5
6
7
8
9
10
11
12
template<typename T>
void f(T param) {
// ...
}

int x = 10;
const int cx = x;
const int& rx = x;

f(x); // T 被推导为 int, param 的类型是 int
f(cx); // T 被推导为 int, param 的类型是 int (cx 的 const 被忽略)
f(rx); // T 被推导为 int, param 的类型是 int (rx 的 const 和引用都被忽略)

考虑这种情况: expr是个指涉到 const 对象的 const 指针,且 expr按值传给 param:

1
2
3
4
template<typename T> 
void f(T param); // param 仍按值传递
const char* const ptr = "Fun with pointers"; // ptr 指涉到 const 对象的 const 指针
f(ptr); // 传递型别为 canst char* canst 的实参
这里,位于星号右侧的 const 将 ptr 声明为 const: ptr 不可以指涉到其他内存位置,也不可将 ptr 置为 null。而位于星号左侧的 const 则将 ptr 指涉到的对象(那个字符串)为const, 即该字符串不可修改。 可 ptr 被传递给 f 时,这个指针本身将会按比特复制给param。换言之,ptr这个指针自己会被按值传递。依照按值传递形参的型别推导规则,ptr 的const性会被忽略,param 的型别会被推导为 canst char*,即一个可修改的、指涉到一个 const 字符串的指针。 即对于上述示例, 在型别推导的过程中,ptr指涉到的对象的常量性会得到保留,但其自身的常量性则会在以复制方式创建新指针param的过程中被忽略。

情况 2:按指针或引用传递 (T& 或 T*)

当函数参数是指针或引用时,引用实参的 const 属性或者指针所指数据的const属性会被保留并成为类型 T 的一部分

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
template<typename T>
void f_ref(T& param) {
// ...
}

template<typename T>
void f_ptr(T* param) {
// ...
}

int x = 10;
const int cx = x;
f_ref(x); // T 被推导为 int, param 的类型是 int&
f_ref(cx); // T 被推导为 const int, param 的类型是 const int&

const int* px = &x;
int* const ppx = &x;
f_ptr(&x); // T 被推导为 int, param 的类型是 int*
f_ptr(px); // T 被推导为 const int, param 的类型是 const int*
f_ptr(ppx); // T 被推导为 int, param 的类型是 int*; 因为传递指针本质也是按指针拷贝(拷贝指针a的值也就是变量的地址到指针b), 而这里的指针具有常量性, 因此在按值拷贝的过程中会失去; 上一个示例的const修饰的不是指针所以在指针的按值拷贝过程中不会失去

在这个过程中,编译器就像在解一个方程. 再例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 模板函数定义
template<typename T>
void f(const T& param);

// 一个返回 std::vector 的工厂函数
std::vector<Widget> createVec();

// 使用工厂函数返回值初始化 vw
const auto vw = createVec();

if (!vw.empty()) {
// 调用模板函数
f(&vw[0]);
}
- 函数模板(签名): template void f(const T& param); - 形参类型 (P): const T& - 实参类型 (A): const Widget*

下面我们求解 T:根据规则, T 获取实参的底层const属性, 同时因为传入指针而形参不存在指针, 编译器自然假设 T = const Widget*。

接着将 T 替换回形参 P ( const T& )后得到形参类型:param 的类型变为 const (const Widget) &, 这也通常被读作 const Widget const &。(这是一个“对 [指向 const Widget 的 const 指针] 的 const 引用”)

尝试绑定:编译器再次尝试将实参 (A) 绑定到这个新生成的形参 (P) 上:此时实参 (A): const Widget; 形参 (P): const (const Widget) & (即 const Widget* const &)

因为实参类型 const Widget* 与形参期望引用的核心类型的主体 const Widget* 完全匹配。没有发生任何丢弃常量性的危险操作; 同时我们的实参(&vw[0] 的结果)是一个临时产生的指针,它是一个右值 (rvalue)。C++ 规定,右值不能绑定到非 const 的左值引用 (non-const lvalue reference),但它们可以完美地绑定到 const 的左值引用 (const lvalue reference)。我们的形参 const (const Widget*) & 正是一个 const 左值引用,因此它可以合法地绑定来自实参的右值。

情况 3:按万能引用传递 (T&& param)

这种情况更为特殊,但规则与情况2类似:const 属性会被保留。

1
2
3
4
5
6
7
8
9
10
template<typename T>
void f_forward(T&& param) {
// ...
}

int x = 10;
const int cx = x;

f_forward(x); // T 被推导为 int&
f_forward(cx); // T 被推导为 const int&

详细情况线上略

数组实参和字符指针

以上已经基本讨论完模板型别推导的主流情况,但还有一个边缘情况值得了解。这种情况是:数组型别有别千指针型别,尽管有时它们看起来可以互换。形成这种假象的主要原因是,在很多语境下,数组会退化成指涉到其首元素的指针。

函数实参和函数指针

总之, 在模板型别推导过程中,数组或函数型别的实参会退化成对应的指针,除非它们被用来初始化引用