目前应用最广泛的技术之一是编写生成其他程序或部分程序的程序。因此十分有必要学习为什么要采用元编程,以及元编程都有哪些组件(文本宏语言,专用代码生成器)。在本文中,您将学习到如何构建一个代码生成器,并详细了解如何使用Scheme编写对语言敏感的宏。
用来生成代码的程序有时被称为元程序(metaprogram);编写这种程序就称为元编程(metaprogramming)。编写这种输出代码的程序可以有无数的应用。
本文将介绍为什么会考虑进行元编程,并介绍这种技术的一些组件——我们将深入介绍文本宏语言(textualmacrolanguage),了解专用的代码生成器,并讨论如何构建这些工具,最后研究如何使用Scheme编写对语言敏感的宏。
首先,可以编写一些程序来提前生成一些数据供运行时使用。例如,如果您正在开发一个游戏,并且希望使用一个所有8位整数的正弦值的查询表,既可以每次都执行正弦计算的操作,也可以让程序在启动时构建这样的一张表在运行时使用,或者编写一个程序在编译之前为这个表生成定制代码。尽管对于少量的数据来说在运行时构建这张表是可能的,但是有些任务则可能会使得程序启动非常缓慢。在这种情况中,编写一个程序来构建一张静态表通常是最好的解决方案。
最后,有很多编程语言都可以编写非常复杂的语句来真正实现一些功能。代码生成程序可以对这种语句进行简化,并节省很多输入的工作,这可以防止大量的输入错误,因为减少了很多输入错误内容的机会。
作为语言可以有很多特性,代码生成程序就不需要这么多了。一种语言中的标准特性在另外一种语言中可能只能通过代码生成程序实现。然而,语言设计不充分并不是需要代码生成程序的唯一原因。维护简单也是一个原因。
代码生成程序允许您开发并使用小型的、领域特有的语言,这样比直接在目标语言中开发这种功能更容易编写和维护。
用来创建领域特有语言的工具通常称为宏语言(macrolanguage)。本文介绍了几种宏语言的方法,并介绍了如何改进代码。
首先让我们来看一下涉及文本宏编程的元编程。文本宏(textualmacro)是可以直接影响编程语言中的文本的宏,它们并不需要了解或处理语言的意义。两个最广泛使用的文本宏系统是C预处理器和M4宏处理器。
如果您曾经使用C进行过编程,那么可能处理过C语言中的#define宏。文本宏的扩展虽然不甚理想,但在很多没有更好的代码生成能力的语言中,这是用来进行基本元编程的一种简单方法。清单1给出了一个#define宏的例子:
#defineSWAP(a,b,type){type__tmp_c;c=b;b=a;a=c;}这个宏可以交换两个给定类型的值。由于几个原因,这最好是作为一个宏来实现:
清单2给出了一个使用宏的例子:
#defineSWAP(a,b,type){type__tmp_c;c=b;b=a;a=c;}intmain(){inta=3;intb=5;printf("ais%dandbis%d\n",a,b);SWAP(a,b,int);printf("aisnow%dandbisnow%d\n",a,b);return0;}当运行C预处理器时,它会将SWAP(a,b,int)替换成{int__tmp_c;__tmp_c=b;b=a;a=__tmp_c;}。
文本替换是一种有效但是却非常有限的特性。这种特性有以下问题:
在表达式中合并宏的问题使得编写宏非常困难。例如,假设已经定义了下面这个MIN宏,它返回两个值中的较小值:
#defineMIN(x,y)((x)>(y)(y):(x))首先,您可能会奇怪为什么此处使用了这么多的括号。原因是操作符的优先顺序。例如我们要执行MIN(27,b=32),如果没有这些括号,这个表达式就会扩展成27>b=32b=32:27,这会产生一个编译器错误,因为按照操作符的优先顺序,27>b会连接在一起。如果在定义宏时使用了这些括号,那它就可以正常工作了。
如果这些计算有某些副作用(例如打印、修改全局变量等),那情况就更加严重了,因为这些副作用都会被处理两次。如果这些函数每次调用时所返回的结果都不相同,那么这种“多次调用”的问题甚至会让这个宏返回错误的结果。
M4宏处理器是最高级的文本宏处理系统之一。它的声望主要是由于这是流行的sendmail配置文件所使用的辅助工具。
sendmail的配置既不有趣,也不简单。sendmail的配置文件就有一整本书专门来讲解。然而,sendmail的创造者编写了一些M4宏来简化这个处理过程。在这些宏中,您可以简单地指定某些特定的参数,M4处理器可以对一个样板文件进行操作,这个文件是特定于本地安装和sendmail的通用设置的。这样就可以为您提供一个配置文件了。
例如,清单4给出了一个典型的sendmail配置文件的M4宏:
类似地,autoconf使用M4宏基于简单的宏来生成shell脚本。如果您曾经在安装程序时首先输入./configure,那么就可能使用了一个由autoconf宏所生成的程序。清单5是一个简单的autoconf程序,它生成了一个超过3,000行的configure程序:
AC_INIT(hello.c)AM_CONFIG_HEADER(config.h)AM_INIT_AUTOMAKE(hello,0.1)AC_PROG_CCAC_PROG_INSTALLAC_OUTPUT(Makefile)在宏处理器运行这个脚本时,会创建一个shell脚本,它会进行标准的配置检查,查找标准的路径和编译器命令,并从模板中为您构建config.h和Makefile文件。
现在让我们把注意力从通用的文本替换程序转移到专用的代码生成器上来。我们将介绍几个例子,了解一下样例用法,并构建一个代码生成器。
GNU/Linux系统提供了几个用来编写程序的程序。最常见的有:
这些工具都可以为C语言生成一些文件。您可能会纳闷为什么这些都是作为代码生成器实现的,而不是作为函数实现的。原因有几个方面:
每个工具都着重于构建一种特定类型的系统。Bison用来生成语法分析器;Flex用来生成词汇分析器。其他工具用来实现编程中的自动化部分。
例如,将数据库访问方法集成到一种语言中通常非常繁琐。要让这个过程变得又简单、又标准化,那么嵌入式SQL就是一个很好的元编程系统,可以在C语言中简单地合并数据库访问的功能。
虽然在C语言中有很多库可以用来访问数据库,但是使用诸如嵌入式SQL之类的代码生成器可以使合并C和数据库访问的功能更加简单:它将SQL实体的功能作为语言的一种扩展合并到了C语言中。然而,很多嵌入式SQL的实现通常都是一些专用的宏处理器,可以生成C程序作为输出结果。使用嵌入式SQL可以让对数据库的访问比直接使用库函数来访问数据库更加自然、直观,而且程序员可以更少犯错误。使用嵌入式SQL,数据库编程的复杂性可以通过一些宏子语言来屏蔽。
为了了解代码生成器是如何工作的,让我们先来看一个简短的嵌入式SQL程序。为了实现这种功能,需要使用一个嵌入式SQL的处理程序。PostgreSQL就提供了一个嵌入式SQL的编译器ecpg。要运行这个程序,需要在PostgreSQL中创建一个数据库“test”。然后在这个数据库中执行下面的命令:
createtablepeople(idserialprimarykey,namevarchar(50));insertintopeople(name)values('Tony');insertintopeople(name)values('Bob');insertintopeople(name)values('Mary');清单7是一个简单的程序,它从数据库中读出数据的内容,并将其打印到屏幕上,在打印时对name域进行排序:
#include
要编译并运行这个程序,只需要将其保存到test.pgc文件中,并运行下面的命令:
在这个例子中,我们将构建一个代码生成器,它要对一个整数执行一个或一组函数,并为结果构建一个查找表。
要思考如何构建这样一个程序,让我们从最后入手,并从后往前逐一解决问题。假设我们希望得到这样一个查找表:它返回5到20之间各个数字的平方根。我们可以编写一个简单的程序来生成这样一个查找表,例如:
/*ourlookuptable*/doublesquare_roots[21];/*functiontoloadthetableatruntime*/voidinit_square_roots(){inti;for(i=5;i<21;i++){square_roots[i]=sqrt((double)i);}}/*programthatusesthetable*/intmain(){init_square_roots();printf("Thesquarerootof5is%f\n",square_roots[5]);return0;}现在,要将这些结果转换成一个静态初始化的数组,我们需要删除这个程序的前半部分,并将其替换成手工计算出来的结果,如下所示:
doublesquare_roots[]={/*thesearetheonesweskipped*/0.0,0.0,0.0,0.0,0.02.236068,/*Squarerootof5*/2.449490,/*Squarerootof6*/2.645751,/*Squarerootof7*/2.828427,/*Squarerootof8*/3.0,/*Squarerootof9*/...4.472136/*Squarerootof20*/};我们需要的是这样一个程序,它可以生成这些结果,并将其输出到上面这样的表中,这样就可以在编译时加载了。
下面让我们分析一下要解决哪些问题:
这些都非常简单,并且进行了很好的定义——它们可以作为一个简单的列表进行输出。因此我们可能会希望执行宏调用,将这些元素合并到一个使用冒号进行分隔的列表中,如下所示:
/*sqrt.in*//*Ourmacroinvocationtobuildusthetable.Theformatis:*//*TABLE:arrayname:type:startindex:endindex:default:expression*//*VALisusedastheplaceholderforthecurrentindexintheexpression*/TABLE:square_roots:double:5:20:0.0:sqrt(VAL)intmain(){printf("Thesquarerootof5is%f\n",square_roots[5]);return0;}现在我们只需要一个程序将这个宏转换成标准的C语言就可以了。对于这个简单的例子来说,我们将使用Perl来实现这个程序,因为它可以对字符串中的用户代码进行评测,其语法也与C语言非常类似。这样我们就可以动态加载并处理用户代码了。
清单12是创建这个表生成器所使用的Perl代码:
./tablegen.pl
尽管代码生成器可以理解一点儿目标语言的知识,但是它们通常都不是完整的语法分析器,不重新编写一个完整的编译器是无法全面考虑目标语言的。
然而,如果有一种语言已经使用一个简单的数据结构进行了表示,那么这种情况就可以简化了。在Scheme编程语言中,这种语言本身可以表示成一个链表,并且Scheme编程语言就是为进行列表处理而开发的。这使得Scheme非常适合于创建被转换的程序,要对程序进行分析并不需要大量的处理,Scheme本身就是一种列表处理语言。
实际上,Scheme用来实现转换的功能已经超出了本文的范围。Scheme标准定义了一种专门用来简化对其他语言进行扩展的宏语言。大部分Scheme的实现都提供了一些特性来辅助构建代码生成程序。
让我们重新研究一下C宏中的问题。使用SWAP宏,首先必须要显式地说明要交换的值的类型,必须要为临时变量使用一个名字,并且要确保这个名字没有在其他地方使用。让我们来看一下Scheme的等效代码,以及Scheme是如何解决这个问题的:
;;DefineSWAPtobeamacro(define-syntaxSWAP;;Weareusingthesyntax-rulesmethodofmacro-building(syntax-rules();;RuleGroup(;;Thisisthepatternwearematching(SWAPab);;Thisiswhatwewantittotransforminto(let((cb))(set!ba)(set!ac)))))(definefirst2)(definesecond9)(SWAPfirstsecond)(display"firstis:")(displayfirst)(newline)(display"secondis:")(displaysecond)(newline)这是一个syntax-rules宏。Scheme有几个宏系统,但是syntax-rules是其中最标准的。
在syntax-rules宏中,define-syntax是用来定义宏转换的关键字。在define-syntax关键字之后是要定义的宏的名字;之后是要转换的内容。
syntax-rules是要采用的转换类型。在圆括号中的是正在使用的其他符号,而不是宏名本身(在这个例子中没有宏名)。
之后是一系列转换规则。这种语法转换器会遍历每条规则,并试图查找一个匹配的模式。在找到这样一个模式之后,就执行指定的转换操作。在这个例子中,只有一个模式:(SWAPab)。a和b是模式变量(patternvariable),它们与宏调用中的代码单元进行匹配,并且用来重新安排转换过程中的部分。
表面上来看,这与C版本的程序具有同样的缺陷;然而实际上它们之间存在很多不同之处。首先,由于这个宏采用的是Scheme语言,因此类型都已经被绑定到值本身上面了,而不是绑定到变量名上面,因此根本不用担心会出现C版本中那种变量类型的问题。但是它是否也有原来的变量名问题呢?如果一个变量被命名为c,那么这不会产生冲突吗?
实际上的确不会。Scheme中使用syntax-rules的宏都是hygienic。这意味着宏所使用的所有临时变量都会在替换发生之前自动重新进行命名,从而防止名字产生冲突。因此在这个宏中,如果替换变量是c,那么在替换发生之前c就会被重新命名成其他的名字。实际上,此时通常都会重新进行命名。清单15是对这个程序进行宏转换的一种可能的结果:
(definefirst2)(definesecond9)(let((__generated_symbol_1second))(set!secondfirst)(set!first__generated_symbol_1))(display"firstis:")(displayfirst)(newline)(display"secondis:")(displaysecond)(newline)正如您可以看到的一样,Scheme的宏可以提供其他宏系统的优点,却没有那些系统的问题。
syntax-case宏很难编写,但是其功能更加强大,因为这样就可以使用完整的Scheme系统功能来进行转换了。syntax-case宏并不是实际的标准,但是它们在很多Scheme系统中都已经实现了。没有syntax-case宏的系统通常也会有其他类似的系统可以使用。
让我们来看一下syntax-case宏的基本格式。让我们来定义一个宏at-compile-time,它将在编译时执行一个给定的表单。
;;Defineourmacro(define-syntaxat-compile-time;;xisthesyntaxobjecttobetransformed(lambda(x)(syntax-casex()(;;Patternjustlikeasyntax-rulespattern(at-compile-timeexpression);;with-syntaxallowsustobuildsyntaxobjects;;dynamically(with-syntax(;thisisthesyntaxobjectwearebuilding(expression-value;aftercomputingexpression,transformitintoasyntaxobject(datum->syntax-object;syntaxdomain(syntaxk);quotethevaluesothatitsaliteralvalue(list'quote;computethevaluetotransform(eval;;converttheexpressionfromthesyntaxrepresentation;;toalistrepresentation(syntax-object->datum(syntaxexpression));;environmenttoevaluatein(interaction-environment))))));;Justreturnthegeneratedvalueastheresult(syntaxexpression-value))))))(definea;;convertsto5atcompile-time(at-compile-time(+23)))它可以在编译时执行给定的操作。更具体地说,它是在宏展开时执行给定的操作,在Scheme系统中宏展开与编译并不总是同时进行的。Scheme系统中编译时允许执行的任何表达式都可以在这个表达式中使用。现在让我们来看一下这是如何工作的。
使用syntax-case系统,实际上是在定义一个转换函数,这就是lambda发挥作用的地方。x是正在转换的表达式。with-syntax额外定义了一些语法元素,可以在转换表达式中使用。syntax可以使用这些语法元素,并将其组合在一起,它遵循与syntax-rules中相同的转换规则。让我们来看一下每个步骤中会发生什么操作:
利用这种在编译时执行计算的功能,我们可以编写一个比C语言更好的TABLE宏。清单17显示了在Scheme中应该如何使用at-compile-time宏:
(definesqrt-table(at-compile-time(list->vector(letbuild((val0))(if(>val20)'()(cons(sqrtval)(build(+val1))))))))(display(vector-refsqrt-table5))(newline)可以通过对这个宏进一步进行处理生成一个用来构建表的宏,进一步进行简化,这与前面的C语言版本的宏类似:
(define-syntaxbuild-compiled-table(syntax-rules()((build-compiled-tablenamestartenddefaultfunc)(definename(at-compile-time(list->vector(letbuild((val0))(if(>valend)'()(if( 我们已经介绍了很多知识,因此现在花一分钟来回顾一下。首先我们讨论了哪些问题最适合使用代码生成程序来解决。这包括以下问题: 然后我们介绍了几种元编程系统,并给出了几个使用这些系统的例子。这包括通用文本替换系统,以及领域特有的程序和函数生成器。然后又介绍了一个具体的构建表的示例,并介绍了用C编写这样一个代码生成程序来构建静态表的详细过程。 最后,我们介绍了Scheme,并了解了它如何解决我们在C语言中所面对的问题:它使用了一些结构,而这些结构本身就是Scheme语言的一部分。Scheme既是一种语言,又是一种代码生成语言。由于这些技术都已经构建到了语言本身中,因此很容易编写程序,并且不会碰到其他语言中所面临的问题。这样我们就可以为Scheme语言在代码生成器传统应用的地方简单地添加一些领域特有的扩展了。