文章

条款1:模板类型推导

模板类型推导根据实参推断模板参数,自动处理引用、指针及cv限定,实现类型匹配与转换。

条款1:模板类型推导

条款1:模板类型推导

  • 模板类型推导统一步骤:先忽略实参引用性,再根据形参模板形式不同决定是否保留 cv 限定和是否允许 T 推导成引用类型。

  • “忽略实参引用性”是所有模板类型推导的基础预处理规则。

四个要素

函数模板大致形如:

1
2
3
4
template<typename T>
void f(ParamType param);
// 以某种表达式调用 f
f(expr);

在编译期间,编译器使用expr进行两个类型推导:一个是针对T的,另一个是针对ParamType的。这两个类型通常是不同的,因为ParamType包含一些修饰,比如const和引用修饰符。

一共涉及到四个要素:TParamTypeparamexpr,它们分别在模板类型推导中扮演不同角色。

T — 模板参数(Template Parameter)

这是函数模板的 模板参数类型,由编译器根据调用 f(expr) 时传入的实参 expr 自动推导得出。

  • 是一个占位符,用于表示某种类型。
  • 类型推导的目标就是确定 T 是什么类型。

ParamType — 形参类型(Parameter Type)

这是函数 f 的形参的声明类型,是由 T 派生出来的一个形式类型。

  • 可以是 TT&const T&T&&T* 等等。
  • ParamType 的形式决定了 类型推导的规则和结果
  • 它也可以是 auto,那就变成了 C++14 的自动类型推导。

例子:

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

此时 ParamTypeconst T&,类型推导的时候要从 const T& 的角度去分析 expr

param — 形参名(Parameter Name)

就是 f 函数里定义的 形参变量名,是实际在函数体内使用的参数。

  • 和普通函数参数一样,用于在函数中访问调用者传进来的数据。
  • 对类型推导无直接影响,属于语法形式上的变量命名。

expr — 实参表达式(Argument Expression)

这就是调用函数 f(expr) 时传进去的实际表达式,用于参与类型推导。

  • 编译器根据 expr 的类型 + ParamType 的形式来反推 T 是什么。
  • 不同的 ParamType 形式会导致推导出的 T 不一样(这是类型推导的核心)。

第一类:形参是指针或引用类型,但不是万能引用

对应函数形参形如:T&const T&T*const T* 等,不包括 T&&(即第二类)。

这种情况下,类型推导会这样运作:

  1. expr 具有引用类型,则首先忽略引用部分

  2. 然后对 expr 的类型和 ParamType 的类型执行模式匹配,决定 T 的类型

示例 1:

1
2
3
4
5
6
7
8
9
10
template<typename T>
void f(T& param);  // param 是个引用

int x = 996;       // x 类型为 int
const int cx = x;  // cx 的类型为 const int
const int& rx = x; // rx 是一个 const int 类型的引用,引用了 int 类型的 x

f(x);   // T = int,         param 类型是 int&
f(cx);  // T = const int,   param 类型是 const int&
f(rx);  // T = const int,   param 类型是 const int&

第二个和第三个调用,向引用类型的形参传入 const 对象时,自然是希望保持其不可修改的属性,也就是期望该形参成为 const 的引用类型。该对象的常量性 constness 会成为 T 的类型推导结果的组成部分。

第三个调用中,rx 的引用性会在类型推导过程中被忽略。

这个例子只展示了左值引用,但是类型推导会如左值引用一样对待右值引用。当然,右值只能传递给右值引用,但是在类型推导中这种限制将不复存在。

示例 2:

f 的形参类型 T& 改为 const T&,情况有所变化,但不会变得那么出人意料,cxrx 的常量性 constness 依然会被保留。因为现在我们假设 paramreference-to-const,所以 const 不再被推导为T的一部分:

1
2
3
4
5
6
7
8
9
10
template<typename T>
void f(const T& param);  // param 是一个 const T 类型的引用

int x = 27;              // x 的类型是 int
const int cx = x;        // cx 的类型是 const int
const int& rx = x;       // rx 是一个 const int 类型的引用,引用了 x

f(x);   // T = int,         param 的类型是 const int&
f(cx);  // T = int,         param 的类型是 const int&
f(rx);  // T = int,         param 的类型是 const int&

和之前一样,rx 的引用性会在类型推导过程中被忽略。

示例 3:

如果param是一个指针(或者指向const的指针)而不是引用,情况本质上也一样:

1
2
3
4
5
6
7
8
9
10
template<typename T>
void f(T* param);           // param 是个指针,指向类型为 T 的对象

int x = 27;                 // x 的类型是 int
const int* px = &x;         // px 是个指针,指向 const int 类型的对象(x)
const int* const qx = &x;   // qx 是个 const 指针,指向 const int 类型的对象(x)

f(&x); // T = int,       param 的类型是 int*
f(px); // T = const int, param 的类型是 const int*
f(qx); // T = const int, param 的类型是 const int*

模板类型推导时会忽略实参的顶层 const,但会保留底层 const。所以 const int* const qx 传给 T* param,推导结果是 T = const int,param 是 const int*。换句话说:“指针本身是 const”不会影响 T 的推导;“指针指向的东西是 const”会体现在 T 中。

小结

第一类推导规则:当函数形参是指针或引用类型,但不是万能引用(即形参形如 T&const T&T*)时的推导规则。

  • T 的推导 不会保留引用性,但会 保留 cv 修饰符
  • 实参如果是引用,会先移除引用再推导。
  • 指针类型按被指向对象类型推导 T,保留其 const / volatile。
  • 不会发生退化,数组 / 函数会保持原类型。

第二类:形参是万能引用(Universal Reference)

  1. 实参如果是引用,推导时忽略引用部分。

  2. 如果实参是左值

    • T 被推导为 T&(左值引用),

    • ParamType 最终变成 T& &&,折叠成 T&

    • 这是模板中唯一 T 会被推导成引用的情况。

  3. 如果实参是右值,则

    • T 正常推导为实参类型,

    • ParamType 保持为 T&&

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
template<typename T>
void f(T&& param);             // param 是一个万能引用(Universal Reference)

int x = 27;                    // x 的类型是 int
const int cx = x;              // cx 的类型是 const int
const int& rx = cx;            // rx 是 const int 的左值引用

f(x);   // x 是左值,所以 T = int&,
        // param 的类型是 int&(T& && 折叠为 T&)

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&&(原样保留)

int* p = &x;                   // p 是 int* 类型
const int* cp = &cx;           // cp 是 const int* 类型(指向 const 对象)
const int* const ccp = &cx;    // ccp 是 const 指针,指向 const 对象

f(p);    // p 是左值,类型是 int*,
         // 所以 T = int*&,param 类型是 int*&

f(cp);   // cp 是左值,类型是 const int*,
         // 所以 T = const int*&,param 类型是 const int*&

f(ccp);  // ccp 是左值,类型是 const int* const,
         // 所以 T = const int* const&,param 类型是 const int* const&

f(new int(42));  // new int(42) 是右值,类型是 int*
                 // 所以 T = int*,param 类型是 int*&&(原样保留)

f(static_cast<const int*>("hello"));  
                 // 是右值,类型为 const int*
                 // 所以 T = const int*,param 类型是 const int*&&(原样保留)

第三类:形参既非指针/引用,也非万能引用

当形参既不是指针也不是引用时,我们通过传值(pass-by-value)的方式处理:

1
2
template<typename T>
void f(T param);                // 以传值的方式处理 param

这意味着无论传递什么 param 都会成为它的一份拷贝——一个完整的新对象。事实上 param 成为一个新对象这一行为会影响 T 如何从 expr 中推导出结果。

  1. 实参如果是引用,推导时忽略引用部分。

  2. 忽略引用后,如果实参是顶层 constvolatile,也会被忽略。

示例 1:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
template<typename T>
void f(T param);                // param 以值传递方式接收,类型是 T

int x = 27;                     // x 的类型是 int
const int cx = x;               // cx 的类型是 const int
const int& rx = cx;             // rx 是 const int 类型的左值引用

f(x);  // x 是 int 类型,
       // T = int,param 的类型是 int

f(cx); // cx 是 const int,传值时忽略顶层 const,
       // T = int,param 的类型是 int

f(rx); // rx 是 const int&,传值时忽略引用和顶层 const,
       // T = int,param 的类型是 int

即使 cxrxconstparam 不是 const,因为它是它们的拷贝。cxrx 的常量性不影响拷贝的可修改性,所以推导时会忽略实参的 const(和 volatile),确保拷贝是可变的。

示例 2:

1
2
3
4
5
6
7
8
9
10
template<typename T>
void f(T param);                // param 以传值方式接收,类型是 T

const char* const ptr = "巴巴博一.mp4"; // ptr 是 const 指针,指向 const char (字符串字面量)

f(ptr);  // 实参类型是 const char* const,
         // 传值时顶层 const(指针本身的 const)被忽略,
         // 所以 T = const char*,保留了底层 const
         // param 的类型是 const char*(可变指针,指向 const 字符串)

传值时,实参的顶层 const(比如指针本身是 const)会被忽略,但底层 const(指针指向的数据是 const)会被保留。

例如,const char* const ptr 传给 T param 时,param 的类型是 const char*,指针指向的数据不可改,但指针本身可变,因为指针本身的顶层 const 被忽略了。

顶层 const/volatile底层 const/volatile 的保留与忽略

形参形式顶层 const/volatile底层 const/volatile说明与举例
T& param(引用)引用没有顶层 const保留顶层 const/volatile 传递给模板参数 T,比如 const int& 推导为 T = const int
T* param(指针)忽略保留指针本身(顶层)的 const/volatile 忽略,指针指向的类型(底层)保留。示例:const char* const ptr 传给 T* paramT = const char
T&& param(万能引用)保留保留同引用,且根据实参值类别推导出 T,左值时 T 会是引用类型,保持 const/volatile。
T param(传值)忽略保留顶层 const/volatile 会被剥除,比如 const intint;指针的底层 const 保留。示例:const char* const ptr 传给 T paramT = const char*
  • 顶层 const/volatile:修饰变量本身,比如指针是 const。
  • 底层 const/volatile:修饰指针或引用指向的对象。

数组实参

数组类型和指针类型看起来类似,但在模板类型推导中它们是不同的。虽然数组在很多场合会退化为指针(比如传参时),但这种退化不是总发生,具体要看形参类型。正是这种“自动退化”,让人误以为数组和指针可以完全互换,其实并非如此。

数组退化示例

这种退化允许代码像下面这样正常编译:

1
2
3
4
5
6
void func(const int* p);      // 参数类型是 const int*,指向 const int 的指针

int arr[5] = {1, 2, 3, 4, 5}; // arr 的实际类型是 int[5](长度为 5 的 int 数组)

func(arr); // arr 作为实参时会发生退化:int[5] → int*
           // 然后 int* 被隐式转换为 const int* 以匹配函数参数类型

但在模板类型推导中是否退化,取决于形参是传值、指针、引用还是万能引用。数组作为实参,只有传值时会发生退化,传引用则保持原始数组类型。

函数数组形参实质是指针

虽然可以这样声明函数:

1
void myFunc(int param[]);

但它其实等价于:

1
void myFunc(int* param);  // 数组参数视作指针参数

这是从 C 语言继承下来的特性,容易让人误以为数组和指针是等价的,但它们在类型系统中是不同的,只有在传值时会自动退化为指针

模板传值时的数组退化

1
2
3
4
5
6
7
8
template<typename T>
void f(T param);       // param 是传值参数

const char name[] = "hello";
f(name);               // name 是 const char[6](数组)

// 但传值时数组会退化为指针:const char[6] → const char*
// 所以 T = const char*,param 的类型也是 const char*

传引用时数组类型不退化

虽然函数不能声明形参为真正的数组,但可以接受指向数组的引用

例如:

1
2
3
4
5
template<typename T>
void f(T& param);    // 传引用形参的模板

const char name[] = "Hello, world";
f(name);             // 传数组给 f

此时 T 被推导为真正的数组类型,包含大小:

  • T = const char[13]
  • param 类型是 const char (&)[13](引用类型,指向数组)

这种写法虽然复杂,但能保留数组的完整类型信息。

利用数组引用推导数组大小

我们可以声明一个接受数组引用的模板函数,在编译期间返回数组大小:

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

这样就能在编译期获取数组的大小。示例:

1
2
int keyVals[] = {1, 3, 7, 9, 11, 22, 35};       // keyVals 有 7 个元素
int mappedVals[arraySize(keyVals)];             // mappedVals 也有 7 个元素

作为现代 C++ 程序员,更推荐用 std::array

1
std::array<int, arraySize(keyVals)> mappedVals; // mappedVals 大小为 7

constexpr 保证函数在编译时执行,noexcept 有助于编译器优化,具体细节可参考相关资料。

函数实参

在 C++ 中,不仅数组会退化为指针,函数类型同样会退化为函数指针。数组类型的推导和退化规则,同样适用于函数类型。

1
2
3
4
5
6
7
8
9
10
void someFunc(int, double);       // someFunc 是函数,类型为 void(int, double)

template<typename T>
void f1(T param);                 // 传值

template<typename T>
void f2(T& param);                // 传引用

f1(someFunc);  // param 被推导为函数指针类型:void(*)(int, double)
f2(someFunc);  // param 被推导为函数引用类型:void(&)(int, double)

在模板类型推导中,数组名或函数名实参通常会退化为指针,除非用来初始化引用时才保留其原始类型。

本文由作者按照 CC BY 4.0 进行授权