C++模板类型推导大全

前言和背景

《Effective c++》一书中条款01为:视c++为一个语言联邦,该条款中将c++语言分为4个次语言组成的“联邦政府”,其分别为:兼容基础c的部分、c++面向对象的部分、c++模板部分、stl库部分。
我非常认同将c++语言看成由几个子语言组成的联邦语言,不过我个人认为stl库应该是建立在前三个子语言的基础上发展出来的,较为成熟和通用的一个典型作品。(尽管有很多人对stl库有各种各样的吐槽,但是话说回来没被吐槽过的c++库真是少之又少…),所以我个人把c++语言分为兼容c部分(随着c++和c语言各自的发展和演化,目前已经不是一个完全包含的关系了)、c++面向对象部分和c++模板部分,这三个部分各自独立成一体系,相互之间又有很深刻的联系。有很多人说c++模板就是c++标准提供给程序员的,用来生成自定义的函数和类的脚本语言,只不过这种脚本语言被c++纳入标准了而已。
C++模板部分的基石,我个人认为是模板类型推导和模板的特化与偏特化。从基础的stl库的各种组件的使用到自己进行模板元编程,都是在这两个基本概念之上发展起来的。这篇文章的目的就是记录c++11/14标准下的c++模板类型推导规则。

C++的声明语法:
decl-specifier-seq init-declarator-list

decl-specifier-seq可以是以下几种(顺序任意):

  • 1.typedef 说明符,如果出现的话说明整个声明是一个typedef声明,该声明会introduce一个新的类型名称,而不是一个函数或者对象。
  • 2.inline说明符,(c++17开始允许出现在变量声明中)
  • 3.friend说明符,允许出现在类和函数声明中。
  • 4.constexpr说明符(c++11),允许出现在变量定义、函数和函数模板声明以及静态数据成员的声明中(必须用字面值初始化)。
  • 5.存储周期说明符(register(until c++11),static,thread_local,extern,mutable)
  • 6.type specifiers,其中包括:
  • (1)class declaration
  • (2)enum declaration
  • (3)内置类型说明符
  • (4)auto、decltype specifier(根据表达式来推导类型的两个说明符)
  • (5)之前声明的类和enum名字
  • (6)之前声明的typedef-name或者type alias
  • (7)模板参数填充的模板名字
  • (8)elaborated type 说明符,没用过
  • (9)typename specifier
  • (10)cv说明符(const volatile)
  • (11)attributes这里不介绍,没用过

注:上述出现的specifier并不仅仅翻译为说明符,它与前面的关键字作为一个整体,代表了某一段语法规则而不仅仅是其前面的关键字而已,具体请查询:
https://en.cppreference.com/w…
仅只有一个type specifier允许出现在decl-specifier-seq中,但以下情况除外:
1.const和volatile能够和除了自己以外的其他type specifiers一同出现。
2.signed和unsigned能够和char、long、short或者int一起出现。
3.short和long能够和int一起出现。
4.long可以和long一起出现。(c++11)

接下来看init-declarator-list:
init-declarator-list的语法规则我们略过,只说明会有哪些情况:

  • 1.unqualified-id
  • 2.qualified-id
  • 3….
  • 4.指针声明
  • 5.指向成员的指针声明
  • 6.左值引用
  • 7.右值引用
  • 8.数组声明符
  • 9.函数声明符

尽管上述列出的标准是如此繁杂和无从下手,但我不得不说这已经是一份简化版本,我略去了c++11之后的标准中提出的内容,以及自己没怎么使用过的部分。我列出这些的目的在于理解,c++中的类型由:decl-specifier-seq 和init-declarator-list两部分共同组成。并且,decl-specifier-seq的部分中,type specifiers参与了类型的真正构成(其余类型的标识符都说明了声明的name其他方面的性质)
我们将关注点放在变量的声明上,以此排除掉类、函数和模板声明相关的标识符。一个变量的类型如下:

  • 1.可选的cv标识符
  • 2.type specifiers中的标识符
  • 3.可选的*、&、&&、[(可选的constexpr)]标识符

例如:int、const int、const int&、int*、const int[12]等等,都是符合上述条件的类型。我们之后的模板类型推导也正是建立在这个前提下。

类型推导

此节关于模板类型推导、auto类型推导和decltype。大部分借鉴于《Effective modern c++》
首先来看模板类型推导

template<typename T>
void f(ParamType param);
...
f(expr);

如上,模板类型T的类型由ParamType和expr联合决定,分如下几种情况:

  • ParamType是引用或者指针,但不是万能引用:

如果expr的类型是引用类型,丢掉其引用类型。然后用expr的类型去匹配ParamType以确定T的真实类型。我们在前言中提到过,任何一个变量的类型由三部分组成:可选的cv标识符、裸类型(我自己这么称呼,代表不具有cv属性、不具有引用、指针标识符后缀的类型,数组和函数标识符标识的类型在模板类型推导中是特殊情况,我们在最后讨论)和可选的引用、指针、数组、函数标识符。这里指的是,先用expr中的裸类型确定T的裸类型(直接匹配),然后如果expr或者ParamType中的任何一个具有cv标识符,则给T的裸类型前加cv标识符,推导完成。

template<class 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&

将f中的T& 换位const T& ,结果不变。
将上述两种情况中的&换为*,结果不变。

  • ParamType是万能引用类型:

万能引用类型的语法格式很固定:

template<class T>
void f(T&& param);

这里面的ParamType是万能引用类型。注:关于c++中的万能引用类型,effective modern c++一书中有章节专门讲这个:Item24 。
这种情况对于模板类型T的推导和情况1完全不同,因为这里涉及到了c++中表达式结果的一个性质:value category。说到这个又是一个十分基础和重要的性质,c++中的表达式的结果有两个独立的性质:type和value category。其中type很好理解,例如int i = 2; (i)这个表达式的type就是int&,就是我们理解的变量的类型。而value category有童鞋听过的话就是我们说的左值、纯右值、消亡值等等概念,这个性质代表了变量的生命周期、能否对其进行取地址操作等。理解value category的概念对于c++11非常重要,右值引用、完美转发、std::move、移动构造函数和移动赋值函数等都建立在以下几个基本概念上:value category、模板类型推导、引用摺叠规则和右值引用。大家可以打开编译器看看move的一份实现,短短几行代码几乎凝聚了c++11带来的大部分根本性的扩展:

        // FUNCTION TEMPLATE move
template<class _Ty>
    _NODISCARD constexpr remove_reference_t<_Ty>&&
        move(_Ty&& _Arg) _NOEXCEPT
    {    // forward _Arg as movable
    return (static_cast<remove_reference_t<_Ty>&&>(_Arg));
    }

罗里吧嗦说了这么多,好像还没开始我们的正题。但是c++就是这么一门语言,理解c++中构成其大部分组件的基石是进阶必要的。

如果expr是一个lvalue,T会被推断为对应expr裸类型的左值引用,而根据引用摺叠规则,ParamType也被推断为左值引用。
如果expr是一个rvalue(纯右值+消亡值统称为右值),情况1生效,即expr会被推断为对应的裸类型,而ParamType将会是裸类型对应的右值引用类型。
这里我举几个十分经典的例子:
例子1:

1.template<class T>
void f(const T&& param); //这不是万能引用,见effective modern c++ item24
...
const int i = 2;
f(i);

会发生什么?模板类型推导成功,但程序编译不过,因为不是万能引用,用情况1的条件去推导,得出T的类型是int,那么该模板函数会生成如下函数:
void f(const int&& param);
f(i)
相当于用常量右值引用去绑定i,而i是一个lvalue,所以编译会不通过。

例子2:

template<class T>
void f(T&& param);
...
int i = 2;
f(i);              //T是int&
f(std::move(i));    //T是 int

两次调用都触发了第二种万能引用情况,但是i是一个lvalue,而std::move(i)返回的是一个type为int&&,value category为xvalue的变量,该变量是一个rvalue,所以这两种调用分别对应了2中的两种情况。
注:std::move之所以会返回xvalue变量,因为c++标准规定static_cast类型转换表达式当把某个变量的类型转化为对应的右值引用类型时,会将其value category变为xvalue.见:
https://en.cppreference.com/w…

  • ParamType既不是指针也不是引用
template<class T>
void f(T param);

或者

template<class T>
void f2(const T param);

如果expr的类型中含有引用,则忽略其引用。如果expr的类型中含有cv,也将其忽略,然后用expr的裸类型去匹配T。
注意到,这和情况1比较像,区别在于模板类型T不会继承expr的cv属性,理由如下:由于ParamType既不是指针也不是引用,故此函数调用将会是值传递,实参是形参copy过来的,而形参的cv属性是用来限制形参本身,现在实参已经是一份复制品,对其修改也不会影响形参。

三种情况介绍完了,之后需要补充c++模板类型推导对于数组类型和函数类型的特殊处理。
对于数组类型,如果ParamType符合情况1和2,则T会被推导为对应裸类型的数组类型的应用类型,例如:

template<class T>
void func(T&& param); //or void func(T& param);
...
int i[2] = {1,1};
func(i);                    //T会被推导为int(&)[2]类型。

如果ParamType符合情况3,则T会被推导为对应裸类型的指针类型。

对于函数类型,由于c++中函数类型会隐式转化为函数指针类型,故和数组类型的处理方法是一样的(数组类型也是c++中隐式转化为指针类型的)。

这就是关于模板类型推导的内容,三种主流情况与两个特例。本节涉及到的基本概念有:
1.value category
2.右值引用、左值引用、常量左值引用的匹配优先级
3.引用摺叠
4.std::move和完美转发
5.模板类型推导规则
这些概念十分重要,c++中很多高级特性都是在这些基本概念上搭建起来的,基本概念5和模板特化与偏特化,这两块搭建起了c++模板的绝大部分内容;基本概念2+3+4是理解c++移动构造函数、移动赋值函数的基石。

文章写的仓促,不当之处颇多,并且关于auto类型推导和decltype的使用还没有介绍,之后再仔细校对填充。

点赞