浅谈 C++ 元编程
Posted 一去丶二三里
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了浅谈 C++ 元编程相关的知识,希望对你有一定的参考价值。
置顶/星标公众号????,硬核文章第一时间送达!
随着 C++ 11/14/17 标准的不断更新,C++ 语言得到了极大的完善和补充。元编程作为一种新兴的编程方式,受到了越来越多的广泛关注。结合已有文献和个人实践,对有关 C++ 元编程进行了系统的分析。首先介绍了 C++ 元编程中的相关概念和背景,然后利用科学的方法分析了元编程的 演算规则、基本应用 和实践过程中的 主要难点,最后提出了对 C++ 元编程发展的 展望。
1. 引言
1.1 什么是元编程
元编程 (metaprogramming) 通过操作 程序实体 (program entity),在 编译时 (compile time) 计算出 运行时 (runtime) 需要的常数、类型、代码的方法。
一般的编程是通过直接编写 程序 (program),通过编译器 编译 (compile),产生目标代码,并用于 运行时 执行。与普通的编程不同,元编程则是借助语言提供的 模板 (template) 机制,通过编译器 推导 (deduce),在 编译时 生成程序。元编程经过编译器推导得到的程序,再进一步通过编译器编译,产生最终的目标代码。在使用 if
进行编译时测试中,用一个例子说明了两者的区别。
因此,元编程又被成为 两级编程 (two-level programming),生成式编程 (generative programming) 或 模板元编程 (template metaprogramming)。
1.2 元编程在 C++ 中的位置
C++ 语言 = C 语言的超集 + 抽象机制 + 标准库
C++ 的 抽象机制 (abstraction mechanisms) 主要有两种:面向对象编程 (object-oriented programming) 和 模板编程 (generic programming)。
为了实现面向对象编程,C++ 提供了 类 (class),用 C++ 的已有 类型 (type) 构造出新的类型。而在模板编程方面,C++ 提供了 模板 (template),以一种直观的方式表示 通用概念 (general concept)。
模板编程的应用主要有两种:泛型编程 (generic programming) 和 元编程 (meta-programming)。前者注重于 通用概念 的抽象,设计通用的 类型 或 算法 (algorithm),不需要过于关心编译器如何生成具体的代码;而后者注重于设计模板推导时的 选择 (selection) 和 迭代 (iteration),通过模板技巧设计程序。
1.3 C++ 元编程的历史
1988 年,David R. Musser 和 Alexander A. Stepanov 提出了 模板 ,并最早应用于 C++ 语言。Alexander A. Stepanov 等人在 Bjarne Stroustrup 的邀请下,参与了 C++ 标准模板库 (C++ Standard Template Library, C++ STL) (属于 C++ 标准库 的一部分) 的设计。模板的设计初衷仅是用于泛型编程,对数据结构和算法进行 抽象 (abstraction)。
而在现代 C++ 的时代,人们发现模板可以用于元编程。1994 年的 C++ 标准委员会会议上,Erwin Unruh 演示了一段利用编译器错误信息计算素数的代码。1995 年的 Todd Veldhuizen 在 C++ Report 上,首次提出了 C++ 模板元编程 的概念,并指出了其在数值计算上的应用前景。随后,Andrei Alexandrescu 提出了除了数值计算之外的元编程应用,并设计了一个通用的 C++ 的模板元编程库 —— Loki。受限于 C++ 对模板本身的限制,Andrei Alexandrescu 等人又发明了 D 语言,把元编程提升为语言自身的一个特性。
元编程已被广泛的应用于现代 C++ 的程序设计中。由于元编程不同于一般的编程,在程序设计上更具有挑战性,所以受到了许多学者和工程师的广泛关注。
1.4 元编程的语言支持
C++ 的元编程主要依赖于语言提供的模板机制。除了模板,现代 C++ 还允许使用 constexpr
函数进行常量计算。由于 constexpr
函数功能有限,所以目前的元编程程序主要基于模板。这一部分主要总结 C++ 模板机制相关的语言基础,包括 狭义的模板 和 泛型 lambda 表达式。
1.4.1 狭义的模板
目前最新的 C++ 将模板分成了 4 类:类模板 (class template),函数模板 (function template),别名模板 (alias template) 和 变量模板 (variable template)。前两者能产生新的类型,属于 类型构造器 (type constructor);而后两者仅是语言提供的简化记法,属于 语法糖 (syntactic sugar)。
类模板 和 函数模板 分别用于定义具有相似功能的 类 和 函数 (function),是泛型中对 类型 和 算法 的抽象。在标准库中,容器 (container) 和 函数 都是 类模板 和 函数模板 的应用。
别名模板 和 变量模板 分别在 C++ 11 和 C++ 14 引入,分别提供了具有模板特性的 类型别名 (type alias) 和 常量 (constant) 的简记方法。前者只能用于简记 已知类型,并不产生新的类型;后者则可以通过 函数模板返回值 等方法实现。尽管这两类模板不是必须的,但可以增加程序的可读性(复杂性)。例如,C++ 14 中的 别名模板 std::enable_if_t<T>
等价于 typename std::enable_if<T>::type
。
C++ 中的 模板参数 (template parameter / argument) 可以分为三种:值参数,类型参数,模板参数。从 C++ 11 开始,C++ 支持了 变长模板 (variadic template):模板参数的个数可以不确定,变长参数折叠为一个 参数包 (parameter pack) ,使用时通过编译时迭代,遍历各个参数([sec|变长模板的迭代])。标准库中的 元组 (tuple) —— std::tuple
就是变长模板的一个应用(元组的 类型参数 是不定长的,可以用 template<typename... Ts>
匹配)。
尽管 模板参数 也可以当作一般的 类型参数 进行传递(模板也是一个类型),但之所以单独提出来,是因为它可以实现对传入模板的参数匹配。类型推导的例子(代码)使用 std::tuple
作为参数,然后通过匹配的方法,提取 std::tuple
内部的变长参数。
特化 (specialization) 类似于函数的 重载 (overload),即给出 全部模板参数取值(完全特化)或 部分模板参数取值(部分特化)的模板实现。实例化 (instantiation) 类似于函数的 绑定 (binding),是编译器根据参数的个数和类型,判断使用哪个重载的过程。由于函数和模板的重载具有相似性,所以他们的参数 重载规则 (overloading rule) 也是相似的。
1.4.2 泛型 lambda 表达式
由于 C++ 不允许在函数内定义模板,有时候为了实现函数内的局部特殊功能,需要在函数外专门定义一个模板。一方面,这导致了代码结构松散,不易于维护;另一方面,使用模板时,需要传递特定的 上下文 (context),不易于复用。(类似于 C 语言里的回调机制,不能在函数内定义回调函数,需要通过参数传递上下文。)
为此,C++ 14 引入了 泛型 lambda 表达式 (generic lambda expression) :一方面,能像 C++ 11 引入的 lambda 表达式一样,在函数内构造 闭包 (closure),避免在 函数外定义 函数内使用 的局部功能;另一方面,能实现 函数模板 的功能,允许传递任意类型的参数。
2. 元编程的基本演算
C++ 的模板机制仅仅提供了 纯函数 (pure functional) 的方法,即不支持变量,且所有的推导必须在编译时完成。但是 C++ 中提供的模板是 图灵完备 (turing complete) 的,所以可以使用模板实现完整的元编程。
元编程的基本 演算规则 (calculus rule) 有两种:编译时测试 (compile-time test) 和 编译时迭代 (compile-time iteration) ,分别实现了 控制结构 (control structure) 中的 选择 (selection) 和 迭代 (iteration)。基于这两种基本的演算方法,可以完成更复杂的演算。
2.1 编译时测试
编译时测试 相当于面向过程编程中的 选择语句 (selection statement),可以实现 if-else
/ switch
的选择逻辑。
在 C++ 17 之前,编译时测试是通过模板的 实例化 和 特化 实现的 —— 每次找到最特殊的模板进行匹配;而 C++ 17 提出了使用 constexpr-if
的编译时测试方法。
2.1.1 测试表达式
类似于 静态断言 (static assert),编译时测试的对象是 常量表达式 (constexpr),即编译时能得出结果的表达式。以不同的常量表达式作为参数,可以构造各种需要的模板重载。例如,代码演示了如何构造 谓词 (predicate) isZero<Val>
,编译时判断 Val
是不是 0
。
template <unsigned Val> struct _isZero {
constexpr static bool value = false;
};
template <> struct _isZero <0> {
constexpr static bool value = true;
};
template <unsigned Val>
constexpr bool isZero = _isZero<Val>::value;
static_assert (!isZero<1>, "compile error");
static_assert (isZero<0>, "compile error");
代码编译时测试表达式
2.1.2 测试类型
在元编程的很多应用场景中,需要对类型进行测试,即对不同的类型实现不同的功能。而常见的测试类型又分为两种:判断一个类型 是否为特定的类型 和 是否满足某些条件。前者可以通过对模板的 特化 直接实现;后者既能通过 替换失败不是错误 SFINAE (Substitution Failure Is Not An Error) 规则进行最优匹配,又能通过 标签派发 (tag dispatch) 匹配可枚举的有限情况。
为了更好的支持 SFINAE,C++ 11 的 <type_traits>
除了提供类型检查的谓词模板 is_*
/has_*
,还提供了两个重要的辅助模板:
std::enable_if
将对条件的判断 转化为常量表达式,类似测试表达式实现重载的选择(但需要添加一个冗余的 函数参数/函数返回值/模板参数);std::void_t
直接 检查依赖 的成员/函数是否存在,不存在则无法重载(可以用于构造谓词,再通过std::enable_if
判断条件)。
是否为特定的类型 的判断,类似于代码,将 unsigned Val
改为 typename Type
;并把传入的模板参数由 值参数 改为 类型参数,根据最优原则匹配重载。
是否满足某些条件 的判断,在代码中,展示了如何将 C 语言的基本类型数据,转换为 std::string
的函数 ToString
。代码具体分为三个部分:
首先定义三个 变量模板 :
isNum
/isStr
/isBad
,分别对应了三个类型条件的谓词(使用了 中的std::is_arithmetic
和std::is_same
);然后根据 SFINAE 规则:
使用
std::enable_if
重载函数ToString
,分别对应了数值、C 风格字符串和非法类型;在前两个重载中:
分别调用
std::to_string
和std::string
构造函数;在最后一个重载中,通过 类型依赖 (type-dependent) 的false
表达式(例如sizeof (T) == 0
)静态断言直接报错(根据 两阶段名称查找 (two-phase name lookup)的规定,如果直接使用static_assert (false)
断言,会在模板还没实例化的第一阶段无法通过编译)。
template <typename T>
constexpr bool isNum = std::is_arithmetic<T>::value;
template <typename T>
constexpr bool isStr = std::is_same<T, const char *>::value;
template <typename T>
constexpr bool isBad = !isNum<T> && !isStr<T>;
template <typename T>
std::enable_if_t<isNum<T>, std::string> ToString (T num) {
return std::to_string (num);
}
template <typename T>
std::enable_if_t<isStr<T>, std::string> ToString (T str) {
return std::string (str);
}
template <typename T>
std::enable_if_t<isBad<T>, std::string> 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.1.3 使用 if 进行编译时测试
对于初次接触元编程的人,往往会使用 if
语句进行编译时测试。代码是代码 一个 错误的写法,很代表性的体现了元编程和普通编程的不同之处。
template <typename T>
std::string ToString (T val) {
if (isNum<T>) return std::to_string (val);
else if (isStr<T>) return std::string (val);
else static_assert (!isBad<T>, "neither Num nor Str");
}
代码编译时测试类型的错误用法
代码中的错误在于:编译代码的函数 ToString
时,对于给定的类型 T
,需要进行两次函数绑定 —— val
作为参数分别调用 std::to_string (val)
和 std::string (val)
,再进行一次静态断言 —— 判断 !isBad<T>
是否为 true
。这会导致:两次绑定中,有一次会失败。假设调用 ToString ("str")
,在编译这段代码时,std::string (const char *)
可以正确的重载,但是 std::to_string (const char *)
并不能找到正确的重载,导致编译失败。
假设是脚本语言,这段代码是没有问题的:因为脚本语言没有编译的概念,所有函数的绑定都在 运行时 完成;而静态语言的函数绑定是在 编译时 完成的。为了使得代码的风格用于元编程,C++ 17 引入了 constexpr-if
—— 只需要把以上代码中的 if
改为 if constexpr
就可以编译了。
constexpr-if
的引入让模板测试更加直观,提高了模板代码的可读性。代码展示了如何使用 constexpr-if
解决编译时选择的问题;而且最后的 兜底 (catch-all) 语句,可以使用类型依赖的 false
表达式进行静态断言,不再需要 isBad<T>
谓词模板(也不能直接使用 static_assert (false)
断言)。
template <typename T>
std::string ToString (T val) {
if constexpr (isNum<T>) return std::to_string (val);
else if constexpr (isStr<T>) return std::string (val);
else static_assert (sizeof (T) == 0, "neither Num nor Str");
}
代码编译时测试类型的正确用法
然而,constexpr-if
背后的思路早在 Visual Studio 2012 已出现了。其引入了 __if_exists
语句,用于编译时测试标识符是否存在。
2.2 编译时迭代
编译时迭代 和面向过程编程中的 循环语句 (loop statement) 类似:
用于实现与 for
/ while
/ do
类似的循环逻辑。
在 C++ 17 之前,和普通的编程不同,元编程的演算规则是纯函数的,不能通过 变量迭代 实现编译时迭代,只能用 递归 (recursion) 和 特化 的组合实现。一般思路是:提供两类重载 —— 一类接受 任意参数,内部 递归 调用自己;另一类是前者的 模板特化 或 函数重载,直接返回结果,相当于 递归终止条件。它们的重载条件可以是 表达式 或 类型。
而 C++ 17 提出了 折叠表达式 (fold expression) 的语法,化简了迭代的写法。
2.2.1 定长模板的迭代
代码展示了如何使用 编译时迭代 实现编译时计算阶乘(N!)。函数 _Factor
有两个重载:一个是对任意非负整数的,一个是对 0
为参数的。前者利用递归产生结果,后者直接返回结果。当调用 _Factor<2>
时,编译器会展开为 2 * _Factor<1>
,然后 _Factor<1>
再展开为 1 * _Factor<0>
,最后 _Factor<0>
直接匹配到参数为 0
的重载。
template <unsigned N>
constexpr unsigned _Factor () { return N * _Factor<N - 1> (); }
template <>
constexpr unsigned _Factor<0> () { return 1; }
template <unsigned N>
constexpr unsigned Factor = _Factor<N> ();
static_assert (Factor<0> == 1, "compile error");
static_assert (Factor<1> == 1, "compile error");
static_assert (Factor<4> == 24, "compile error");
代码编译时迭代计算阶乘(N!)
2.2.2 变长模板的迭代
为了遍历变长模板的每个参数,可以使用 编译时迭代 实现循环遍历。代码实现了对所有参数求和的功能。函数 Sum
有两个重载:一个是对没有函数参数的情况,一个是对函数参数个数至少为 1
的情况。和定长模板的迭代类似,这里也是通过 递归 调用实现参数遍历。
template <typename T>
constexpr auto Sum () {
return T (0);
}
template <typename T, typename... Ts>
constexpr auto Sum (T arg, Ts... args) {
return arg + Sum<T> (args...);
}
static_assert (Sum () == 0, "compile error");
static_assert (Sum (1, 2.0, 3) == 6, "compile error");
代码编译时迭代计算和(Σ)
2.2.3 使用折叠表达式化简编译时迭代
在 C++ 11 引入变长模板时,就支持了在模板内直接展开参数包的语法;但该语法仅支持对参数包里的每个参数进行 一元操作 (unary operation);为了实现参数间的 二元操作 (binary operation),必须借助额外的模板实现(例如,代码 定义了两个 Sum
函数模板,其中一个展开参数包进行递归调用)。
而 C++ 17 引入了折叠表达式,允许直接遍历参数包里的各个参数,对其应用 二元运算符 (binary operator) 进行 左折叠 (left fold) 或 右折叠 (right fold)。代码使用初始值为 0
的左折叠表达式,对代码进行改进。
template <typename... Ts>
constexpr auto Sum (Ts... args) {
return (0 + ... + args);
}
static_assert (Sum () == 0, "compile error");
static_assert (Sum (1, 2.0, 3) == 6, "compile error");
代码编译时折叠表达式计算和(Σ)
3. 元编程的基本应用
利用元编程,可以很方便的设计出 类型安全 (type safe)、运行时高效 (runtime effective) 的程序。到现在,元编程已被广泛的应用于 C++ 的编程实践中。例如,Todd Veldhuizen 提出了使用元编程的方法构造 表达式模板 (expression template),使用表达式优化的方法,提升向量计算的运行速度;K. Czarnecki 和 U. Eisenecker 利用模板实现 Lisp 解释器。
尽管元编程的应用场景各不相同,但都是三类基本应用的组合:数值计算 (numeric computation)、类型推导 (type deduction) 和 代码生成 (code generation)。例如,在 BOT Man 设计的 对象关系映射 (object-relation mapping, ORM) 中,主要使用了 类型推导 和 代码生成 的功能。根据 对象 (object) 在 C++ 中的类型,推导出对应数据库 关系 (relation) 中元组各个字段的类型;将对 C++ 对象的操作,映射到对应的数据库语句上,并生成相应的代码。
3.1 数值计算
作为元编程的最早的应用,数值计算可以用于 编译时常数计算 和 优化运行时表达式计算。
编译时常数计算 能让程序员使用程序设计语言,写编译时确定的常量;而不是直接写常数(迷之数字 (magic number))或 在运行时计算这些常数。例如,几个例子都是编译时对常数的计算。
最早的有关元编程 优化表达式计算 的思路是 Todd Veldhuizen 提出的。利用表达式模板,可以实现部分求值、惰性求值、表达式化简等特性。
3.2 类型推导
除了基本的数值计算之外,还可以利用元编程进行任意类型之间的相互推导。例如,在 领域特定语言 (domain-specific language) 和 C++ 语言原生结合时,类型推导可以实现将这些语言中的类型,转化为 C++ 的类型,并保证类型安全。
BOT Man 提出了一种能编译时进行 SQL 语言元组类型推导的方法。C++ 所有的数据类型都不能为 NULL
;而 SQL 的字段是允许为 NULL
的,所以在 C++ 中使用 std::optional
容器存储可以为空的字段。通过 SQL 的 outer-join
拼接得到的元组的所有字段都可以为 NULL
,所以 ORM 需要一种方法:把字段可能是 std::optional<T>
或 T
的元组,转化为全部字段都是 std::optional<T>
的新元组。
template <typename T> struct TypeToNullable {
using type = std::optional<T>;
};
template <typename T> struct TypeToNullable <std::optional<T>> {
using type = std::optional<T>;
};
template <typename... Args>
auto TupleToNullable (const std::tuple<Args...> &) {
return std::tuple<typename TypeToNullable<Args>::type...> {};
}
auto t1 = std::make_tuple (std::optional<int> {}, 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");
代码类型推导
代码展示了这个功能:
定义
TypeToNullable
并对std::optional
进行特化,作用是将std::optional
和T
自动转换为std::optional
;定义
TupleToNullable
,拆解元组中的所有类型,转化为参数包,再把参数包中所有类型分别传入TypeToNullable
,最后得到的结果重新组装为新的元组。
3.3 代码生成
和泛型编程一样,元编程也常常被用于代码的生成。但是和简单的泛型编程不同,元编程生成的代码往往是通过 编译时测试 和 编译时迭代 的演算推导出来的。例如,代码就是一个将 C 语言基本类型转化为 std::string
的代码的生成代码。
在实际项目中,我们往往需要将 C++ 数据结构,和实际业务逻辑相关的 领域模型 (domain model) 相互转化。例如,将承载着领域模型的 JSON 字符串 反序列化 (deserialize) 为 C++ 对象,再做进一步的业务逻辑处理,然后将处理后的 C++ 对象 序列化 (serialize) 变为 JSON 字符串。而这些序列化/反序列化的代码,一般不需要手动编写,可以自动生成。
BOT Man 提出了一种基于 编译时多态 (compile-time polymorphism) 的方法,定义领域模型的 模式 (schema),自动生成领域模型和 C++ 对象的序列化/反序列化的代码。这样,业务逻辑的处理者可以更专注于如何处理业务逻辑,而不需要关注如何做底层的数据结构转换。
4. 元编程的主要难点
由于 C++ 语言设计层面上没有专门考虑元编程的相关问题,所以实际元编程难度较大。元编程的难点主要有四类:复杂性、实例化错误、代码膨胀、调试模板。
4.1 复杂性
由于元编程的语言层面上的限制较大,所以许多的元编程代码使用了很多的 编译时测试 和 编译时迭代 技巧,可读性 (readability) 都比较差。另外,由于巧妙的设计出编译时能完成的演算也是很困难的,相较于一般的 C++ 程序,元编程的 可写性 (writability) 也不是很好。
现代 C++ 也不断地增加语言的特性,致力于降低元编程的复杂性:
C++ 11 的 别名模板提供了对模板中的类型的简记方法;
C++ 14 的 变量模板提供了对模板中常量的简记方法;
C++ 17 的 `constexpr-if`提供了 编译时测试 的新写法;
C++ 17 的 折叠表达式降低了 编译时迭代 的编写难度。
基于 C++ 14 的 泛型 lambda 表达式,元编程库 Boost.Hana 提出了 不用模板就能元编程 的理念,宣告从 模板元编程 (template metaprogramming) 时代进入 现代元编程 (modern metaprogramming) 时代。其核心思想是:只需要使用 C++ 14 的泛型 lambda 表达式和 C++ 11 的 constexpr
/decltype
,就可以快速实现元编程的基本演算了。
4.2 实例化错误
模板的实例化 和 函数的绑定 不同:在编译前,前者对传入的参数是什么,没有太多的限制;而后者则根据函数的声明,确定了应该传入参数的类型。而对于模板实参内容的检查,则是在实例化的过程中完成的。所以,程序的设计者在编译前,很难发现实例化时可能产生的错误。
为了减少可能产生的错误,Bjarne Stroustrup 等人提出了在 语言层面 上,给模板上引入 概念 (concept)。利用概念,可以对传入的参数加上 限制 (constraint),即只有满足特定限制的类型才能作为参数传入模板。例如,模板 std::max
限制接受支持运算符 <
的类型传入。但是由于各种原因,这个语言特性一直没有能正式加入 C++ 标准(可能在 C++ 20 中加入)。尽管如此,编译时仍可以通过 编译时测试 和 静态断言 等方法实现检查。
另外,编译时模板的实例化出错位置,在调用层数较深处时,编译器会提示每一层实例化的状态,这使得报错信息包含了很多的无用信息,很难让人较快的发现问题所在。BOT Man 提出了一种 短路编译 (short-circuit compiling) 的方法,能让基于元编程的 库 (library),给用户提供更人性化的编译时报错。具体方法是,在 实现 (implementation) 调用需要的操作之前,接口 (interface) 先检查是传入的参数否有对应的操作;如果没有,就通过短路的方法,转到一个用于报错的接口,然后停止编译并使用 静态断言 提供报错信息。
4.3 代码膨胀
由于模板会对所有不同模板实参都进行一次实例化,所以当参数的组合很多的时候,很可能会发生 代码膨胀 (code bloat),即产生体积巨大的代码。这些代码可以分为两种:死代码 (dead code) 和 有效代码 (effective code)。
在元编程中,很多时候只关心推导的结果,而不是过程。例如,代码中只关心最后的 Factor<4> == 24
,而不需要中间过程中产生的临时模板。但是在 N
很大的时候,编译会产生很多临时模板。这些临时模板是 死代码,即不被执行的代码。所以,编译器会自动优化最终的代码生成,在 链接时 (link-time) 移除这些无用代码,使得最终的目标代码不会包含它们。
另一种情况下,展开的代码都是 有效代码,即都是被执行的,但是又由于需要的参数的类型繁多,最后的代码体积仍然很大。编译器很难优化这些代码,所以程序员应该在 设计时编码代码膨胀。Bjarne Stroustrup 提出了一种消除 冗余运算 (redundant calculation) 的方法,用于缩小模板实例体积。具体思路是,将不同参数实例化得到的模板的 相同部分 抽象为一个 基类 (base class),然后 “继承” 并 “重载” 每种参数情况的 不同部分,从而实现更多代码的共享。
例如,在 std::vector
的实现中,对 T *
和 void *
进行了特化;然后将所有的 T *
的实现 继承 到 void *
的实现上,并在公开的函数里通过强制类型转换,进行 void *
和 T *
的相互转换;最后这使得所有的指针的 std::vector
就可以共享同一份实现,从而避免了代码膨胀。
template <typename T> class vector; // general
template <typename T> class vector<T *>; // partial spec
template <> class vector<void *>; // complete spec
template <typename T>
class vector<T *> : private vector<void *>
{
using Base = Vector<void∗>;
public:
T∗& operator[] (int i) {
return reinterpret_cast<T∗&>(Base::operator[] (i));
}
...
}
代码特化 std::vector
避免代码膨胀
4.4 调试模板
元编程在运行时主要的难点在于:对模板代码的 调试 (debugging)。如果需要调试的是一段通过很多次的 编译时测试和 编译时迭代展开的代码,即这段代码是各个模板的拼接生成的(而且展开的层数很多);那么,调试时需要不断地在各个模板的 实例 (instance) 间来回切换。这种情景下,调试人员很难把具体的问题定位到展开后的代码上。
所以,一些大型项目很少使用复杂的代码生成技巧,而是通过传统的代码生成器生成重复的代码,易于调试。例如 Chromium 的 通用扩展接口 (common extension api) 通过定义 JSON/IDL 文件,通过代码生成器生成相关的 C++ 代码。
5. 总结
C++ 元编程的出现,是一个无心插柳的偶然 —— 人们发现 C++ 语言提供的模板抽象机制,能很好的被应用于元编程上。借助元编程,可以写出 类型安全、运行时高效 的代码。但是,过度的使用元编程,一方面会 增加编译时间,另一方面会 降低程序的可读性。不过,在 C++ 不断地演化中,新的语言特性被不断提出,为元编程提供更多的可能。
往期推荐
关注公众号「高效程序员」????,一起优秀!
回复“1024”,送你一份程序员大礼包。
以上是关于浅谈 C++ 元编程的主要内容,如果未能解决你的问题,请参考以下文章