现代模版元编程
前言
模板元编程已成为 C++ 程序员工具包的重要组成部分。本文将学习并展示元编程工具和技术,并应用于每个标准库设施的代表实现。在此过程中,我们将研究 void_t
,这是最近提出的一种极其简单的新 <type_traits>
候选项,一位专家将其描述为 “高度先进(且优雅),甚至对经验丰富的模板元编程人员来说都令人惊讶。”
注意:本文不适合 C++ 新手
本文可能存在一些对计算机软件编程方法持有相当强烈的观点,这些观点不是所有程序员都度认同的。但他们应该确实如此
From the std::
library:
- integral_constant, true_type, false_type;
- is_same, is_void;
- is_copy_assignable, is_move_assignable;
- remove_const, remove_volatile, remove_cv;
- conditional, enable_if;
- is_intergal, is_floating_point;
Not int the std::
library:
- abs, gcd;
- type_is, bool_constant;
- is_one_of;
- void_t (!), haas_type_member;
什么是模版元编程
模板元编程(英语:Template metaprogramming,缩写:TMP)是一种元编程技术,编译器使用模板产生暂时性的源码,然后再和剩下的源码混合并编译。这些模板的输出包括编译时期常量、数据结构以及完整的函数。如此利用模板可以被想成编译期的执行。这种技术被许多语言使用,最为知名的当属C++,其他还有Curl、D、Eiffel,以及语言扩展,如Template Haskell。
————来自维基百科
C++ 模版元编程使用模版实例化来驱动编译时评估.以防你虽然不是 C++ 新手,但不是很熟悉模板,这里举一个例子:调用 f(x)
, 其中 f
命名一个函数模版,意思就是当我们使用一个模版的名称,其中包含一个(函数,类型,变量)时,编译器将从该模版实例化(创建)预期的实体。模板元程序员利用这种机制来提高源代码的灵活性和运行时性能,显而易见的,你把运行期做的工作一部分转移到了编译器实现。也正因如此,他从来不是没有代价的,因为编译器正在工作,所以要花费一点时间在编译期。
以防不相信模版带来的性能提升,再举一个例子:std
标准库中的 std::pow()
函数,将其改为手动实现的模版 pow<>
(编译器计算),即使是在 2001 年那种粗糙的计时条件下,计算 x^50 重复 10000000 次。
在700MHz 的奔腾芯片上使用 gcc 2.95.2,-O0,Win2K.
std::pow() | pow<> v1 | pow<> v2 | |
---|---|---|---|
real | 11.858 s | 8.081 s | 3.035 s |
user | 11.837 s | 8.081 s | 3.024 s |
sys | 0.020 s | 0.020 s | 0.030 s |
使用 g++ -O2 编译,其他方面相同。
std::pow() | pow<> v1 | pow<> v2 | |
---|---|---|---|
real | 11.857 s | 4.286 s | 0.300 s |
user | 11.847 s | 4.236 s | 0.190 s |
sys | 0.020 s | 0.010 s | 0.010 s |
改进:v1的改进率高达47%,v2的改进率达到了90%!
元编程时:
记住,运行时间 == 编译时间,所以不能依赖:可变性、virtual
、其他 RTTI 等。所以接下来的讲解均不涉及运行期的任何事情。
如何将工作转移到编译期
把工作从运行期转移到编译期并不困难,简单来说就是:当你提前知道你输入的值是什么,如果在运行期前没有这些值,那么元编程不会帮到你。
Example:一个编译器绝对值元函数:
1 | template<int N> |
这里有一个静态断言有两个原因: 一个是如果是二进制的补码,你不能得到 INT_MIN
的绝对值,因为没有正的对应值,二是这是静态断言的变体,被选入了 C++17,在 C++11 中,你必须输入另一个字符串字面量,在 C++17 中这就是可选的(为什么要这么做呢?开发者的回答是:大多数时候不在乎,字符串字面值对我来说至少是引用,因为我从我的编译器中得到诊断包含断言的文本。简而言之,知道错了就好没必要用字符串描述,笑~)。
又一个有趣的点是:在结尾使用结果进行初始化,这里不是赋值,赋值是运行时的,我们正在初始化一个 static
,现在更多的是 constexpr
变量,这就替代了我们所认为的返回,这是可行的。
Usage(元函数调用):
Metafunction arg(s) 作为模板的 arg(s) 提供。“调用” 语法是对模版的已发布值的请求。
1 | `int const n = ...; // 也可以是 constexpr |
这种写法是自 C++98 起就有用的写法。稍后会介绍在 C++11 14 17 中的其他写法。
C++11 的 constexpr 函数
Example: 一个编译期求绝对值函数:
1 | constexpr int abs(int N) {return (N < 0) ? -N : N;} |
constexpr
函数和元函数是什么关系呢?
constexpr
函数的主要优势是它那熟悉的调用语法,没有冒号和尖括号的尴尬,你只要假装它是一个正常函数,但是如果你把元函数和 constexpr
函数比较,元函数相当于一个结构体,它给了我们更多的工具,因为你可以在结构体内部将东西公开,你不能在 constexpr
函数中做同样的事情。
constexpr
函数主要用于编译期的计算,它处理的是具体的数值或数据,可以在编译时计算出函数结果。模板则是为不同的类型生成不同的代码,主要用于处理类型逻辑、元编程等。例如,可以使用模板生成不同的类或函数,而这在 constexpr
函数中无法完成。
实现编译器递归
Example:gcd(m,n)
元函数(编译时计算最大公因数)的模版
1 | template<unsigned M, unsigned N> |
与模式匹配非常类似,这部分的全特化可以识别 gcd(m,0)
1 | template<unsigned M> |
(这里没写 constexpr
是因为开发者写这个例子的时候还没有 constexpr
呢,不过这样就适用于较老的编译器,随你喜欢)
元函数可以使用类型作为参数
如标题所说的,使用 constexpr
函数是做不到的,类型不是值。
现在我们就有一个很好的例子sizeof
,sizeof
是一个内置的类型函数,但是我们也可以自己编写。
1 | template<class T> |
这是一个标准库之外的简单例子,用来求数组的维数。就像大多数情况一样它分为两个部分,这是第一部分,如果传入的类型不是一个数组,这个元函数就返回 0,
1 | template<class U, size_t N> |
Usage:
1 | using array_t = int [10][20][30]; |
详解:
1 |
|
元函数也可以作为其结果产生一种类型
Example:”remove” 一个类型的 const-qualification
(不是实际的移除,相当于给我一个等效的没有 const
的类型)
1 | template<class T> |
Usages:
1 | remove_const<T>::type t; // 确保 t 具有可变类型 |
C++11 库元函数约定
第一个约定是用于元函数的是使其具有类型结果,正如之前看到的,返回结果的类型用 type
给他命名,(除此约定之前的几个 std::
元函数外,例如:iterator_traits
有5个类型结果,没有一个是命名类型)
Example:身份认同元函数(identity metafunction)
1 | template<class T> |
现在可以通过继承来应用约定
1 | // 主模板处理非易失性限定的类型: |
编译时决策
假设一个元函数,IF/IF_t,选择一下两种类型之一
1 | template<bool p, class T, class F> |
这样一个工具可以让我们编写自配置代码:
1 | int const q = ...; //用户的配置参数 |
IF 的幕后
实现起来也是惊人的简单的:
1 | template<bool, class T, calss> // 无需命名未使用的参数 |
这个 IF
在 C++11 中被命名为 conditional
,由一个方便的调用别名,conditional_t
来增强(C++14)。(所有 C++14 标准类型返回特性都具有类似的 ...-t
方便元函数调用别名。)
条件变量的单类型变异
如果为真,则使用给定的类型,如果为假,则不使用任何类型。
1 | // 主模板假设 bool 值为真。 |
这有什么好处呢,有什么用处呢?现在考虑一个元函数调用:
1 | enable_if<false, ...>::type; |
如果 enable_if
是 false
,则启用特化版本,这样的话 ::type
就什么都不会得到,没有这个 type
成员,这是一个错误。当然了,仅在少数情况下是一个错误,因为我们还有 SFINAE!(通常称为显式过载集管理。代换失败不是错误)
SFINAE 在隐式实例化过程中适用
在模板实例化过程中,编译器将会发生以下操作:
- 获取模版参数:
- 如果在模版使用是明确提供,则为逐字记录
- 否则是调用点的函数参数中推导出来的
- 否则取自声明的默认模版参数
在同一个实例化中做这三件事
- 将整个模版中的每个参数替换为相应的参数
如果这些步骤生成了格式良好的代码,实例化就成功了,但是,如果生成的代码格式不正确,则被认为是不可行的(由于替换失败,会抛弃之,继续寻找下一个)
使用 SFINAE
Example:需要一个算法 f
取代积分类型 T,并用第二个 f
取代浮点类型 T
来加它。
对于给定类型 T
,最少要实例化两种算法中的一种:
1 | template<class T> |
如果两个重载都可不行
例如,用字符串参数调用 f
会产生一个不成型的程序,因为两个候选人都将被 SFINAE 掉。就会得到一个编译错误,没有匹配的模版实例。
C++11 库元函数约定#2
具有值结果的元函数具有:
- 一个
static constexpr
成员,值,给出其结果… - 几个方便的成员类型和
constexpr
函数
经典的 C++11 返回值的元函数
1 | template<class T, T v> |
从 integral_constant
继承为元调用语法提供了更多选项(稍后介绍详情)
重构 rank 元函数
Example: 获取数组的类型(编译期)
1 | // 主模版处理标量类型(非数组)作为基本情况 |
一些不可或缺的持续便利
一个有用的方便别名:
1 | template<bool b> |
一些有用的 C++11 方便别名:
1 | using true_type = bool_constant<true>; |
返回值的元函数调用已经演变了:
is_void<T>::value
// 自技术报告1以来bool(is_void<T>{})
// 实例化/演示,C++11is_void<T>{}()
// 实例化/调用,C++14is_void_v<T>
// C++17