知识屋:更实用的电脑技术知识网站
所在位置:首页 > 科技

浅谈 C++ 元编程

发表时间:2022-03-25来源:网络

原文:《浅谈 C++ 元编程》,公众号 BOTManJL~

原文为 2017 程序设计语言结课论文,现已根据 C++ 17 标准更新。

随着 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 { constexpr static bool value = true; }; template constexpr bool isZero = _isZero::value; static_assert (!isZero, "compile error"); static_assert (isZero, "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 * _Factor,然后 _Factor 再展开为 1 * _Factor,最后 _Factor 直接匹配到参数为 0 的重载。

template constexpr unsigned _Factor () { return N * _Factor (); } template constexpr unsigned _Factor () { return 1; } template constexpr unsigned Factor = _Factor (); static_assert (Factor == 1, "compile error"); static_assert (Factor == 1, "compile error"); static_assert (Factor == 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::value, "compile error"); static_assert (std::is_same::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 限制接受支持运算符

收藏
  • 人气文章
  • 最新文章
  • 下载排行榜
  • 热门排行榜