理解模板类型推导

本文最后更新于:April 20, 2022 am

本篇博客将会详细介绍现代C++的模板类型推导

类型参数的推导

当某个极其复杂的系统的用户不需要关心其实现逻辑就可以开心地正确地使用它,这本身就能说明这个系统设计的优良之处。在这一层面上,C++的模板类型推导是如此的成功,数百万的C++开发者可以简单地将参数传递给函数模板,然后心满意足地拿到正确返回值离开,即使祂们之中的大多数对函数模板的类型是如何被推导的这一过程云里雾里

如果你是祂们之中的一员,这里有一条好消息和一条坏消息,好消息是,模板的类型推导是现代C++最强制性的特性之一,auto,的实现基础;如果熟悉C++98中模板的类型推导过程,那大概也会对C++11中的模板类型推导感到熟悉和自然。坏消息是,当模板类型推导的规则应用到auto关键字的语境中时,它们有时看起来会比在模板的语境中更晦涩。因此,我们需要真正地理解auto这栋大厦建立在模板类型推导上的地基,本篇博客会将这部分做详细的介绍

为了更形象地进行讨论,想象一下通常的函数模板的样子

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

调用时:

1
f(expression);

编译期间,编译器使用expression来推导两个类型,一个是类型参数T,一个是类型ParamType,这两个类型通常是不一样的,因为ParamType通常附带某些诸如const的修饰符,或者其本身是个引用,比如,如果某个函数模板的声明如下,然后我们使用int类型的变量来调用

1
2
3
4
5
6
template<typename T> 
void f(const T& param);

//call
int x=0;
f(x);

类型参数T将会被推导为int,而ParamTypeconst int&

我们会很自然地期望,被推导出的类型参数T最终的类型和实参的表达式expression的类型一致,正如在上述的例子中,实参表达式就是int类型,而类型参数T也被推导为int。但是实际情况更为复杂,T最终被推导为什么,这不仅仅和实参表达式expression有关,还和ParamType的形式有关,总的来说一共有三种情况

  • ParamType是一个指针类型,或者引用类型,但是不是万能引用
  • ParamType是一个万能引用类型
  • ParamType既不是指针类型,也不是引用类型

接下来我们将会详细讨论三种情况

Case 1

这也是最简单的情形,即ParamType是一种引用类型,或者指针类型,但不是万能引用,在此情况下的类型推导规则如下

  • 如果expression的类型是引用类型,忽略expression和ParamType的引用部分,然后——
  • 将expression剩下的类型和ParamType的样子进行比较,类型参数T为expression的类型信息中ParamType所不具备的内容

举例而言:

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

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

f(x); //T为int,ParamType为int&
f(cx); //T为const int,ParamType为const int&
f(rx); //T为const int,ParamType为const int&

在第二次调用和第三次调用中,我们注意到cxrx都是常量类型的,因此推导出的ParamType都是const int &

这一点很重要,因为当实参是常量类型时,调用者自然地很期待此函数不会改变实参的状态,因此最终ParamTypereference to const,这也是为什么向参数是T&的函数模板中传递常量对象也是安全的,因为const修饰符会被成类型参数T的一部分

在第三次调用中,即使实参rx的是引用类型,类型参数T也不是引用类型,这是因为在执行类型推导过程中都不考虑引用的符号

上面三个例子展示了针对左值引用的参数传递情况,但实际上这个规则对于右值引用也同样适用,当然,这得是实参是右值引用才行

如果我们将函数模板f的参数从T&改为const T&,类型推导的结果会略有不同,但是这也很合常理。实参cxrxconst修饰符继续存在,但是由于ParamType中已经带有const关键字了,因此类型参数T就不带有const

1
2
3
4
5
6
7
8
template<typename T>
void f(const T& param);
int x=27;
const int cx=x;
const int& rx=x;
f(x); //T是int,ParamType是const int&
f(cx); //T是int,ParamType是const int&
f(rx); //T是int,ParamType是const int&

和修改前一样,推导时不考虑引用符号;另外,如果形参param的类型是指针,指向常量的指针,结果完全一致

1
2
3
4
5
6
7
template<typename T>
void f(T* param);

int x=27;
const int *px= &x;
f(&x); // T是int,ParamType是int*
f(px); // T是const int,ParamType是const int *

至此,一切都符合我们的设想,这种情况下的类型推导也是如此自然

Case 2

当函数模板的参数ParamType是万能引用形式时(比如template <typename T>void func(T&& param)),类型推导的过程就不那么显而易见了,因为当实参是左值或者右值时,推导出来的类型参数T将会不一样(如果希望更多地了解,可以参考这篇博客):

  • 当expression是左值,类型参数TParamType都会被推导成左值引用类型,然后通过引用折叠的机制进一步推导param的类型

    这种情况下有两点很反常,第一,在所有类型推导的情况中,这是T唯一被推导为某种引用类型的情况;第二,在函数签名中,ParamType已经是使用右值引用声明的形式了,T居然还被推导为左值引用类型,而最后ParamType的类型仍然是左值引用类型

  • 如果expression是右值,那么应该按照case 1的规则进行推导

举例而言:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
template<typename T>
void f(T&& param);
// 函数模板f的参数param是万能引用

int x=27;
const int cx=x;
const int& rx=x;
//请注意,引用变量本身是左值

//使用左值调用:
f(x); //T是int&,ParamType是int&

f(cx); //T是const int&,ParamType是const int&

f(rx); //T是const int&,ParamType是const int&

//使用右值调用:
f(27); //T是int,ParamType是int&&

简单来说,当万能引用作为函数模板的形参时,类型推导的规则同形参是左值引用或者右值引用的情况并不相同。当且仅当函数模板的形参是万能引用时,才会适用case 2的规则

Case 3

ParamType既不是指针类型,也和引用无关时,我们实际上在使用按值传递

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

这意味着param相较于实参expression,将会是一个全新的对象。此时的类型推导规则如下:

  • 同往常一样,首先忽略实参expression的引用部分
  • 如果expression有const或者volatile修饰符,同样加以忽略(volatile关键字并不常见,它主要被用于开发设备的驱动,具体可以参考Item40的讨论

因此:

1
2
3
4
5
6
7
8
int x=27;
const int cx=x;
const int& rx=x;
f(x); //int是int,ParamType也是int

f(cx); //int是int,ParamType也是int

f(rx); //int是int,ParamType也是int

即使cxrx变量本身是带有const 修饰符,但param也不是const的,这很好理解,因为形参和实参是两个独立的变量,值得注意的是,只有在这种按值传递的情况下,ParamType才会忽略constvolatile

举一个特殊情况的例子:

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

const char* const ptr="const ptr pointing to const string literal!";

f(ptr);

此处对变量ptr的声明中有两个关键字const,靠左侧的const关键字代表此ptr不可以改变其指向的std::string对象,而右侧的const关键字代表此ptr变量本身的值不可改变,它不能被改变为指向其它某个字符串,也不能被设置为nullptr

因此,当变量ptr被作为参数传递给函数f时,左侧的const需要予以保留,以保持访问权限的一致性,而右侧的const修饰符会被忽略。

数组作为实参

尽管上述三个case的讨论已经cover了绝大多数有关类型推导的情况,但仍有少数漏网之鱼,其中之一就是数组作实参时,类型推导的情况其实和指针有所不同,即使很多时候数组和指针看似是不分你我的。在很多语境下,数组变量退化成它的首个元素的指针,这使得类似下面这种代码能够编译成功

1
2
const char note[] = "Programming in C++!";
const char* ptrToNote= note;

第二行使用const char[20]类型的ptrToNote来初始化char* 类型的pointer,理论上这两种类型本不一样,但是由于有数组退化成指针的机制,代码能够编译

如果某个数组作为实参被传递给按值传递的某个函数模板,类型参数会被推导成什么呢?

1
2
3
const char note[] = "Programming in C++!";
template<typename T> void f(T param);
f(name);

首先,注意到我们无从为某个函数真正声明数组形式的参数

1
2
3
4
void myFunc(int param[]);
// 此处类似数组的声明被编译器视作指针
// 等同于
void myFunc(int* param);

关于在形参语境下,数组和指针时等效的这一特点,其实是源于C,而C++对C的兼容使得这一特点被保留到了C++中

因此,在值传递的语境下,即使我们向函数传递数组作为实参,类型参数T也会被推导为指针类型,f(name)中,T将会被推导为const char *

神奇的是,即使函数模板不能将参数类型推导为数组,但是它居然可以推导出数组的引用,如果我们的函数声明以及调用是:

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

T的类型就会是数组,并且包含数组的长度、元素类型等。此处,T将会被推导为const char[20]f的参数因此变成const char& [20]

这里的语法乍一看挺有毒,但正是这种机制,使得我们可以声明一个模板,在编译时期就推导出某个数组的元素个数:

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

带上关键字constexpr之后,编译器将在编译期间就将这个函数的返回值计算出来,通过这样的方法我们可以在编译期间得出使用大括号初始化的数组的大小

1
2
3
4
int keyVals[]= {1,3,5,7,9};
int mappedVals[arraySize(keyVals)];
// or use std::array
std:array<int,arraySize(keyVals)> mappedVals;

将函数arraySize声明为noexcept是为了让编译器生成更快的代码

函数作为实参

在C++中,和数组一起退化成指针的,还有函数。上一节讨论的所有关于数组的内容,对于函数而言都同样适用

1
2
3
4
5
6
7
8
9
10
11
12
void someFunc(int ,double);

template<typename T> void f1(T param);

template<typename T> void f2(T& param);


f1(someFunc);
//值传递,实参退化为void (*) (int ,double)

f2(someFunc);
//引用,实参为void (&) (int, double)

但是在函数这一边,这样的细小差别在生产中几乎没有影响,理解了数组的退化过程,也就理解了函数的退化过程

总结

在模板类型推导过程中,实参的引用符号将被忽略

当为万能引用推导类型参数时,实参为左值是一种特殊情况

当按值传递时,类型参数会忽略const和volatile

按值传递时,数组和函数都会退化成指针;如果按引用传递,则参数是原来数组/函数的引用


本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!