目录
第17章 metaprogram
metaprogramming(元编程)含有“对一个程序进行编程”的意思。 换句话说, 编程系统将会执行我们所写的代码, 来生成新的代码, 而这些新代码才真正实现了我们所期望的功能。
我们为什么需要 metaprogramming 呢? 和大多数程序设计技术一样, 使用metaprogramming 的目的是为了实现更多的功能, 并且使花费的开销更小, 其中开销是以: 代码大小、 维护的开销等来衡量的。 另一方面, metaprogramming 的最大特点在于: 某些用户自定义的计算可以在程序翻译期进行。 而这通常都能够在性能(因为在程序翻译期所进行的计算通常都可以被优化) 或者接口简单性(一个metaprogram通常都要比它所扩展的程序简短) 方面带来好处; 甚至为两方面同时带来好处。
17.1 metaprogram的第一个实例
在深入了解metaprogramming的细节之前, 让我们先来看一个简单的例子。 下面的程序给出了如何在编译期计算3的幂:
// 用于计算3的N次方的基本模板
template<int N>
class Pow3 {
public:
enum { result = 3 * Pow3<N - 1>::result };
};
// 用于结束递归的全局特化
template<>
class Pow3 < 0 > {
public:
enum { result = 1 };
};
实际上, 在template metaprogramming后面所做的工作是递归的模板实例化。 在这个计算3N的递归模板实例化将应用下面这两个规则:
1.3N= 3 * 3N -1
2.30= 1
让我们通过实例化Pow3<7>来计算37, 从而研究一下具体的计算细节:
int main()
{
std::cout << "Pow3<7>::result = " << Pow3<7>::result<< '\n';
}
首先, 编译器会实例化Pow3<7>, 从而获得:
3 * Pow3<6>::result
于是, 上面的Pow3<6>要求基于实参6实例化相同的模板。 类似地, Pow3<6>的结果也会实例化Pow3<5>、 Pow3<4>等。 于是, 递归不断进行下去, 直到Pow3<>基于0进行实例化时, 递归才结束, 并且以1作为Pow3<0>的结果。
在这里, Pow3<>模板(包含它的特化) 就被称为一个template metaprogramming。 它描述一些可以在翻译期(编译期) 进行求值的计算, 而这整个求值过程属于模板实例化过程的一部分。 从表面上看来,上面的这些实现相对比较简单, 而且用处也不大; 但在某些情况下,template metaprogramming却是非常有用的。
17.2 枚举值和静态常量
在原来的 C++编译器中, 在类声明的内部, 枚举值是声明“真常值”(也称为常量表达式) 的唯一方法。 然而, 现在的情况已经发生了改变, C++的标准化过程引入了在类内部进行静态常量初始化的概念。可以使用下面的简短例子来阐明:
struct TrueConstants {
enum { Three = 3 };
static int const Four = 4;
};
在上面例子中, Four就是一个“真常量”——和Three一样。有了上面这个性质之后, 我们的Pow3 metaprogram可以更改如下:
// 用于计算3的N次幂的基本模板
template<int N>
class Pow3 {
public:
static int const result = 3 * Pow3<N - 1>::result;
};
// 用于结束递归的局部特化
template<>
class Pow3 < 0 > {
public:
static int const result = 1;
};
与上一节的例子相比, 该例子的唯一不同在于: 我们这里使用静态常量成员, 而不是枚举值。 然而, 该版本存在一个缺点: 静态成员变量只能是左值。 因此, 如果你具有一个如下的声明:
void foo(int const&);
而且你把上一个metaprogram的结果传递进去, 即:
foo(Pow3<7>::result);
那么编译器将必须传递Pow3<7>::result的地址, 而这会强制编译器实例化静态成员的定义, 并为该定义分配内存。 于是, 该计算将不再局限于完全的“编译期”效果。
然而, 枚举值却不是左值(也就是说, 它们并没有地址) 。 因此,当你通过引用传递枚举值的时候, 并不会使用任何静态内存, 就像是以文字常量的形式传递这个完成计算的值一样。 基于这些考虑, 在本书的剩余章节里, 我们将会使用枚举值, 而放弃使用静态常量。
17.3 第2个例子: 计算平方根
让我们看一个稍微复杂的例子: 一个用于计算值N的平方根的metaprogram, 如下所示:
// 用于计算 sqrt(N)的基本模板
template <int N, int LO = 0, int HI = N>
class Sqrt {
public:
// 计算中点
enum { mid = (LO + HI + 1) / 2 };
// 借助二分查找一个较小的result
enum {
result = (N<mid*mid)
? Sqrt<N, LO, mid - 1>::result
: Sqrt<N, mid, HI>::result
};
};
// 局部特化, 适用于LO 等于 HI
template<int N, int M>
class Sqrt < N, M, M > {
public:
enum { result = M };
};
让我们仔细分析该metaprogram的程序:
int main()
{
std::cout << "Sqrt<16>::result = " << Sqrt<16>::result
<< '\n';
std::cout << "Sqrt<25>::result = " << Sqrt<25>::result
<< '\n';
std::cout << "Sqrt<42>::result = " << Sqrt<42>::result
<< '\n';
std::cout << "Sqrt<1>::result = " << Sqrt<1>::result
<< '\n';
}
其中, 表达式
Sqrt<16>::result
被扩展为:
Sqrt<16,1,16>::result
在模板的内部, 该metaprogram计算Sqrt<16,1,16>::result的过程如下:
mid = (1 + 16 + 1) / 2
= 9
result = (16 < 9 * 9) ? Sqrt<16, 1, 8>::result
: Sqrt<16, 9, 16>::result
= (16 < 81) ? Sqrt<16, 1, 8>::result
: Sqrt<16, 9, 16>::result
= Sqrt<16, 1, 8>::result
于是, 我们接下来需要计算Sqrt<16,1,8>::result, 它被扩展为:
mid = (1+8+1)/2
= 5
result = (16<5*5) ? Sqrt<16,1,4>::result
: Sqrt<16,5,8>::result
= (16<25) ? Sqrt<16,1,4>::result
: Sqrt<16,5,8>::result
= Sqrt<16,1,4>::result
然后, Sqrt<16,1,4>::result被类似地扩展为:
mid = (1+4+1)/2
= 3
result = (16<3*3) ? Sqrt<16,1,2>::result
: Sqrt<16,3,4>::result
= (16<9) ? Sqrt<16,1,2>::result
: Sqrt<16,3,4>::result
= Sqrt<16,3,4>::result
最后, Sqrt<16,3,4>::result的扩展如下:
mid = (3+4+1)/2
= 4
result = (16<4*4) ? Sqrt<16,3,3>::result
: Sqrt<16,4,4>::result
= (16<16) ? Sqrt<16,3,3>::result
: Sqrt<16,4,4>::result
= Sqrt<16,4,4>::result
于是, Sqrt<16,4,4>::result结束了整个递归过程, 因为它的上界等于下界, 能够与显式特化进行匹配。 因此, 最终的结果如下:
result = 4
追踪所有的实例化
在前面的例子中, 我们给出了计算 16 的平方根的一系列重要的实例化过程。 然而, 当编译器试图计算下面表达式的时候:
(16<=8*8) ? Sqrt<16,1,8>::result
: Sqrt<16,9,16>::result
编译器不仅仅实例化位于条件运算符正面分支的模板(即Sqrt<16,1,8>) , 同时也实例化了负面分支的模板(Sqrt<16,9,16>) 。 而且, 由于代码试图使用 :: 运算符访问结果类的成员(即result) , 所以类中的所有成员同时也会被实例化。 这就意味着: 完全实例化Sqrt<16,9,16>将会促使 Sqrt<16,9,12>和 Sqrt<16,13,16>的完全实例化。最后, 当仔细考察这整个过程的时候, 我们会发现最终将会产生数量庞大的实例化体, 总数大约是N的两倍。
为了阐明这一点, 让我们改写Sqrt metaprogram如下:
// 用于主要递归步骤的基本模板
template<int N, int LO = 0, int HI = N>
class Sqrt {
public:
// 计算中点值
enum { mid = (LO + HI + 1) / 2 };
// 使用二分法查找一个较小的值
typedef typename IfThenElse < (N < mid*mid),
Sqrt<N, LO, mid - 1>,
Sqrt<N, mid, HI> > ::ResultT
SubT;
enum { result = SubT::result };
};
// 用于结束递归的局部特化
template<int N, int S>
class Sqrt < N, S, S >
{
public:
enum { result = S };
};
与前面的方法相比, 这里主要的改变在于: 我们使用了IfThenElse模板:
// 基本模板: 根据第1个实参的值, 来确定是使用第2个实参, 还是第3个实参
template<bool C, typename Ta, typename Tb>
class IfThenElse;
// 局部特化: true 意味着选择第2个实参
template<typename Ta, typename Tb>
class IfThenElse < true, Ta, Tb >
{
public:
typedef Ta ResultT;
};
// 局部特化: false意味着选择第3个实参
template<typename Ta, typename Tb>
class IfThenElse < false, Ta, Tb >
{
public:
typedef Tb ResultT;
};
记住, 可以把IfThenElse看成一个简易装置(实际上是模板) , 它能根据给定布尔常量的值, 在两个类型中选择出其中一个。 如果布尔常量为真的话, 那么将会把第1个类型typedef为ResultT; 否则, ResultT将代表第2个类型。 还有一点我们要清楚的是: 为一个类模板实例定义一个typedef并不会导致C++编译器实例化该实例的实体。 也就是说, 当我们编写:
typedef typename IfThenElse< (N<mid*mid),
Sqrt<N,LO,mid-1>,
Sqrt<N,mid,HI> >::ResultT SubT;
的时候, Sqrt<N,LO,mid-1>和Sqrt<N,mid,HI>都不会被完全实例化。 实际上, SubT最后只能代表其中的一个类型, 而且只有在查找Sub::result的时候, 才会完全实例化SubT所代表的类型。 基于这种策略, 我们的实例化体的数量得以趋向于log2(N): 当N变得相当大的时候, 该策略将能大大减少metaprogramming的开销。
17.4 使用归纳变量
你可能会抱怨我们前面例子中的metaprogram看起来太复杂了。 你可能也会疑惑: 当碰到一个能够用一个metaprogram解决的问题时, 怎么样才能很有把握地借鉴前面的例子, 来解决你的问题呢。 于是, 我们接下来将会考察一个更加自然(接近metaprogramming本质) 、 可能更加迭代的metaprogram实现, 它也是用于计算平方根。
一个“自然且迭代的算法”可以组织如下: 为了计算值N的平方根,我们编写了一个迭代, 在迭代中, I的值将会从0迭代到N, 直到I的平方等于或者大于N。 这时I的值就是N的平方根(不考虑不存在平方根的情况) 。 如果我们用普通的 C++程序来表示这个问题, 结果将如下所示:
int I;
for (I=0; I*I<N; ++I) {
;
}
//I现在等于N的平方根
然而, 作为一个 metaprogram, 我们需要以递归的方式来组织这个迭代, 而且我们需要一个终止条件来结束该递归。 于是, 可以这样实现这个作为metaprogram的迭代:
// 借助于迭代计算 sqrt(N)的基本模板
template <int N, int I = 0>
class Sqrt {
public:
enum {
result = (I*I < N) ? Sqrt<N, I + 1>::result : I
};
};
// 用于结束迭代的局部特化
template<int N>
class Sqrt < N, N > {
public:
enum { result = N };
};
我们将根据 I 的值进行迭代。 如果 I*I<N 的值为真, 那么将使用下次迭代Sqrt<N,I+1>::result的结果作为此次result的结果。 否则的话将取I为此次result的结果。例如, 当编译器计算Sqrt<4>的时候, 实例化过程将会如下:
//步骤1:
result = (1 * 1 < 4) ? Sqrt<4, 2>::result
: 1
//步骤2 :
result = (1 * 1 < 4) ? (2 * 2 < 4) ? Sqrt<4, 3>::result
: 2
: 1
//步骤3 :
result = (1 * 1 < 4) ? (2 * 2 < 4) ? (3 * 3 < 4) ? Sqrt<4, 4>::result
: 3
: 2
: 1
//步骤4 :
result = (1 * 1 < 4) ? (2 * 2 < 4) ? (3 * 3 < 4) ? 4
: 3
: 2
: 1
尽管我们在Step 2就已经找到了结果; 但是编译器的实例化过程将会继续进行, 直到找到一个用于结束递归的特化才结束。 也就是说, 如果没有提供该特化的话, 编译器将会继续进行实例化, 直到最后到达编译器内部实例化个数的最大值。
和前面一样, 这里我们可以再次使用IfThenElse模板来解决这个问题:
// 以模板参数作为 result 的基本模板
template<int N>
class Value {
public:
enum { result = N };
};
// 借助迭代计算 sqrt(N)的模板
template <int N, int I = 0>
class Sqrt {
public:
//以实例化下一步Sqrt<N,I+1>或者结果类型Value<I>作为两个分支
typedef typename IfThenElse < (I*I < N),
Sqrt<N, I + 1>,
Value<I>
> ::ResultT
SubT;
// 使用分支类型的结果
enum { result = SubT::result };
};
在此, 我们并没有提供结束递归的局部特化, 而是使用了一个Value<>模板, 它会返回模板实参的值, 并且作为所求的result。现在使用了IfThenElse<>之后, 实例化的数量将会趋近于Sqrt(N),而不是原来的N, 这就大大减少了metaprogramming的开销。
17.5 计算完整性
Pow3<>和Sqrt这两个例子说明: 一个template metaprogram可以包含下面几部分:
•状态变量: 也就是模板参数。
•迭代构造: 通过递归。
•路径选择: 通过使用条件表达式或者特化。
•整型(即枚举里面的值应该为整型) 算法。
如果对递归实例化体和状态变量的数量都没有限制, 那么对于在编译期可计算的任何对象, 都可以利用metaprogram高效地进行计算。 而且我们知道, 使用模板来进行这类计算通常都是有限制的。 而且, 模板实例化通常都要消耗巨大的编译器资源, 而且扩展的递归实例化也会很快地降低编译器的效率, 甚至耗光所有的可用资源。 事实上, C++标准建议最多只进行17层的递归实例化, 但是这并没有写入书面文档中。 另一方面, 在实际开发中, 某些复杂的template metaprogramming很容易就会超过这个(17层的) 限制。
因此, 在实际开发中, 我们都很少使用 templat metaprogram。 然而, 在某些情况下, metaprogram又是实现高效率模板的一个不可替代的工具。 特别是, metaprogram有时候可以隐藏在普通模板的内部, 并且用于实现那些对性能要求很严格的算法, 从而大大提高效率。
17.7 使用metaprogram来展开循环
接下来, 我们要介绍metaprogramming的首个实用的应用程序, 用于展开数值计算的循环, 接下来我们将给出一个完整的例子。
数值应用程序通常都会访问n元的数组, 或者数学上的vector。 一个典型的应用就是计算所谓的点乘。 两个数学vector a和b的点乘是: a和b中相应元素的乘积的总和。 例如, 如果每个vector都具有3个元素, 那么结果应该如下:
a[0]*b[0] + a[1]*b[1] + a[2]*b[2]
通常而言, 数学库会提供一个用于计算点乘的函数。 现在让我们来考虑下面这个比较直接的实现:
template <typename T>
inline T dot_product(int dim, T* a, T* b)
{
T result = T();
for (int i = 0; i < dim; ++i) {
result += a[i] * b[i];
}
return result;
}
但是针对性能要求很严格的应用程序而言, 该实现实际上耗费的时间却太多了。 即使把函数声明为内联也未能获得足够优化的性能。
问题在于: 对于许多迭代, 编译器通常都会优化这种循环(即迭代) , 而在这个例子中, 这种优化却会带来反面的效果。 例如, 将上面的循环片断简单地扩展为:
a[0]*b[0] + a[1]*b[1] + a[2]*b[2]
可能会更好。
显然, 我们可以直接编写计算点乘的程序, 而并不需要调用dot_product(); 我们还可以提供针对元数较少的用于点乘计算的特殊函数, 但如果总是重复地解决这些问题, 肯定会令我们感到乏味的。 幸运的是, template metaprogramming为我们解决了这个问题: 我们可以“编写”用于展开循环的程序, 来解决这个问题。 实际的metaprogam如下:
// 基本模板
template <int DIM, typename T>
class DotProduct {
public:
static T result(T* a, T* b) {
return *a * *b + DotProduct<DIM - 1, T>::result(a + 1, b + 1);
}
};
// 作为结束条件的局部特化
template <typename T>
class DotProduct < 1, T > {
public:
static T result(T* a, T* b) {
return *a * *b;
}
};
// 辅助函数
template <int DIM, typename T>
inline T dot_product(T* a, T* b)
{
return DotProduct<DIM, T>::result(a, b);
}
int main()
{
int a[3] = { 1, 2, 3 };
int b[3] = { 5, 6, 7 };
std::cout << "dot_product<3>(a,b) = " << dot_product<3>(a, b)
<< '\n';
std::cout << "dot_product<3>(a,a) = " << dot_product<3>(a, a)
<< '\n';
}
因此, 对于:
dot_product<3>(a,b)
实例化过程的计算将如下:
DotProduct<3, int>::result(a, b)
= *a * *b + DotProduct<2, int>::result(a + 1, b + 1)
= *a * *b + *(a + 1) * *(b + 1) + DotProduct<1, int>::result(a + 2, b + 2)
= *a * *b + *(a + 1) * *(b + 1) + *(a + 2) * *(b + 2)
注意, 运用这种 metaprogram 的程序设计要求: vector 的元数在编译期是已知的, 而且很多情况也确实如此(但也并非所有的情况都如此) 。