用Python创建声明性迷你语言的教程
大多数程序员考虑编程时,他们都要设想用于编写应用程序的命令式样式和技术。最受欢迎的通用编程语言(包括Python和其它面向对象的语言)在样式上绝大多数都是命令式的。另一方面,也有许多编程语言是声明性样式,包括函数语言和逻辑语言,还包括通用语言和专用语言。
让我们列出几个属于各个种类的语言。许多读者已经使用过这些工具中的许多工具,但不见得考虑过它们之间的种类差别。Python、C、C++、Java、Perl、Ruby、Smalltalk、Fortran、Basic和xBase都是简单的命令式编程语言。其中,一些是面向对象的,但那只是组织代码和数据的问题,而非基本编程样式的问题。使用这些语言,您命令程序执行指令序列:把某些数据放入(put)变量中;从变量中获取(fetch)数据;循环(loop)一个指令块直到(until)满足了某些条件;如果(if)某个命题为true,那么就进行某些操作。所有这些语言的一个妙处在于:便于用日常生活中熟悉的比喻来考虑它们。日常生活都是由做事、选择、再做另一件事所组成的,期间或许会使用一些工具。可以简单地将运行程序的计算机想象成厨师、瓦匠或汽车司机。
诸如Prolog、Mercury、SQL、XSLT这样的语言、EBNF语法和各种格式的真正配置文件,都声明某事是这种情况,或者应用了某些约束。函数语言(比如Haskell、ML、Dylan、Ocaml和Scheme)与此相似,但是它们更加强调陈述编程对象(递归、列表,等等)之间的内部(函数)关系。我们的日常生活(至少在叙事质量方面)没有提供对这些语言的编程构造的直接模拟。然而,对于那些可以用这些语言进行描述的问题来说,声明性描述远远比命令式解决方案来得简明且不易出错。例如,请研究下面这个线性方程组:
清单1.线性方程式系统样本
10x+5y-7z+1=0 17x+5y-10z+3=0 5x-4y+3z-6=0
这是个相当漂亮的说明对象(x、y和z)之间几个关系的简单表达式。在现实生活中您可能会用不同的方式求出这些答案,但是实际上用笔和纸“求解x”很烦,而且容易出错。从调试角度来讲,用Python编写求解步骤或许会更糟糕。
Prolog是与逻辑或数学关系密切的语言。使用这种语言,您只要编写您知道是正确的语句,然后让应用程序为您得出结果。语句不是按照特定的顺序构成的(和线性方程式一样,没有顺序),而且您(程序员或用户)并不知道得出的结果都采用了哪些步骤。例如:
清单2.family.proProlog样本
/*Adaptedfromsampleat: <http://www.engin.umd.umich.edu/CIS/course.des/cis479/prolog/> Thisappcananswerquestionsaboutsisterhood&love,e.g.: #Isaliceasisterofharry? ?-sisterof(alice,harry) #Whichofalice'sisterslovewine? ?-sisterof(X,alice),love(X,wine) */ sisterof(X,Y):-parents(X,M,F), female(X), parents(Y,M,F). parents(edward,victoria,albert). parents(harry,victoria,albert). parents(alice,victoria,albert). female(alice). loves(harry,wine). loves(alice,wine).
它和EBNF(扩展巴科斯范式,ExtendedBackus-NaurForm)语法声明并不完全一样,但是实质相似。您可以编写一些下面这样的声明:
清单3.EBNF样本
word:=alphanums,(wordpunct,alphanums)*,contraction? alphanums:=[a-zA-Z0-9]+ wordpunct:=[-_] contraction:="'",("clock"/"d"/"ll"/"m"/"re"/"s"/"t"/"ve")
如果您遇到一个单词而想要表述其看上去可能会是什么,而实际上又不想给出如何识别它的序列指令,上面便是个简练的方法。正则表达式与此相似(并且事实上它能够满足这种特定语法产品的需要)。
还有另一个声明性示例,请研究描述有效XML文档方言的文档类型声明:
清单4.XML文档类型声明
<!ELEMENTdissertation(chapter+)> <!ELEMENTchapter(title,paragraph+)> <!ELEMENTtitle(#PCDATA)> <!ELEMENTparagraph(#PCDATA|figure)+> <!ELEMENTfigureEMPTY>
和其它示例一样,DTD语言不包含任何有关如何识别或创建有效XML文档的指令。它只描述了如果文档存在,那它会是怎么样的。声明性语言采用虚拟语气。
Python作为解释器vsPython作为环境
Python库可以通过两种截然不同的方式中的一种来利用声明性语言。或许更为常用的技术是将非Python声明性语言作为数据来解析和处理。应用程序或库可以读入外部来源(或者是内部定义的但只用作“blob”的字符串),然后指出一组要执行的命令式步骤,这些步骤在某种形式上与那些外部声明是一致的。本质上,这些类型的库是“数据驱动的”系统;声明性语言和Python应用程序执行或利用其声明的操作之间有着概念和范畴差别。事实上,相当普遍的一点是,处理那些相同声明的库也被用来实现其它编程语言。
上面给出的所有示例都属于第一种技术。库PyLog是Prolog系统的Python实现。它读取像样本那样的Prolog数据文件,然后创建Python对象来对Prolog声明建模。EBNF样本使用专门变体SimpleParse,这是一个Python库,它将这些声明转换成可以被mx.TextTools所使用的状态表。mx.TextTools自身是Python的扩展库,它使用底层C引擎来运行存储在Python数据结构中的代码,但与Python本质上几乎没什么关系。对于这些任务而言,Python是极佳的粘合剂,但是粘合在一起的语言与Python差别很大。而且,大多数Prolog实现都不是用Python编写的,这和大多数EBNF解析器一样。
DTD类似于其它示例。如果您使用象xmlproc这样的验证解析器,您可以利用DTD来验证XML文档的方言。但是DTD的语言并不是Python式的,xmlproc只将它用作需要解析的数据。而且,已经用许多编程语言编写过XML验证解析器。XSLT转换与此相似,也不是特定于Python的,而且像ft.4xslt这样的模块只将Python用作“粘合剂”。
虽然上面的方法和上面所提到的工具(我一直都在使用)都没什么不对,但如果Python本身是声明性语言的话,那么它可能会更精妙,而且某些方面会表达得更清晰。如果没有其它因素的话,有助于此的库不会使程序员在编写一个应用程序时考虑是否采用两种(或更多)语言。有时,依靠Python的自省能力来实现“本机”声明,既简单又管用。
自省的魔力
解析器Spark和PLY让用户用Python来声明Python值,然后使用某些魔法来让Python运行时环境进行解析配置。例如,让我们研究一下与前面SimpleParse语法等价的PLY语法。Spark类似于下面这个示例:
清单5.PLY样本
tokens=('ALPHANUMS','WORDPUNCT','CONTRACTION','WHITSPACE') t_ALPHANUMS=r"[a-zA-Z0-0]+" t_WORDPUNCT=r"[-_]" t_CONTRACTION=r"'(clock|d|ll|m|re|s|t|ve)" deft_WHITESPACE(t): r"\s+" t.value="" returnt importlex lex.lex() lex.input(sometext) while1: t=lex.token() ifnott:break
我已经在我即将出版的书籍TextProcessinginPython中编写了有关PLY的内容,并且在本专栏文章中编写了有关Spark的内容(请参阅参考资料以获取相应链接)。不必深入了解库的详细信息,这里您应当注意的是:正是Python绑定本身配置了解析(在这个示例中实际是词法分析/标记化)。PLY模块在Python环境中运行以作用于这些模式声明,因此就正好非常了解该环境。
PLY如何得知它自己做什么,这涉及到一些非常奇异的Python编程。起初,中级程序员会发现可以查明globals()和locals()字典的内容。如果声明样式略有差异的话就好了。例如,假想代码更类似于这样:
清单6.使用导入的模块名称空间
importbasic_lexas_ _.tokens=('ALPHANUMS','WORDPUNCT','CONTRACTION') _.ALPHANUMS=r"[a-zA-Z0-0]+" _.WORDPUNCT=r"[-_]" _.CONTRACTION=r"'(clock|d|ll|m|re|s|t|ve)" _.lex()
这种样式的声明性并不差,而且可以假设basic_lex模块包含类似下面这样的简单内容:
清单7.basic_lex.py
deflex(): fortintokens: printt,'=',globals()[t]
这会产生:
%pythonbasic_app.py ALPHANUMS=[a-zA-Z0-0]+ WORDPUNCT=[-_] CONTRACTION='(clock|d|ll|m|re|s|t|ve)
PLY设法使用堆栈帧信息插入了导入模块的名称空间。例如:
清单8.magic_lex.py
importsys try:raiseRuntimeError exceptRuntimeError: e,b,t=sys.exc_info() caller_dict=t.tb_frame.f_back.f_globals deflex(): fortincaller_dict['tokens']: printt,'=',caller_dict['t_'+t]
这产生了与basic_app.py样本所给输出一样的输出,但是具有使用前面t_TOKEN样式的声明。
实际的PLY模块中要比这更神奇。我们看到用模式t_TOKEN命名的标记实际上可以是包含了正则表达式的字符串,或是包含了正则表达式文档字符串和操作代码的函数。某些类型检查允许以下多态行为:
清单9.polymorphic_lex
#...determinecaller_dictusingRuntimeError... fromtypesimport* deflex(): fortincaller_dict['tokens']: t_obj=caller_dict['t_'+t] iftype(t_obj)isFunctionType: printt,'=',t_obj.__doc__ else: printt,'=',t_obj
显然,相对于用来玩玩的示例而言,真正的PLY模块用这些已声明的模式可以做更有趣的事,但是这些示例演示了其中所涉及的一些技术。
继承的魔力
让支持库到处插入并操作应用程序的名称空间,这会启用精妙的声明性样式。但通常,将继承结构和自省一起使用会使灵活性更佳。
模块gnosis.xml.validity是用来创建直接映射到DTD产品的类的框架。任何gnosis.xml.validity类只能用符合XML方言有效性约束的参数进行实例化。实际上,这并不十分正确;当只存在一种明确的方式可将参数“提升”成正确类型时,模块也可从更简单的参数中推断出正确类型。
由于我已经编写了gnosis.xml.validity模块,所以我倾向于思考其用途自身是否有趣。但是对于本文,我只想研究创建有效性类的声明性样式。与前面的DTD样本相匹配的一组规则/类包括:
清单10.gnosis.xml.validity规则声明
fromgnosis.xml.validityimport* classfigure(EMPTY):pass class_mixedpara(Or):_disjoins=(PCDATA,figure) classparagraph(Some):_type=_mixedpara classtitle(PCDATA):pass class_paras(Some):_type=paragraph classchapter(Seq):_order=(title,_paras) classdissertation(Some):_type=chapter
您可以使用以下命令从这些声明中创建出实例:
ch1=LiftSeq(chapter,("1stTitle","Validityisimportant")) ch2=LiftSeq(chapter,("2ndTitle","Declarationisfun")) diss=dissertation([ch1,ch2]) printdiss
请注意这些类和前面的DTD非常匹配。映射基本上是一一对应的;除了有必要对嵌套标记的量化和交替使用中介体之外(中介体名称用前导下划线标出来)。
还要注意的是,这些类虽然是用标准Python语法创建的,但它们也有不同寻常(且更简练)之处:它们没有方法或实例数据。单独定义类,以便从某框架继承类,而该框架受到单一的类属性限制。例如,<chapter>是其它标记序列,即<title>后面跟着一个或多个<paragraph>标记。但是为确保在实例中遵守约束,我们所需做的就是用这种简单的方式来声明chapter类。
编写像gnosis.xml.validity.Seq这样的父类程序所涉及的主要“技巧”,就是在初始化期间研究实例的.__class__属性。类chapter自身并不进行初始化,因此调用其父类的__init__()方法。但是传递给父类__init__()的self是chapter的实例,而且self知道chapter。为了举例说明这一点,下面列出了部分gnosis.xml.validity.Seq实现:
清单11.类gnosis.xml.validity.Seq
classSeq(tuple): def__init__(self,inittup): ifnothasattr(self.__class__,'_order'): raiseNotImplementedError,\ "ChildofAbstractClassSeqmustspecifyorder" ifnotisinstance(self._order,tuple): raiseValidityError,"Seqmusthavetupleasorder" self.validate() self._tag=self.__class__.__name__
一旦应用程序程序员试图创建chapter实例,实例化代码就检查是否用所要求的._order类属性声明了chapter,并检查该属性是否为所需的元组对象。方法.validate()要做进一步的检查,以确保初始化实例所用的对象属于._order中指定的相应类。
何时声明
声明性编程样式在声明约束方面几乎一直比命令式或过程式样式更直接。当然,并非所有的编程问题都是关于约束的-或者说至少这并非总是自然定律。但是如果基于规则的系统(比如语法和推理系统)可以进行声明性描述,那么它们的问题就比较容易处理了。是否符合语法的命令式验证很快就会变成非常复杂难懂的所谓“意大利面条式代码”(spaghetticode),而且很难调试。模式和规则的声明仍然可以更简单。
当然,起码在Python中,声明规则的验证和增强总是会归结为过程式检查。但是把这种过程式检查放在进行了良好测试的库代码中比较合适。单独的应用程序应该依靠由像Spark或PLY或gnosis.xml.validity这样的库所提供的更简单的声明性接口。其它像xmlproc、SimpleParse或ft.4xslt这样的库,尽管不是用Python进行声明的(Python当然适用于它们的领域),也能使用声明性样式。