乐趣区

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 的使用还没有介绍,之后再仔细校对填充。

退出移动版