浅谈C++元编程

随着 C++ 11/14/17 标准的不断更新,C++ 语言得到了极大的完善和补充。元编程作为一种新兴的编程方式,受到了越来越多的广泛关注。结合已有文献和个人实践,对有关 C++ 元编程进行了系统的分析。首先介绍了 C++ 元编程中的相关概念和背景,然后利用科学的方法分析了元编程的 演算规则、基本应用 和实践过程中的 主要难点,最后提出了对 C++ 元编程发展的 展望。

1 引言
1.1 什么是元编程
元编程 (metaprogramming) 通过操作 程序实体 (program entity),在 编译时 (compile time) 计算出 运行时 (runtime) 需要的常数、类型、代码的方法。

一般的编程是通过直接编写 程序 (program),通过编译器 编译 (compile),产生目标代码,并用于 运行时 执行。与普通的编程不同,元编程则是借助语言提供的 模板 (template) 机制,通过编译器 推导 (deduce),在 编译时 生成程序。元编程经过编译器推导得到的程序,再进一步通过编译器编译,产生最终的目标代码。在 § 2.1.3 中,用一个例子说明了两者的区别。

因此,元编程又被成为 两级编程 (two-level programming),生成式编程 (generative programming) 或 模板元编程 (template metaprogramming)。[1]

1.2 元编程在 C++ 中的位置
C++ 语言 = C 语言的超集 + 抽象机制 + 标准库
C++ 的 抽象机制 (abstraction mechanisms) 主要有两种:面向对象编程 (object-oriented programming) 和 模板编程 (generic programming)。[1]

为了实现面向对象编程,C++ 提供了 类 (class),用 C++ 的已有 类型 (type) 构造出新的类型。而在模板编程方面,C++ 提供了 模板 (template),以一种直观的方式表示 通用概念 (general concept)。

模板编程的应用主要有两种:泛型编程 (generic programming) 和 元编程 (meta-programming)。前者注重于 通用概念 的抽象,设计通用的 类型 或 算法 (algorithm),不需要过于关心编译器如何生成具体的代码;而后者注重于设计模板推导时的 选择 (selection) 和 迭代 (iteration),通过模板技巧设计程序。[1]

1.3 C++ 元编程的历史
1988 年,David R. Musser 和 Alexander A. Stepanov 提出了 模板 [2],并最早应用于 C++ 语言。Alexander A. Stepanov 等人在 Bjarne Stroustrup 的邀请下,参与了 C++ 标准模板库 (C++ Standard Template Library, C++ STL) (属于 C++ 标准库 的一部分) 的设计。[3] 模板的设计初衷仅是用于泛型编程,对数据结构和算法进行 抽象 (abstraction)。

而在现代 C++ 的时代,人们发现模板可以用于元编程。1994 年的 C++ 标准委员会会议上,Erwin Unruh 演示了一段利用编译器错误信息计算素数的代码。[4] 1995 年的 Todd Veldhuizen 在 C++ Report 上,首次提出了 C++ 模板元编程 的概念,并指出了其在数值计算上的应用前景。[5] 随后,Andrei Alexandrescu 提出了除了数值计算之外的元编程应用,并设计了一个通用的 C++ 的模板元编程库 —— Loki。[6] 受限于 C++ 对模板本身的限制,Andrei Alexandrescu 等人又发明了 D 语言,把元编程提升为语言自身的一个特性。[7]

元编程已被广泛的应用于现代 C++ 的程序设计中。由于元编程不同于一般的编程,在程序设计上更具有挑战性,所以受到了许多学者和工程师的广泛关注。

1.4 元编程的语言支持
C++ 的元编程主要依赖于语言提供的模板机制。除了模板,现代 C++ 还允许使用 constexpr 函数进行常量计算。[8] 由于 constexpr 函数功能有限,所以目前的元编程程序主要基于模板。这一部分主要总结 C++ 模板机制相关的语言基础,包括 狭义的模板 和 泛型 lambda 表达式。

1.4.1 狭义的模板
目前最新的 C++ 将模板分成了 4 类:类模板 (class template),函数模板 (function template),别名模板 (alias template) 和 变量模板 (variable template)。[9] 前两者能产生新的类型,属于 类型构造器 (type constructor);而后两者仅是语言提供的简化记法,属于 语法糖 (syntactic sugar)。

类模板 和 函数模板 分别用于定义具有相似功能的 类 和 函数 (function),是泛型中对 类型 和 算法 的抽象。在标准库中,容器 (container) 和 函数 都是 类模板 和 函数模板 的应用。

别名模板 和 变量模板 分别在 C++ 11 和 C++ 14 引入,分别提供了具有模板特性的 类型别名 (type alias) 和 常量 (constant) 的简记方法。前者 类模板的嵌套类 等方法实现,后者则可以通过 constexpr 函数、类模板的静态成员、函数模板的返回值 等方法实现。例如,C++ 14 中的 别名模板 std::enable_if_t 等价于 typename std::enable_if::type,C++ 17 中的 变量模板 std::is_same 等价于 std::is_same::value。尽管这两类模板不是必须的,但一方面可以增加程序的可读性(§ 4.1),另一方面可以提高模板的编译性能(§ 4.4)。

C++ 中的 模板参数 (template parameter / argument) 可以分为三种:值参数,类型参数,模板参数。[10] 从 C++ 11 开始,C++ 支持了 变长模板 (variadic template):模板参数的个数可以不确定,变长参数折叠为一个 参数包 (parameter pack) [11],使用时通过编译时迭代,遍历各个参数(§ 2.2.2)。标准库中的 元组 (tuple) —— std::tuple 就是变长模板的一个应用(元组的 类型参数 是不定长的,可以用 template 匹配)。

尽管 模板参数 也可以当作一般的 类型参数 进行传递(模板也是一个类型),但之所以单独提出来,是因为它可以实现对传入模板的参数匹配。§ 3.2 的例子(代码 8)使用 std::tuple 作为参数,然后通过匹配的方法,提取 std::tuple 内部的变长参数。

特化 (specialization) 类似于函数的 重载 (overload),即给出 全部模板参数取值(完全特化)或 部分模板参数取值(部分特化)的模板实现。实例化 (instantiation) 类似于函数的 绑定 (binding),是编译器根据参数的个数和类型,判断使用哪个重载的过程。由于函数和模板的重载具有相似性,所以他们的参数 重载规则 (overloading rule) 也是相似的。

1.4.2 泛型 lambda 表达式
由于 C++ 不允许在函数内定义模板,有时候为了实现函数内的局部特殊功能,需要在函数外专门定义一个模板。一方面,这导致了代码结构松散,不易于维护;另一方面,使用模板时,需要传递特定的 上下文 (context),不易于复用。(类似于 C 语言里的回调机制,不能在函数内定义回调函数,需要通过参数传递上下文。)

为此,C++ 14 引入了 泛型 lambda 表达式 (generic lambda expression) [12]:一方面,能像 C++ 11 引入的 lambda 表达式一样,在函数内构造 闭包 (closure),避免在 函数外定义 函数内使用 的局部功能;另一方面,能实现 函数模板 的功能,允许传递任意类型的参数。

2 元编程的基本演算
C++ 的模板机制仅仅提供了 纯函数 (pure functional) 的方法,即不支持变量,且所有的推导必须在编译时完成。但是 C++ 中提供的模板是 图灵完备 (turing complete) 的 [13],所以可以使用模板实现完整的元编程。

元编程的基本 演算规则 (calculus rule) 有两种:编译时测试 (compile-time test) 和 编译时迭代 (compile-time iteration) [1],分别实现了 控制结构 (control structure) 中的 选择 (selection) 和 迭代 (iteration)。基于这两种基本的演算方法,可以完成更复杂的演算。

2.1 编译时测试
编译时测试 相当于面向过程编程中的 选择语句 (selection statement),可以实现 if-else / switch 的选择逻辑。

在 C++ 17 之前,编译时测试是通过模板的 实例化 和 特化 实现的 —— 每次找到最特殊的模板进行匹配;而 C++ 17 提出了使用 constexpr-if 的编译时测试方法。

2.1.1 测试表达式
类似于 静态断言 (static assert),编译时测试的对象是 常量表达式 (constexpr),即编译时能得出结果的表达式。以不同的常量表达式作为参数,可以构造各种需要的模板重载。例如,代码 1 演示了如何构造 谓词 (predicate) isZero,编译时判断 Val 是不是 0。

template struct _isZero {
constexpr static bool value = false;
};

template <> struct _isZero <0> {
constexpr static bool value = true;
};

template
constexpr bool isZero = _isZero::value;

static_assert (!isZero<1>, “compile error”);
static_assert (isZero<0>, “compile error”);
代码 1 - 编译时测试表达式

2.1.2 测试类型
在元编程的很多应用场景中,需要对类型进行测试,即对不同的类型实现不同的功能。而常见的测试类型又分为两种:判断一个类型 是否为特定的类型 和 是否满足某些条件。前者可以通过对模板的 特化 直接实现;后者既能通过 替换失败不是错误 SFINAE (Substitution Failure Is Not An Error) 规则进行最优匹配 [14],又能通过 标签派发 (tag dispatch) 匹配可枚举的有限情况 [15]。

为了更好的支持 SFINAE,C++ 11 的 除了提供类型检查的谓词模板 is_/has_,还提供了两个重要的辅助模板 [14]:

std::enable_if 将对条件的判断 转化为常量表达式,类似测试表达式(§ 2.1.1)实现重载的选择(但需要添加一个冗余的 函数参数/函数返回值/模板参数);
std::void_t 直接 检查依赖 的成员/函数是否存在,不存在则无法重载(可以用于构造谓词,再通过 std::enable_if 判断条件)。
是否为特定的类型 的判断,类似于代码 1,将 unsigned Val 改为 typename Type;并把传入的模板参数由 值参数 改为 类型参数,根据最优原则匹配重载。

是否满足某些条件 的判断,在代码 2 中,展示了如何将 C 语言的基本类型数据,转换为 std::string 的函数 ToString。代码具体分为三个部分:

首先定义三个 变量模板isNum/isStr/isBad,分别对应了三个类型条件的谓词(使用了 中的 std::is_arithmetic 和 std::is_same);
然后根据 SFINAE 规则,使用 std::enable_if 重载函数 ToString,分别对应了数值、C 风格字符串和非法类型;
在前两个重载中,分别调用 std::to_string 和 std::string 构造函数;在最后一个重载中,静态断言直接报错。
template
constexpr bool isNum = std::is_arithmetic::value;

template
constexpr bool isStr = std::is_same::value;

template
constexpr bool isBad = !isNum && !isStr;

template
std::enable_if_t ToString (T num) {
return std::to_string (num);
}

template
std::enable_if_t ToString (T str) {
return std::string (str);
}

template
std::enable_if_t ToString (T bad) {
static_assert (sizeof (T) == 0, “neither Num nor Str”);
}

auto a = ToString (1); // std::to_string (num);
auto b = ToString (1.0); // std::to_string (num);
auto c = ToString (“0x0”); // std::string (str);
auto d = ToString (std::string {}); // not compile
代码 2 - 编译时测试类型

根据 两阶段名称查找 (two-phase name lookup) [16] 的规定:如果直接使用 static_assert (false) 断言,会在模板还没实例化的第一阶段编译失败;所以需要借助 类型依赖 (type-dependent) 的 false 表达式(一般依赖于参数 T)进行失败的静态断言。

类似的,可以通过定义一个 变量模板 template constexpr bool false_v = false;,并使用 false_v 替换 sizeof (T) == 0。[17]

2.1.3 使用 if 进行编译时测试
对于初次接触元编程的人,往往会使用 if 语句进行编译时测试。代码 3 是 代码 2 一个 错误的写法,很代表性的体现了元编程和普通编程的不同之处(§ 1.1)。

template
std::string ToString (T val) {
if (isNum) return std::to_string (val);
else if (isStr) return std::string (val);
else static_assert (!isBad, “neither Num nor Str”);
}
代码 3 - 编译时测试类型的错误用法

代码 3 中的错误在于:编译代码的函数 ToString 时,对于给定的类型 T,需要进行两次函数绑定 —— val 作为参数分别调用 std::to_string (val) 和 std::string (val),再进行一次静态断言 —— 判断 !isBad 是否为 true。这会导致:两次绑定中,有一次会失败。假设调用 ToString (“str”),在编译这段代码时,std::string (const char *) 可以正确的重载,但是 std::to_string (const char *) 并不能找到正确的重载,导致编译失败。

假设是脚本语言,这段代码是没有问题的:因为脚本语言没有编译的概念,所有函数的绑定都在 运行时 完成;而静态语言的函数绑定是在 编译时 完成的。为了使得代码 3 的风格用于元编程,C++ 17 引入了 constexpr-if [18] —— 只需要把以上代码 3 中的 if 改为 if constexpr 就可以编译了。

constexpr-if 的引入让模板测试更加直观,提高了模板代码的可读性(§ 4.1)。代码 4 展示了如何使用 constexpr-if 解决编译时选择的问题;而且最后的 兜底 (catch-all) 语句,不再需要 isBad 谓词模板,可以使用类型依赖的 false 表达式进行静态断言(但也不能直接使用 static_assert (false) 断言)。[18]

template
std::string ToString (T val) {
if constexpr (isNum) return std::to_string (val);
else if constexpr (isStr) return std::string (val);
else static_assert (false_v, “neither Num nor Str”);
}
代码 4 - 编译时测试类型的正确用法

然而,constexpr-if 背后的思路早在 Visual Studio 2012 已出现了。其引入了 __if_exists 语句,用于编译时测试标识符是否存在。[19]

2.2 编译时迭代
编译时迭代 和面向过程编程中的 循环语句 (loop statement) 类似,用于实现与 for / while / do 类似的循环逻辑。

在 C++ 17 之前,和普通的编程不同,元编程的演算规则是纯函数的,不能通过 变量迭代 实现编译时迭代,只能用 递归 (recursion) 和 特化 的组合实现。一般思路是:提供两类重载 —— 一类接受 任意参数,内部 递归 调用自己;另一类是前者的 模板特化 或 函数重载,直接返回结果,相当于 递归终止条件。它们的重载条件可以是 表达式 或 类型(§ 2.1)。

而 C++ 17 提出了 折叠表达式 (fold expression) 的语法,化简了迭代的写法。

2.2.1 定长模板的迭代
代码 5 展示了如何使用 编译时迭代 实现编译时计算阶乘(N!)。函数 _Factor 有两个重载:一个是对任意非负整数的,一个是对 0 为参数的。前者利用递归产生结果,后者直接返回结果。当调用 _Factor<2> 时,编译器会展开为 2 * _Factor<1>,然后 _Factor<1> 再展开为 1 * _Factor<0>,最后 _Factor<0> 直接匹配到参数为 0 的重载。

template
constexpr unsigned _Factor () { return N * _Factor (); }

template <>
constexpr unsigned _Factor<0> () { return 1; }

template
constexpr unsigned Factor = _Factor ();

static_assert (Factor<0> == 1, “compile error”);
static_assert (Factor<1> == 1, “compile error”);
static_assert (Factor<4> == 24, “compile error”);
代码 5 - 编译时迭代计算阶乘(N!)

2.2.2 变长模板的迭代
为了遍历变长模板的每个参数,可以使用 编译时迭代 实现循环遍历。代码 6 实现了对所有参数求和的功能。函数 Sum 有两个重载:一个是对没有函数参数的情况,一个是对函数参数个数至少为 1 的情况。和定长模板的迭代类似(§ 2.2.1),这里也是通过 递归 调用实现参数遍历。

template
constexpr auto Sum () {
return T (0);
}

template
constexpr auto Sum (T arg, Ts… args) {
return arg + Sum (args…);
}

static_assert (Sum () == 0, “compile error”);
static_assert (Sum (1, 2.0, 3) == 6, “compile error”);
代码 6 - 编译时迭代计算和(Σ)

2.2.3 使用折叠表达式化简编译时迭代
在 C++ 11 引入变长模板时,就支持了在模板内直接展开参数包的语法 [11];但该语法仅支持对参数包里的每个参数进行 一元操作 (unary operation);为了实现参数间的 二元操作 (binary operation),必须借助额外的模板实现(例如,代码 6 定义了两个 Sum 函数模板,其中一个展开参数包进行递归调用)。

而 C++ 17 引入了折叠表达式,允许直接遍历参数包里的各个参数,对其应用 二元运算符 (binary operator) 进行 左折叠 (left fold) 或 右折叠 (right fold)。[20] 代码 7 使用初始值为 0 的左折叠表达式,对代码 6 进行改进。

template
constexpr auto Sum (Ts… args) {
return (0 + … + args);
}

static_assert (Sum () == 0, “compile error”);
static_assert (Sum (1, 2.0, 3) == 6, “compile error”);
代码 7 - 编译时折叠表达式计算和(Σ)

3 元编程的基本应用
利用元编程,可以很方便的设计出 类型安全 (type safe)、运行时高效 (runtime effective) 的程序。到现在,元编程已被广泛的应用于 C++ 的编程实践中。例如,Todd Veldhuizen 提出了使用元编程的方法构造 表达式模板 (expression template),使用表达式优化的方法,提升向量计算的运行速度 [21];K. Czarnecki 和 U. Eisenecker 利用模板实现 Lisp 解释器 [22]。

尽管元编程的应用场景各不相同,但都是三类基本应用的组合:数值计算 (numeric computation)、类型推导 (type deduction) 和 代码生成 (code generation)。例如,在 BOT Man 设计的 对象关系映射 (object-relation mapping, ORM) 中,主要使用了 类型推导 和 代码生成 的功能。根据 对象 (object) 在 C++ 中的类型,推导出对应数据库 关系 (relation) 中元组各个字段的类型;将对 C++ 对象的操作,映射到对应的数据库语句上,并生成相应的代码。[23] [24]

3.1 数值计算
作为元编程的最早的应用,数值计算可以用于 编译时常数计算 和 优化运行时表达式计算。

编译时常数计算 能让程序员使用程序设计语言,写编译时确定的常量;而不是直接写常数(迷之数字 (magic number))或 在运行时计算这些常数。例如,§ 2.2 的几个例子(代码 5, 6, 7)都是编译时对常数的计算。

最早的有关元编程 优化表达式计算 的思路是 Todd Veldhuizen 提出的。[21] 利用表达式模板,可以实现部分求值、惰性求值、表达式化简等特性。

3.2 类型推导
除了基本的数值计算之外,还可以利用元编程进行任意类型之间的相互推导。例如,在 领域特定语言 (domain-specific language) 和 C++ 语言原生结合时,类型推导可以实现将这些语言中的类型,转化为 C++ 的类型,并保证类型安全。

BOT Man 提出了一种能编译时进行 SQL 语言元组类型推导的方法。[24] C++ 所有的数据类型都不能为 NULL;而 SQL 的字段是允许为 NULL 的,所以在 C++ 中使用 std::optional 容器存储可以为空的字段。通过 SQL 的 outer-join 拼接得到的元组的所有字段都可以为 NULL,所以 ORM 需要一种方法:把字段可能是 std::optional 或 T 的元组,转化为全部字段都是 std::optional 的新元组。

template struct TypeToNullable {
using type = std::optional;
};
template struct TypeToNullable {
using type = std::optional;
};

template
auto TupleToNullable (const std::tuple &) {
return std::tuple {};
}

auto t1 = std::make_tuple (std::optional {}, int {});
auto t2 = TupleToNullable (t1);
static_assert (!std::is_same<
std::tuple_element_t<0, decltype (t1)>,
std::tuple_element_t<1, decltype (t1)>

::value, “compile error”);
static_assert (std::is_same<
std::tuple_element_t<0, decltype (t2)>,
std::tuple_element_t<1, decltype (t2)>
::value, “compile error”);
代码 8 - 类型推导

代码 8 展示了这个功能:

定义 TypeToNullable,并对 std::optional 进行特化,作用是将 std::optional 和 T 自动转换为 std::optional;
定义 TupleToNullable,拆解元组中的所有类型,转化为参数包,再把参数包中所有类型分别传入 TypeToNullable,最后得到的结果重新组装为新的元组。
3.3 代码生成
和泛型编程一样,元编程也常常被用于代码的生成。但是和简单的泛型编程不同,元编程生成的代码往往是通过 编译时测试 和 编译时迭代 的演算推导出来的。例如,§ 2.1.2 中的代码 2 就是一个将 C 语言基本类型转化为 std::string 的代码的生成代码。

在实际项目中,我们往往需要将 C++ 数据结构,和实际业务逻辑相关的 领域模型 (domain model) 相互转化。例如,将承载着领域模型的 JSON 字符串 反序列化 (deserialize) 为 C++ 对象,再做进一步的业务逻辑处理,然后将处理后的 C++ 对象 序列化 (serialize) 变为 JSON 字符串。而这些序列化/反序列化的代码,一般不需要手动编写,可以自动生成。

BOT Man 提出了一种基于 编译时多态 (compile-time polymorphism) 的方法,定义领域模型的 模式 (schema),自动生成领域模型和 C++ 对象的序列化/反序列化的代码。[25] 这样,业务逻辑的处理者可以更专注于如何处理业务逻辑,而不需要关注如何做底层的数据结构转换。

4 元编程的主要难点
尽管元编程的能力丰富,但学习、使用的难度都很大。一方面,复杂的语法和运算规则,往往让初学者望而却步;另一方面,即使是有经验的 C++ 开发者,也可能掉进元编程 “看不见的坑” 里。

4.1 复杂性
由于元编程的语言层面上的限制较大,所以许多的元编程代码使用了很多的 编译时测试 和 编译时迭代 技巧,可读性 (readability) 都比较差。另外,由于巧妙的设计出编译时能完成的演算也是很困难的,相较于一般的 C++ 程序,元编程的 可写性 (writability) 也不是很好。

现代 C++ 也不断地增加语言的特性,致力于降低元编程的复杂性:

C++ 11 的 别名模板(§ 1.4)提供了对模板中的类型的简记方法;
C++ 14 的 变量模板(§ 1.4)提供了对模板中常量的简记方法;
C++ 17 的 constexpr-if(§ 2.1.3)提供了 编译时测试 的新写法;
C++ 17 的 折叠表达式(§ 2.2.3)降低了 编译时迭代 的编写难度。
基于 C++ 14 的 泛型 lambda 表达式(§ 1.4.2),Louis Dionne 设计的元编程库 Boost.Hana 提出了 不用模板就能元编程 的理念,宣告从 模板元编程 (template metaprogramming) 时代进入 现代元编程 (modern metaprogramming) 时代。[26] 其核心思想是:只需要使用 C++ 14 的泛型 lambda 表达式和 C++ 11 的 constexpr/decltype,就可以快速实现元编程的基本演算了。

4.2 实例化错误
模板的实例化 和 函数的绑定 不同:在编译前,前者对传入的参数是什么,没有太多的限制;而后者则根据函数的声明,确定了应该传入参数的类型。而对于模板实参内容的检查,则是在实例化的过程中完成的(§ 4.2)。所以,程序的设计者在编译前,很难发现实例化时可能产生的错误。

为了减少可能产生的错误,Bjarne Stroustrup 等人提出了在 语言层面 上,给模板上引入 概念 (concept)。[1] 利用概念,可以对传入的参数加上 限制 (constraint),即只有满足特定限制的类型才能作为参数传入模板。[27] 例如,模板 std::max 限制接受支持运算符 < 的类型传入。但是由于各种原因,这个语言特性一直没有能正式加入 C++ 标准(可能在 C++ 20 中加入)。尽管如此,编译时仍可以通过 编译时测试 和 静态断言 等方法(§ 2.1.2)实现检查。

另外,编译时模板的实例化出错位置,在调用层数较深处时,编译器会提示每一层实例化的状态,这使得报错信息包含了很多的无用信息,很难让人较快的发现问题所在。BOT Man 提出了一种 短路编译 (short-circuit compiling) 的方法,能让基于元编程的 库 (library),给用户提供更人性化的编译时报错。具体方法是,在 实现 (implementation) 调用需要的操作之前,接口 (interface) 先检查是传入的参数否有对应的操作;如果没有,就通过短路的方法,转到一个用于报错的接口,然后停止编译并使用 静态断言 提供报错信息。[24] Paul Fultz II 提出了一种类似于 C++ 20 “概念/限制” 的接口检查方法,通过定义概念对应的 特性 (traits) 模板,然后在使用前检查特性是否满足。[28]

4.3 代码膨胀
由于模板会对所有不同模板实参都进行一次实例化,所以当参数的组合很多的时候,很可能会发生 代码膨胀 (code bloat),即产生体积巨大的代码。这些代码可以分为两种:死代码 (dead code) 和 有效代码 (effective code)。

在元编程中,很多时候只关心推导的结果,而不是过程。例如,§ 2.2.1 的代码 5 中,只关心最后的 Factor<4> == 24,而不需要中间过程中产生的临时模板。但是在 N 很大的时候,编译会产生很多临时模板。这些临时模板是 死代码,即不被执行的代码。所以,编译器会自动优化最终的代码生成,在 链接时 (link-time) 移除这些无用代码,使得最终的目标代码不会包含它们。尽管如此,如果产生过多的死代码,会浪费宝贵的 编译时间。(在 § 4.4 中详细讨论)

另一种情况下,展开的代码都是 有效代码,即都是被执行的,但是又由于需要的参数的类型繁多,最后的代码体积仍然很大。编译器很难优化这些代码,所以程序员应该在 设计时编码代码膨胀。Bjarne Stroustrup 提出了一种消除 冗余运算 (redundant calculation) 的方法,用于缩小模板实例体积。具体思路是,将不同参数实例化得到的模板的 相同部分 抽象为一个 基类 (base class),然后 “继承” 并 “重载” 每种参数情况的 不同部分,从而实现更多代码的共享。

例如,在 std::vector 的实现中,对 T * 和 void * 进行了特化;然后将所有的 T * 的实现 继承 到 void * 的实现上,并在公开的函数里通过强制类型转换,进行 void * 和 T * 的相互转换;最后这使得所有的指针的 std::vector 就可以共享同一份实现,从而避免了代码膨胀。(代码 9)

template class vector; // general
template class vector; // partial spec
template <> class vector; // complete spec

template
class vector : private vector
{
using Base = Vector;
public:
T∗& operator[] (int i) {
return reinterpret_cast(Base::operator[] (i));
}

}
代码 9 - 特化 std::vector 避免代码膨胀 [1]

4.4 编译性能
元编程尽管不会带来额外的 运行时开销 (runtime overhead),但如果过度使用,可能会大大增加编译时间(尤其是在大型项目中)。为了提高元编程的编译性能,需要使用特殊的技巧进行优化。为了衡量编译性能的优化效果,Louis Dionne 设计了一个基于 CMake 的编译时间基准测试框架。[29]

Chiel Douwes 对元编程中的常用模板操作进行了深入分析,对比了几种 模板操作的代价 (Cost of operations: The Rule of Chiel)(没有提到 C++ 14 的变量模板;从高到低):[30]

替换失败不是错误 SFINAE
实例化 函数模板
实例化 类模板
使用 别名模板
添加参数到 类模板
添加参数到 别名模板
使用 缓存的类型
基于以上原则,Odin Holmes 设计了类型运算库 Kvasir,相比基于 C++ 98/11 的类型运算库,拥有极高的编译性能。[30]

另外,Mateusz Pusz 总结了一些元编程性能的实践经验。例如,基于 C++ 11 别名模板的 std::conditional_t 和基于 C++ 14 变量模板的 std::is_same_v 都比基于 std::conditional/std::is_same 的传统方案更快。代码 10 展示了基于 std::is_same 和直接基于变量模板的 std::is_same_v 的实现。

// traditional, slow
template
struct is_same : std::false_type {};
template
struct is_same : std::true_type {};
template
constexpr bool is_same_v = is_same::value;

// using variable template, fast
template
constexpr bool is_same_v = false;
template
constexpr bool is_same_v = true;
代码 10 - 优化前后的 std::is_same_v

4.5 调试模板
元编程在运行时主要的难点在于:对模板代码的 调试 (debugging)。如果需要调试的是一段通过很多次的 编译时测试(§ 2.1)和 编译时迭代(§ 2.2)展开的代码,即这段代码是各个模板的拼接生成的(而且展开的层数很多);那么,调试时需要不断地在各个模板的 实例 (instance) 间来回切换。这种情景下,调试人员很难把具体的问题定位到展开后的代码上。

所以,一些大型项目很少使用复杂的代码生成技巧(§ 3.3),而是通过传统的代码生成器生成重复的代码,易于调试。例如 Chromium 的 通用扩展接口 (common extension api) 通过定义 JSON/IDL 文件,通过代码生成器生成相关的 C++ 代码,同时还可以生成接口文档。[31]

5 总结
C++ 元编程的出现,是一个无心插柳的偶然 —— 人们发现 C++ 语言提供的模板抽象机制,能很好的被应用于元编程上。借助元编程,可以写出 类型安全、运行时高效 的代码。但是,过度的使用元编程,一方面会 增加编译时间,另一方面会 降低程序的可读性。不过,在 C++ 不断地演化中,新的语言特性被不断提出,为元编程提供更多的可能。

本文主要内容是我对 C++ 元编程的 个人理解。对本文有什么问题,欢迎斧正。

This article is published under MIT License © 2017, BOT Man

参考文献
[1] ^^^^^^ Bjarne Stroustrup. The C++ Programming Language (Fourth Edition) [M] Addison-Wesley, 2013.
[2] ^ David R. Musser, Alexander A. Stepanov. Generic Programming [C] // P. Gianni. In Symbolic and Algebraic Computation: International symposium ISSAC, 1988: 13–25.
[3] ^ Bjarne Stroustrup: The Design and Evolution of C++ [M] Addison-Wesley, 1994.
[4] ^ Erwin Unruh. Primzahlen Original [EB/OL] http://www.erwin-unruh.de/primorig.html
[5] ^ Todd Veldhuizen. Using C++ template metaprograms [C] // S. B. Lippman. In C++ Report, 1995, 7(4): 36-43.
[6] ^ Andrei Alexandrescu. Modern C++ Design [M] Addison-Wesley, 2001.
[7] ^ D Language Foundation. Home - D Programming Language [EB/OL] https://dlang.org/
[8] ^ cppreference.com. constexpr specifier [EB/OL] http://en.cppreference.com/w/cpp/language/constexpr
[9] ^ cppreference.com. Templates [EB/OL] http://en.cppreference.com/w/cpp/language/templates
[10] ^ cppreference.com. Template parameters and template arguments [EB/OL] http://en.cppreference.com/w/cpp/language/template_parameters
[11] ^^ cppreference.com. Parameter pack (since C++11) [EB/OL] https://en.cppreference.com/w/cpp/language/parameter_pack
[12] ^ Faisal Vali, Herb Sutter, Dave Abrahams. Generic (Polymorphic) Lambda Expressions (Revision 3) [EB/OL] http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2013/n3649.html
[13] ^ Todd L. Veldhuizen. C++ Templates are Turing Complete [J] Indiana University Computer Science Technical Report. 2003.
[14] ^^ cppreference.com. SFINAE [EB/OL] http://en.cppreference.com/w/cpp/language/sfinae
[15] ^ cppreference.com. iterator category tags [EB/OL] https://en.cppreference.com/w/cpp/iterator/iterator_tags#Example
[16] ^ John Wilkinson, Jim Dehnert, Matt Austern. A Proposed New Template Compilation Model [EB/OL] http://www.open-std.org/jtc1/sc22/wg21/docs/papers/1996/n0906.pdf
[17] ^ Arthur O’Dwyer. Use-cases for false_v [EB/OL] https://quuxplusone.github.io/blog/2018/04/02/false-v/
[18] ^^ cppreference.com. if statement [EB/OL] http://en.cppreference.com/w/cpp/language/if
[19] ^ Microsoft Docs. __if_exists Statement [EB/OL] https://docs.microsoft.com/en-us/cpp/cpp/if-exists-statement?view=vs-2017
[20] ^ cppreference.com. fold expression (since C++17) [EB/OL] https://en.cppreference.com/w/cpp/language/fold
[21] ^^ Todd Veldhuizen. Expression Templates [C] // S. B. Lippman. In C++ Report, 1995, 7(5): 26–31.
[22] ^ K. Czarnecki, U. Eisenecker. Generative Programming: Methods, Tools, and Applications [M] Addison-Wesley, 2000.
[23] ^ BOT Man JL. How to Design a Naive C++ ORM [EB/OL] https://bot-man-jl.github.io/articles/?post=2016/How-to-Design-a-Naive-Cpp-ORM
[24] ^^^ BOT Man JL. How to Design a Better C++ ORM [EB/OL] https://bot-man-jl.github.io/articles/?post=2016/How-to-Design-a-Better-Cpp-ORM
[25] ^ BOT Man JL. C++ Struct Field Reflection [EB/OL] https://bot-man-jl.github.io/articles/?post=2018/Cpp-Struct-Field-Reflection
[26] ^ Boost. Your standard library for metaprogramming [EB/OL] https://github.com/boostorg/hana
[27] ^ cppreference.com. Constraints and concepts [EB/OL] http://en.cppreference.com/w/cpp/language/constraints
[28] ^ Paul Fultz II. Goodbye metaprogramming, and hello functional: Living in a post-metaprogramming era in C++ [EB/OL] https://github.com/boostcon/cppnow_presentations_2016/blob/master/03_friday/goodbye_metaprogramming_and_hello_functional_living_in_a_post_metaprogramming_era_in_cpp.pdf
[29] ^ Louis Dionne. A simple framework for compile-time benchmarks [EB/OL] https://github.com/ldionne/metabench
[30] ^^ Odin Holmes. Type Based Template Metaprogramming is Not Dead [EB/OL] https://github.com/boostcon/cppnow_presentations_2017/blob/master/05-17-2017_wednesday/type_based_template_metaprogramming_is_not_dead__odin_holmes__cppnow_05-17-2017.pdf
[31] ^ Chromium. Extension API Functions [EB/OL] https://github.com/chromium/chromium/blob/master/extensions/docs/api_functions.md

​1

你可能感兴趣的:(c++,编程语言)