15 Metaprogramming

元编程意味着创建能够生成代码的代码。例如,在解释型脚本语言中,通常可以创建一段代码,生成一个字符串,然后将该字符串解释为代码。
元编程在编译型语言(如C++)中也很有用,可以在编译时而不是运行时进行一些计算,前提是所有计算的输入在编译时都是可用的。当然,在解释型语言中没有这样的优势,因为所有的操作都在运行时发生。
以下技术在C++中可以被视为元编程:
- 预处理器指令,例如使用#if代替if。这是一种非常高效的方式来删除多余的代码,但是预处理器的功能有严重的限制,因为它在编译器之前起作用,并且只能理解最简单的表达式和运算符。
- 创建一个能够生成另一个C++程序(或其中一部分)的C++程序。在某些情况下,这非常有用,例如生成数学函数的表格,你希望将其作为最终程序中的常量数组。当然,这要求你编译第一个程序的输出。
- 优化编译器可能会尽量在编译时进行许多操作。例如,所有好的编译器都会将int x = 2 * 5;简化为int x = 10;
- 模板在编译时进行实例化。在编译之前,模板实例会用其实际值替换参数。这就是为什么使用模板几乎没有成本的原因(参见p.58)。理论上可以使用模板元编程来表示几乎任何算法,但这种方法非常复杂和笨拙,并且可能需要很长时间来编译。
- 编译时分支。使用if constexpr(布尔表达式){}。括号内的布尔表达式可以是任何包含在编译时已知值的表达式。如果bool为true,则{}内的代码将被包含在最终程序中。虽然false分支中的代码会被删除,但它仍然必须在语法上正确。如果出现在模板内部,语法检查会相对宽松一些。此功能对于决定使用哪个版本的代码非常有用。if constexpr特性对于条件声明来说不太有用,因为声明的作用域仅限于{}内部。编译时分支要求在编译器中启用C++17标准。有关详细信息,请参见第166页。
- constexpr函数。constexpr函数可以在编译时进行任意计算,只要它以编译时已知的参数调用即可。有关详细信息,请参见下面的第167页。这要求在编译器中启用了C++14标准或更高版本。
15.1 Template metaprogramming
以下示例解释了如何使用元编程来加速在编译时已知指数为整数的幂函数的计算。

// Example 15.1a. Calculate x to the power of 10
double xpow10(double x) {
return pow(x,10);
}

在一般情况下,pow函数使用对数运算,但在这种情况下,它可以识别到10是一个整数,因此结果可以仅通过乘法计算得出。当指数为正整数时,pow函数内部使用以下算法:

// Example 15.1b. Calculate integer power using loop
double ipow (double x, unsigned int n) {
double y = 1.0; // used for multiplication
while (n != 0) { // loop for each bit in nn
if (n & 1) y *= x; // multiply if bit = 1
x *= x; // square x
n >>= 1; // get next bit of n
}
return y; // return y = pow(x,n)
}
double xpow10(double x) {
return ipow(x,10); // ipow faster than pow
}

15.1b示例中使用的方法在我们展开循环并重新组织后更容易理解。

// Example 15.1c. Calculate integer power, loop unrolled
double xpow10(double x) {
double x2 = x *x; // x^2
double x4 = x2*x2; // x^4
double x8 = x4*x4; // x^8
double x10 = x8*x2; // x^10
return x10; // return x^10
}

如我们所见,只需四次乘法就可以计算出 pow(x,10)。那么如何从示例15.1b过渡到15.1c呢?我们利用了编译时已知 n 值的特性,消除了所有仅依赖于 n 的部分,包括 while 循环、if 语句和所有整数计算。示例15.1c中的代码比15.1b更快,并且在这种情况下,它可能也更小。
从示例15.1b到15.1c的转换是我手动完成的,但如果我们想生成适用于任何编译时常量 n 的代码片段,那么我们需要元编程。只有最好的编译器才能自动将示例15.1a或15.1b转换为15.1c。元编程在编译器无法自动化简时非常有用。
下一个示例展示了使用模板元编程实现的这个计算。如果它看起来过于复杂,请不要慌张。模板元编程可以非常复杂。幸运的是,新的C++17标准提供了一种更简单的方式,我们将在下一章中看到。
我给出这个示例只是为了展示模板元编程可以多么曲折和复杂。

// Example 15.1d. Integer power using template metaprogramming
// Template for pow(x,N) where N is a positive integer constant.
// General case, N is not a power of 2:
template <bool IsPowerOf2, int N>
class powN {
public:
static double p(double x) {
// Remove right-most 1-bit in binary representation of N:
#define N1 (N & (N-1))
return powN<(N1&(N1-1))==0,N1>::p(x) * powN<true,N-N1>::p(x);
#undef N1
}
};
// Partial template specialization for N a power of 2
template <int N>
class powN<true,N> {
public:
static double p(double x) {
return powN<true,N/2>::p(x) * powN<true,N/2>::p(x);
}
};
// Full template specialization for N = 1. This ends the recursion
template<>
class powN<true,1> {
public:
static double p(double x) {
return x;
}
};
// Full template specialization for N = 0
// This is used only for avoiding infinite loop if powN is
// erroneously called with IsPowerOf2 = false where it should be true.
template<>
class powN<true,0> {
public:
static double p(double x) {
return 1.0;
}
};
// Function template for x to the power of N
template <int N>
static inline double integerPower (double x) {
// (N & N-1)==0 if N is a power of 2
return powN<(N & N-1)==0,N>::p(x);
}
// Use template to get x to the power of 10
double xpow10(double x) {
return integerPower<10>(x);
}

如果你想知道这是如何工作的,下面是一个解释。如果你不确定是否需要,请跳过以下解释。
在C++的模板元编程中,循环被实现为递归模板。powN模板在调用自身以模拟示例15.1b中的while循环。分支通过(部分)模板特化来实现。这就是示例15.1b中的if分支的实现方式。递归必须始终以非递归的模板特化结束,而不是在模板内部有一个分支。
powN模板是类模板而不是函数模板,因为只有类才允许进行(部分)模板特化。将N分割为其二进制表示中的各个位特别棘手。我使用了一个技巧,即 N1 = N&(N-1) ,它可以得到将最右边的1位移除的N的值。如果N是2的幂,则 N&(N-1) 是0. 常量N1可以通过其他方式定义而不是宏,但这里使用的方法是在我尝试过的所有编译器中都有效的唯一方法。
优秀的编译器实际上会将示例15.1d按照预期简化为15.1c,因为它们可以消除公共子表达式。
为什么模板元编程如此复杂?因为C++的模板功能从未为此目的而设计。它只是碰巧有可能这样做。我认为除了简单的情况外,使用模板元编程是不明智的,因为复杂的代码本身就是一个风险因素,而验证、调试和维护此类代码的成本非常高昂,很少能够证明在性能上的收益是合理的。
15.2 Metaprogramming with constexpr branches
幸运的是,随着C++17标准的推出,元编程变得更加简单,它提供了使用constexpr关键字进行编译时分支的能力。
以下示例展示了与示例15.1相同的算法,使用了编译时分支。

// Example 15.2. Calculate integer power using C++17
// Recursive template, used below
// Calculates y * pow(x,n)
template <int n>
inline double ipow_step (double x, double y) {
if constexpr ((n & 1) == 1) {
y *= x; // multiply if bit = 1
}
constexpr int n1 = n >> 1; // get next bit of n
if constexpr (n1 == 0) {
return y; // finished
}
else {
// square x and continue recursion
return ipow_step<n1>(x * x, y);
}
}
// Efficient calculation of pow(x,n)
template <int n>
double integerPower (double x) {
if constexpr (n == 0) {
return 1.; // pow(x,0) = 1
}
else if constexpr (n < 0) {
// x is negative
if constexpr ((unsigned int)n == 0x80000000u) {
// -n overflows
return 0.;
}
// pow(x,n) = 1/pow(x,-n)
return 1. / integerPower<-n>(x);
}
// loop through recursion
return ipow_step<n>(x, 1.);
}

在这里,我们仍然需要一个递归模板来展开循环,但更容易进行分支和结束递归。一个优秀的编译器将把示例15.2简化为仅包含乘法指令,没有其他内容。未经采用的分支将不会出现在最终的代码中。
在C++17之前引入编译时分支之前,我们面临的问题是,即使从未使用过,分支中的模板也会被展开。这可能导致无限递归或指数级增长的未使用分支。在C++17之前,结束模板递归的唯一方法是使用模板特化,就像在示例15.1d中一样。如果递归模板包含了许多分支,它可能需要很长时间来编译。
编译时分支更高效,因为未经采用的分支中的模板不会被展开。
15.3 Metaprogramming with constexpr functions
一个constexpr函数是一个函数,如果参数是编译时常量,它可以在编译时进行几乎任何计算。在C++14标准中,您可以在constexpr函数中使用分支、循环等。
这个例子找到了一个整数中最高有效位的位置。这与位扫描反转指令相同,但是在编译时计算:

// Example 15.3. Find most significant bit, using constexpr function
constexpr int bit_scan_reverse (uint64_t const n) {
if (n == 0) return -1;
uint64_t a = n, b = 0, j = 64, k = 0;
do {
j >>= 1;
k = (uint64_t)1 << j;
if (a >= k) {
a >>= j;
b += j;
}
} while (j > 0);
return int(b);
}

如果所有的输入都是已知的常量,一个好的优化编译器无论如何都会在编译时进行简单的计算,但是如果涉及到分支、循环、函数调用等更复杂的计算,很少有编译器能够在编译时进行。constexpr函数可以在编译时确保某些计算被执行。constexpr函数的结果可以在需要编译时常量的任何地方使用,例如数组大小或编译时分支中。
虽然C++14和C++17提供了重要的元编程可能性改进,但仍然有一些事情在C++语言中做不到。例如,不可能制作一个编译时循环,重复十次来生成名为func1、func2、...、func10的十个函数。这在某些脚本语言和汇编程序中是可能的。


 

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值