整理自http://kuibyshev.bokee.com/1584716.html
-
引论
C++的发展史是一个不断吸收程序设计领域精华和不断积累充实语言特性的过程。它的创造者Stroustrup在这门新的编程语言草创之初就定下了几个基本的目标,二十年过去了,至今这些目标仍然是C++继续发展的指南针。其中他明确指出,这种语言不应强迫程序员使用单一程序设计形式[20];就是说C++语言应该是一种“多种花样”的语言。正是因为Stroustrup这种高瞻远瞩的构想,才使得C++逐渐发展成为一门最具综合性、用途最广泛的语言,受到世界上最多的程序员喜爱。也正是因为同样的原因,人们在使用C++的过程中,探索出许多精巧优雅的编程技法,其中不少技法依赖着C++本身丰富的特性,在别种编程语言中并没有对应。例如伴随C++的模板技术而衍生的各种各样的技法,就能生动地说明这一点。
(一)C++ 模板元编程的发展
1994年的一次C++标准委员会的会议上,Erwin Unruh提交了一个很特别的C++程序,这个程序能够产生一系列素数,但与普通程序最大的不同,运算结果的产生是在编译期完成的,为了显示这些结果,甚至故意产生了一些编译期的错误信息,也就是说,程序从未完成编译过程,却能得到结果[13]。过程如此奇异,实际上是利用了C++模板的可递归特性,或者称为“编译期递归”。
受此启发,Todd Veldhuizen在1995年的一篇文章[27]中提出了“模板元编程(Template Metaprogramming)”这个概念,不但指出模板的特化(Specialization)能力可以用于针对编译期的编程,而且提出了几种实用的控制分支结构。从此C++的编程技术分出一门旁支,C++代码拥有的编译期和运行期的两段执行能力受到人们关注。
Krzysztof Czarnecki与Ulrich W. Eisenecker在1999年出版了《Generative Programming Methods, Tools, and Applications》一书,这本书对模板元编程的贡献是实现了一个辅助库,提出了更完善的控制结构的编写方法。
2004年出版的《C++ Template Metaprogramming: Concepts, Tools, and Techniques from Boost and Beyond》一书归纳了十年来模板元编程的技术动向,成为第一本论述C++模板元编程技术的的专著。
模板元编程尽管处于C++程序设计的浪尖,仍然有不少C++程序库的作者直接或间接地利用它,甚至开发出专用于模板元编程的库,当中包括著名的Boost、Loki和Blitz++等。其中Boost提供了MPL(Meta-Programming Library),用以辅助模板元编程,当中提出了许多极有创新性的概念。
目前模板元编程已经成为C++语言的一项庞大的分支,并仍然在迅速发展。许多C++程序员都十分热衷于讨论这门技术,不断提出很多新颖的概念。
(二)C++模板元编程的概念
元编程(Metaprogramming)的前缀meta-表示“处于较高发展状态”的意思,从这个意义上讲,metaprogramming指的就是高阶的编程,即编写一种程序,使得这种程序可以把某些程序作为输入数据,生成或者操作其他的程序。一般而言,这些高阶的程序运行在编译时。因此,元编程并非一个新概念,事实上任何一种高级语言的编译器都可以视为元编程的工具,因为它们的程序必须被转换为汇编语言或者机器语言以后,才能够运行在计算机上。Lex和Yacc也是很传统的元编程工具,它们能够把一种新的语言根据规范转换成对应的C语言代码。
C++强大的模板机制赋予了模板在编译时的运算能力。例如以下的代码计算n!,借助于模板的特化能力形成递归,所有运算都在编译时完成:
在这个例子里面,factorial在传统意义上是一个类模板,但用元编程的观点去看待,则可以认为是一个元函数(Metafunction)。这个元函数接受一个整型的模板参数,它的返回值是元函数的一个静态成员。注意元函数与普通的函数很不一样,一方面,元函数往往需要定义一个以上的特化版本作为终结条件,另一方面,元函数的返回值需要显式地提取出来。
这个简单的程序似乎仅仅是玩弄技巧,但正如1994年Erwin Unruh所演示的那个载入史册的素数生成程序,它们其实都只是引起人们注意的冰山一角,为了深入研究模板元编程的能力、使用范围和局限性,必须清楚了解其背后的机理。
-
模板元编程的原理
(一)函数式编程
1.函数式编程的概念
从上面的例子可以看出,由于模板元编程借助的是C++模板的特化能力,使它的设计方法迥异于普通的C++编程习惯。比如我们不能够在元函数中使用变量,编译期显然只可能接受静态定义的常量,也就因此,我们不能够使用传统意义上的循环;要实现任何分支选择,唯一的方法是调用其它元函数或者使用递归。这种编程风格正是具有重要理论意义的函数式编程(Functional Programming)。
Kenneth C. Louden指出函数式编程有如下的特点:
l 所有的过程都是函数,并且严格地区分输入值(参数)和输出值(结果)。
l 没有变量或赋值(变量被参数替代)。
l 没有循环(循环杯递归调用替代)。
l 函数的值只取决于它的参数的值而与求得参数值的先后或者调用函数的路径无关。
l 函数是一类值。
上述的最后一点是一个很重要的性质。所谓“函数是一类值(First Class Value)”指的是函数和值是同等的概念,一个函数可以作为另外一个函数的参数,也可以作为值使用。如果函数可以作为一类值使用,那么我们就可以写出一些函数,使得这些函数接受其它函数作为参数并返回另外一个函数。比如定义了f和g两个函数,用compose(f,g)的风格就可以生成另外一个函数,使得这个函数执行f(g(x))的操作,则可称compose为高阶函数(Higher-order Function)。
2.函数式编程在C++中的常用方法
如果排除上述的最后一点,那么C语言已经能完整模拟出函数式编程的风格,但是在C语言中,函数却并不能作为一类值。也许我们会想到函数指针,但是试想如果我们的函数需要返回另一个函数的指针:
这个例子是不能通过编译的,因为C语言禁止在函数中定义函数。
幸运的是,我们可以在C++中利用类和模板来解决这个问题,因为C++允许定义()操作符,建立所谓的仿函数(Functor)。所以对象既可以作为值来传递和调用,又可以像函数一样用obj(x)的语法来使用了;另一方面,利用模板对返回值的控制,就可以避免上面无法定义内部函数的矛盾了。例如在GCC的 STL中有一个不属于C++标准的compose1函数,可以接受两个定义了()操作符的对象作为函数参数,并返回一个能进行f(g(x))运算的对象:
3.模板元编程中的高阶函数
那么,使用C++的模板机制又是否可以满足元函数作为一类值使用呢?答案是肯定的,不过解答稍稍有点复杂,并不像上述compose1的解决方法一样优雅。
在这里,f和g是两个元函数,compose接受f和g作为参数,生成了一个可以计算f(g(x))的新函数,看起来能够得出正确的答案,但是却仍然有两个问题。
首先,在compose的模板参数中,不能直接使用类似于template <class op1, class op2>的写法,原因是C++的模板机制严格区分模板和类,我们无法把模板直接作为另一个模板的参数,唯一可行的方法是使用“作为类模板的模板参数(Class Template Template Parameter)”,这样就把f和g的参数类型限制死了。不过我们似乎仍然可以勉强接受这个限制,事实上模板机制对非类型的模板参数本来就存在着限制,现在的C++标准禁止浮点数、类对象和内部链接对象(比如字符串和全局指针)作为模板参数,所以非类型的模板参数实际上只剩下布尔型和整型可用,写成类似composeint和composebool 的两个类仍然有可行性(模板参数是无法重载的)。 其次,同样是C++的模板机制严格区分模板和类的缘故,返回值return_type是一个模板而并不是一个元函数(或者说是类)。 上述两点都隐含着一个最大共同问题,C++对“作为类模板的模板参数”作了很严格的限制,所以一旦定义了以后,其模板参数的个数不能改变。当然,在STL里面我们似乎习惯了这个限制并用重新定义函数的方式来避开这个限制。但在作为函数式编程的模板元编程里面,似乎应该要求以更优雅的方式来解决(事实上即使是常规编程下的高阶函数,也有函数库提供了更好的组合方式[5])。 现在我们仅仅用到了模板元编程的数值计算能力,还没有引入它对类型的处理能力,稍后在分析MPL时会重新讨论到这个问题,还会显示出高阶函数更大的使用障碍。幸而MPL提供了很好的解决方案,通过增加包装层和使用lambda演算的概念,高阶函数依然能用上,使得模板元编程能够符合函数式编程的要求。
Krzysztof Czarnecki曾利用模板元编程实现了一个很简单的LISP,而LISP就是典型的函数式编程语言。
总之既然C++模板能够使用函数式编程,那么也就意味着这个C++语言的子集已经是图灵完备的,因为任何计算都可以使用函数来描述,理论上模板元编程应该能完成图灵机上的一切运算。
当然,理论上的完备并不意味着实用性。尽管在C++中能够在某种程度上使用函数式编程的风格,但是从实用性和效率来说,大概没有程序员使用纯粹的函数编程方式。不过,在进行模板元编程的时候,由于语法的特殊性,却不得不使用纯粹函数式编程。因此,模板元编程与传统的C++命令式编程相差很大,并不符合大多数C++程序员的习惯,不但会带来编写上的困难,还增加了许多程序理解上的难度。那么,为什么要使用模板元编程呢?首先我们应当了解模板元编程能够做些什么,其次,模板元编程有可能用在什么地方。
(二)模板元编程的基本用途
1.数值计算
上文提及的所有关于模板元编程的例子都是在编译时的数值计算,这是模板元编程的最简单直接的使用方法。数值计算主要利用模板的特化和局部特化能力进行递归演算,模板类被视为元函数,利用类中的一个静态常量保存结果。由于C++模板对非类型的参数有限制,一般只有整型和布尔型可以参加运算。元函数的结果一般放在一个静态的常量中,但对于整型而言还有一个更好的选择,可以放置在枚举中,其唯一的优点是静态常量是左值必须在运行期占有内存地址,也就是分配了内存空间,而枚举值不能作为左值,所以不占用内存空间,显得有些微的优势。这样,阶乘计算的例子可以改写如下:
无论是编译期的递归还是运行期内存的节省,对比起模板元编程在数值计算上的不足,都显得有点得不偿失。主要有以下四点:
l 运算范围仅限于整型和布尔型,用途有限。
l 递归式的编程难于实行和理解。
l C++编译器对编译期的递归都是有一定的限制的,C++标准建议进行17层递归实例化,这无疑不能满足稍复杂的程序。
l 大量消耗编译器资源的同时,降低了编译效率。
因此,用模板元编程作编译期数值计算并不在实践中经常使用,但它作为一个“中心设施”在MPL库中发挥着重要的作用[6]。
2.解开循环(Loop Unrolling)
当计算两个向量进行点乘,而维数不定时,例如:
int a[]={1,3,5,7};
int b[]={2,4,6,8};
考虑下面计算点乘的代码:
这里的代码很平常,但对于性能要求极高并大量使用点乘的应用程序,也许想再节省一点开销。如果能减少循环的计数,对性能也有比较可观的提升。这样代码应该展开以直接计算:
T result=a[0]*b[0]+a[1]*b[1]+a[2]*b[2]+a[3]*b[3];
但是我们希望泛化这个表达式,以便应用于不同维数的向量计算,这里,模板元编程正好可以发挥出它编译时计算和生成代码的能力。我们可以把代码改写成:
这种方法是定义了一个类模板DotProduct作为元函数,通过递归调用不断展开表达式,还定义了一个局部特化的版本,使它在维数递减到1时能够终结递归。
我们还留意到一个习惯,元函数都用struct而不是用class定义,这是因为struct中的成员可见性默认为public,在编写元函数时可以省却public:这个声明。
注意包装函数的接口已经改变,利用普通方法的函数是这样使用的:
doc_product(4, a, b);
现在的写法是:
doc_product<4>(a, b);
为什么不能使用相同的接口呢?原因是模板参数必须在编译时确定,所以DIM必须是一个常量,而不可能是一个变量。所以这是对此种编程技术的一个重大限制。当然,对于这类计算而言,向量的维数一般都能在编译时确定。
Todd Veldhuizen在1995年第一次提出了这项技术,并且把这种技术运用到高性能数学计算的Blitz++库中。此外,在Blitz++库中还大量运用到一种称为“表达式模板(Expression Template)”的技术,同样是为了减少线性代数计算中的循环次数和临时对象的开销。表达式模板尽管不属于模板元编程的范畴(因为它不是依赖编译时计算进行优化的),但它与模板元编程具有异曲同工的妙用,特别在高性能数学计算中能够发挥极大的用途。Todd Veldhuizen指出,通过这一系列的优化手段,C++在科学计算上的性能已经达到甚至超过Fortran的性能。
3.类型处理
对类型的处理是模板元编程最重要和最具有现实意义的应用。由于模板可以接受类型参数,也可以通过typedef或定义内嵌类建立模板类的成员类型,再加以强大的模板特化的能力,使得类型计算几乎能有着数值计算所有的全部能力。
(1)类型分支选择
利用模板局部特化的能力,编译时的类型选择可以很容易做到:
不过,有某些旧式编译器并不支持模板局部特化,这种情况下增加一层包装就可以巧妙地转为使用全局特化。
元函数if是模板元编程中最简单但运用得最多的基础设施。
(2)类型的数据结构
把类型作为普通数据一样管理,这初看起来有点匪夷所思:普通数据可以运用struct或者array来组织,但C++并没有为类型提供一个专用的数据结构,可以利用的唯一设施是模板的参数列表。比如我们可以定义一个类型的“数组”如下:
template <class a, class b, class c, class d, class e>
struct type_array;
但是为了使它真正像数组一样使用,还需要在其中定义一系列的typedef,比如某一下标的类型的提取等,类似于:
typedef a type1;
typedef b type2;
……
在这里,数组长度也无法动态变化是一个重大的缺陷。当然,有时候数组仍然是有用的,MPL就提供了一个框架为任何自定义的类型数据结构提供支持,下文会有所提及。现在先介绍一种更自动的类型组织方法——Typelist。
(2)Typelist
上面提到过模板元编程是函数式的编程,参照其他一些函数式编程语言对数据的组织,很容易得到一些启发。比如在Scheme(一种LISP的变体)中,基本的数据结构是表,其他数据结构都必须用表的形式来表达。一个表可以这样定义:
(”A” (“B” () (“C” () () ) ) (“D” () () ) )
这个表可以表示出一个二叉搜索树:
A |
B |
C |
D |
通过简单的表的递归,就可以构造出各种数据结构。注意到C++模板的实现体也是一种类型,利用类型的递归,同样的风格也可以吸收到模板元编程中。
Typelist的定义是很简单的:
另外我们需要定义一个特殊的标记:
struct Nulltype;
这个无定义的类不能产生对象,它的存在仅仅为了提供一个结束的标记。现在我们定义一个Typelist,并在这一节里面反复使用:
typedef Typelist<int, Typelist<float, Typelist<long, Nulltype> > > typelist;
这样的结构比起“数组”有什么优点呢?由于它的结构是递归的,我们可以很容易写一个通用的元函数提取它某一个位置的类型,我们甚至可以插入、修改和删除元素,然后返回一个新的Typelist。
(3)提取Typelist中的类型
如果需要按照位置来提取Typelist类型,可以定义这样一个元函数:
这里使用了局部特化,对于不能支持局部特化的编译器,可以类似上面的if元函数的处理手法,适当修改这里的代码。
这个元函数按照以下方式调用:
typedef typeat<typelist, 0>::result result;
如果试图定义一个变量: result a=1.2f; 编译器会抱怨无法把一个float类型转换为result类型,因为上面定义的typelist的第一个类型是int。而把下标0改为1以后,则可以编译通过,这证明元函数可以在编译时正确选择出所需的类型。
(3)修改Typelist中的元素
实例化以后的模板已经成为一种类型,所以是不可能进行修改的,要达到修改的效果,唯一的方法是返回一种新的类型,使它带有新的Typelist。
比如,如果要在一个已有的Typelist中添加一个类型,可以定义一个这样的元函数:
这个append元函数不仅能插入一个类型,也可以把一个Typelist添加到另一个Typelist尾部。如果这样使用的话:
typedef append<typelist, double>::result newlist;
那么就会得到一个新的newlist,里面共有4个类型。
利用很类似的方法,还可以为Typelist编写删除或者修改某个元素的元函数,这可以参阅[18]的论述。
(4)小结
用合适的数据结构组织类型,我们可以得到这样的一些好处:
l 编译时可以根据情况自动决定选择的类型。
l 由于模板中没有数据成员,它们在运行时不占用任何空间。
l 可以运用于代码的自动生成,或者实现某种设计模式。
下面分析Boost的MPL库时,还会遇到更多和更复杂的类型数据结构。
4.自动生成代码
上面曾经提到的解开循环技术其实已经是一种代码生成了,但这个独立出来的小节专门用于说明代码生成在设计模式中的应用,这些精巧的设计承接着上文提到的Typelist,也是来源于Alexandrescu的Loki库[18]。
首先,引入一个类模板GenScatterHierarchy,这个模板通过递归的定义进行多重继承,从而构造出一种复杂而散乱的体系。
另外还需要定义一个Holder来持有一个类型的对象,这也是为了包装基本类型,以便它们可以被继承。
现在可以定义一个GenScatterHierarchy的实现体了:
这里的Typelist嵌套定义显得有点繁琐,在Loki库里面有定义相对应的宏来简化使用,这里为了展示的方便而展开了。WidgetInfo的继承体系如下图所示:
(这个图在《Modern c++ Design》有)
为什么要使用这样复杂的一个体系呢?这种体系对于程序设计有什么实质性的帮助呢?首先,Typelist是一个可扩展的结构,不但可以定制任意长度的Typelist,在更改Typelist以后,WidgetInfo不需要作任何改变就可以自动适应并产生新的代码。其次,WidgetInfo继承了有Typelist长度个的Holder实体,它们拥有相同的接口,可以加入一个成员模板函数用统一的接口来操纵这些实体。再次,分别继承的Holder实体并不会互相干扰,WidgetInfo可以根据需要上调成它们中的任何一种。
Alexandrescu在Loki库中把这种体系发挥得淋漓尽致[18],他以这个体系(以及其它辅助方法)构造出一个非常灵活的Abstract Factory模式,避免了Abstract Factory通常耦合度较高的缺点。