Skip to content

Latest commit

 

History

History
225 lines (131 loc) · 22.5 KB

doc.md

File metadata and controls

225 lines (131 loc) · 22.5 KB

OpenLua

一、 基本概念介绍

###1、 静态元编程 元(meta)这个词是从希腊词汇中借来的,意为“after”或者“before”,用于表示级别的改变。而在计算机科学中,它主要表示“being about”(关于)的意思。因此元程序(meta-program)是关于程序本身的程序,正如元数学(meta-mathematics)是关于数学本身的数学一样。

元程序实际上可以在不同的语境(context)和不同的时间段运行,但如果元程序是在它们操纵的代码被载入(loaded)之前运行的话,那么就称其为静态元程序(static meta-program),相应的设计活动称为静态元编程(static meta-programming)。包含静态元程序的系统最常见的例子就是编译器和预处理器。这些系统操纵表示输入的内部数据结构(比如抽象语法树),并把它们转换为使用其它语言(例如汇编)或者同样语言但结构被修改了的程序。

在编译期运行的元程序只是静态元程序的一种,不过却是非常常见的一种。如果没有特别说明,本文剩下部分提到的元程序或静态元程序均特指编译期运行的静态元程序。如果要让元程序在编译期运行,那么编译器就有义务为它们提供一个完整的执行环境(execution environment)。

###2、 开放式编译器

开放式编译器(open compiler)的思路是向静态元程序提供某些定义良好的高层接口,使其能够改变默认的编译动作(比如按照新的语法来解析源代码),获取并操纵编译过程中源代码的内部表示(比如语法解析树: syntax parse tree)。事实上每一个编译器或多或少都能称得上是开放式的,因为编译器后端(代码生成器)必然要调用前端提供的接口以取得代码的中间表示(通常是抽象语法树或3元组),向元程序提供接口只不过是朝开放的道路上更前进了一步而已。

二、 研究意义

从理论上来说,任何一种图灵完备(Turing-complete)语言的计算能力都是等价的,但实际上却没有一种语言(即使它号称通用语言)对解决所有问题都是适合的。因为语言内建的表达机制 (语法设施、数据类型,控制结构,组合手段)通常是固定的,而适合描述某类问题的语言,对其它类型问题可能就力不从心了。领域专用语言(DSL,Domain Specific Language)通过提供合适的机制可以优雅而高效地解决特定领域的问题,因此DSL越来越受到开发者的重视。然而从头构建一门新的针对特定领域的语言对许多开发者来说是一项非常困难的工作,因此程序员迫切需要一种允许使用者在基本框架的基础上不断发展出新机制的语言,即可扩展式编程语言(extensible programming language)。开放式架构的编译器提供了这种可能:用户可以在原始语言的基础上定义新的语法格式,可以把用新语法格式书写的源代码转换成语义相等的原始语言代码(这不是必需的,事实上你可以对它们做任何事)。这样程序员便可以在无需重写或扩展编译器的前提下,为一门语言引入适合描述他们所面对问题的新的语法设施、新的控制结构,新的关键字等等。这可不就是创造了一门DSL?

三、OpenLua是什么?

OpenLua这个名字代表两个意思:一是指为支持静态元编程而对标准Lua进行扩展得到的语言;二是指一款针对上述语言的,并且开放了若干内部可编程接口的编译器。读者一般根据上下文就能判断当前OpenLua这个词确指哪个概念,只有在可能引起混淆的情况下作者才会做额外说明。

OpenLua提供给用户的静态元编程语言就是标准Lua本身,因此程序员无需学习另外一门语言即可轻松地利用开放式编译器的扩展能力。而有的系统提供的静态元编程语言与普通源代码使用的语言间的编程模型相差太大,最突出的例子便是C++的template。作为元编程语言的template本质上提供的是一种函数式编程范式(functional programming paradigm):没有变量、不允许赋值(也就没有side-effects)、用递归来实现各种控制结构,这对许多C++程序员来说都是非常陌生的。

该系统主要用标准Lua语言并辅以少量的C来实现,所有功能(包括词法分析,语法分析)均为从头独立构建,并未采用任何外部辅助工具(比如YACC等)。做出这样的设计决定有这么几个原因:1、Lua官方网站http://www.lua.org已经为我们提供了一个高效率的标准实现,这使得用Lua来编写Lua语言编译器(一个有趣的元环: meta-circle)成为可能;2、标准Lua本身非常简洁、易于使用并且功能足够强大,尤其是提供了比较完善的文本处理函数,这一切都有助于开发效率的提高;3、Lua是作为一门嵌入式语言(embeded language)来设计的,并且以C程序库的形式提供完整系统,这使得我们很容易将标准Lua语言的解释器集成到OpenLua编译器中,从而为静态元程序提供一个完善的运行时环境;4、OpenLua是一个开放式架构的编译器,它的很多编译行为(包括词法分析、语法分析)与非开放式系统有很大不同,而YACC等自动生成工具只能生成普通的封闭式系统,因此类似工具并不适合在本项目中使用。

因为Lua虚拟机及其指令集并非语言定义的一部分,它们有可能在未通知语言使用者的情况下变更,而且底层的虚拟机平台与OpenLua提供的静态元编程能力、开放式架构并无关联,所以OpenLua编译器没有做生成字节码(bytecode)的工作。

具体一点来说,OpenLua的编译过程就是:首先解析源文件中的OpenLua源代码,并同时执行相应的静态元程序(如果有的话)以对源代码进行各种转换与处理,如果不出错(意味着语法正确、静态元程序运行无误)那么解析完毕后即生成一颗标准Lua语言的语法解析树(syntax parse tree),最后如果需要(见下文关于OpenLua使用格式的介绍)便将这颗语法解析树反解析(unparse)回标准Lua程序文本。图1形象地刻画了整个过程。反解析得到的标准Lua程序代码一定是能被标准Lua编译器编译通过的语法正确的代码,因为语法树的生成过程即保证了这一点。

workflow

四、 OpenLua的使用格式

OpenLua的使用格式是:

openlua sourcefile [outputfile]

sourcefile是待编译的openlua源文件名,outputfile是可选的输出文件名。上述命令会首先编译sourcefile,如果出错便打印错误信息并退出;如果成功且指定了outputfile参数,openlua就会把编译sourcefile得来的语法树反解析回标准Lua程序并输出到outputfile中,最后显示编译成功消息并退出。

五、OpenLua能做什么?

利用OpenLua我们可以为Lua实现循环编译、面向方面编程(AOP)以及契约式开发(Design by Contract)等一系列有趣的应用,具体请看https://github.com/netease/openlua/raw/master/doc/openlua-design.pdf 的第四章。

六、 OpenLua对标准Lua语言的扩展

###1、 语法的形式化定义及其书写规则

标准Lua语言语法的形式化定义采用的是EBNF格式,大概有70个左右的产生式。基于实现上的考量,作者将它们改写成了标准BNF形式。

一个产生式形如:

field : '[' exp ']' '=' exp

冒号左边是非终结符(nonterminal),也即产生式左部(left side),而冒号右边则是产生式右部(right side)。产生式中出现的所有语法符号有两种书写形式:(1)没有被单引号引用的字符串,代表该语法符号的名字,它必须以下划线(_)或字母开头,其后紧随0或多个字母、数字或下划线,这与标准Lua中对变量名的词法规定是一致的,非终结符和关键字(keyword)一定要写成这种形式。(2)用单引号引用住的字符串,这种形式用于表示标点(punctuation)、操作符(operator)等语法符号,注意单引号本身不是符号的一部分。

具有相同左部的产生式可以把左部合并在一起,右部之间用|号分隔,不同的右部可以写在一行内,也可分几行写,但是同一个右部只能写在同一行内。冒号或者|号必须与它们引导的右部在同一行,并且右部不允许为空。比如

fieldsep : ',' | ';'

或者

fieldsep : ',' | ';'

都是正确的格式,但是

fieldsep : ',' | ';'

却是错误的格式,因为|号引导的右部';'与它不在同一行内。

如果左部是新的非终结符,那么产生式一定要另启新的一行写。empty和eof是两个特殊的终结符,代表空符号和输入结尾符,用户不应该用它们做任何非终结符的名字。

OpenLua在标准Lua的基础上引入了3个新的语法成分: 用户自定义语法(user-defined syntax)、源代码转换子(source code transformer)以及编译期模块导入(import module)语句。

###2、 用户自定义语法

引入用户自定义语法的产生式如下:

syntaxdef : syntax Name ':' Literal

syntax关键字后是自定义语法的名字,然后一个冒号,最后是包含了完整描述自定义语法产生式的字符串。程序1是个例子:

syntax contractfuncSyntax : 
[[ 
  cf : Name '(' optional_parlist ')' pre block post end 
  pre : require exp 
      | empty 
  post : ensure exp 
       | empty 
]]

程序1

在OpenLua(以及标准Lua)中,被 [[ 和 ]] 包住的字符串是raw Literal,即意味着它不会因换行而中止,也不解释任何转义字符,这使得书写包含程序文本之类内容的字符串非常方便。自定义语法的产生式书写规则与上一小节介绍的完全一致,用户可以在自定义语法中引入新的关键字(如本例中的require和ensure),也可以引入新的长度为1的标点或操作符(本例并无体现)。很多时候定义语法成分是一件很繁琐的事情,如果每次都要求用户从头开始,可想而知他们会多么沮丧。因此系统允许自定义语法引用OpenLua内建的所有语法符号(包括终结符与非终结符),如本例的Name终结符和end关键字,以及optional_parlist和block非终结符,但不可重定义它们。用户无需关心内建非终结符如何解析,他只需明白解析器(parser)会在合适的时候根据内建非终结符的语法规则进行解析并生成一个正确的语法变量,这就使得内建非终结符在自定义语法里表现得就象一个终结符一样。对自定义语法的最后一点要求是:它必须是个LL(1)语法,否则编译器将不会接收它。

###3、 源代码转换子

源代码转换子定义的产生式为 :

transformerdef : transformer Name block end

其中transformer是关键字,Name是转换子的名字,block是一个标准Lua代码块,在这里作为转换子的转换体用于执行具体的转换操作,它的返回值要么是一个字符串要么是nil。一个名字被定义为transformer后,它在源代码中就具有特殊的意义了:编译器遇到一个已被定义为transofrmer的名字后,会把它从源代码输入流(input stream)中取出并抛弃,然后立刻执行它的转换体(即block代表的代码块),如果第一个返回值是nil,编译器报错并退出,如果第一个返回值是字符串,编译器就会把这一段字符串当作转换后得到的程序文本插入到当前源码输入流的头部,这整个过程称作转换子的调用。在OpenLua中,静态元编程能力正是由转换子的定义与调用提供的。

假设有这么一段程序:

transformer test 
  return "print('test')" 
end 
a = 2006 
test 

程序2

那么它被编译后得到的输出便是:

a = 2006 
print('test') 

程序3

由此可见转换子调用的结果就好象程序员自己在调用处写下了一些不同的代码(这些代码正是转换体的返回值)一样。当然,本例定义的转换子并没有任何实际意义,但是它却演示了转换子的一个基本作用:代码替换。在介绍完OpenLua提供的编程接口后,读者会看到转换子的另一个基本作用:代码转换。

###4、 编译期模块导入

编译期模块导入语句的产生式为:

import_module : import Literal

import关键字后是你要导入的模块文件名。当编译器遇到import语句时,它会立刻去找相应的文件,如果找到了即会编译它(是的,完全编译),但编译产生的那些运行期代码被简单地抛弃,而只有文件中的语法定义、转换子定义得以保留。毫无疑问,编译期模块导入机制的最大作用就是为了复用(reuse)那些自定义语法和转换子。

七、OpenLua提供的可编程接口及元程序运行环境

OpenLua通过transformer提供了静态元编程能力,但是如果编译器没有开放出一些必需的内部接口,并为元程序运行提供一个完善的运行环境,那么元程序能够做的事情将会很有限。因此作为开放式架构编译器的OpenLua提供了一系列的关键接口。 让我们先来看一个例子:

syntax metablockSyntax : 
[[ 
  metablock : block endmeta 
]] 

transformer meta 
  -- lock用于禁止这段代码重入 
  if not lock() then 
    return nil,"meta transformers can't internest" 
  end 

  -- parse是编译器开放的接口
  local tree,error = parse(metablockSyntax) 

  if nil == tree then 
    return nil,error 
  end 

  local f = loadstring(tree:get_child(1):emit()) 

  -- _METAENV是用于保存编译期运行变量的全局环境 
  setfenv(f,_METAENV) 

  local succeed,msg = pcall(f) 
  if not succeed then 
    return nil,"metaprogram run error : "..msg 
  end 
  unlock() 
  return "" 
end 

程序4

请首先注意parse函数,这是编译器开放给用户的最重要的接口。它以某个自定义语法为参数,并有两个返回值:第一个代表生成的语法树,第二个代表可能的出错信息。该函数被调用时会按照指定语法对当前源码输入流进行解析,如果成功则返回生成的语法树和nil,如果失败就返回nil和出错信息。

一颗语法树可以通过保存其根节点来保存,而使用语法树亦是通过引用其根节点来进行的。一颗语法树节点(syntax tree node)是一个Symbol类型对象,也即所谓的语法变量(syntactical variable),在接下来的论述中作者对这三个概念将不加区分,混合使用。Symbol类型对象提供了type、name、value、has_children、children_count、get_child、emit等一系列接口来取得相关信息。假设s是一个语法变量,那么s:type()用来取得语法变量的类型名,对于非终结符来说,就是它们的名字,对于终结符来说,分keymark(包括关键字、操作符、标点)、Name、Literal和Number 4种。s:value()用于取得语法变量的值,keymark、Name和Literal类的值就是表示相应终结符的字符串,而对于Number来说则是它代表的那个数的值,这个函数对非终结符没有意义。对于所有非keymark类语法变量来说,s:name()得到的就是s:type(),而对于keymark来说,得到的却是s:value()。s:has_children()返回一个标明该语法树节点是否拥有子节点的bool值,所有终结符变量都不可能有子节点,只有非终结符变量才可能有。children_count和get_child分别用于计算语法树节点的子节点个数和取得某个指定的子节点。比如OpenLua语法中定义syntaxdef的产生式是这样的:

syntaxdef : syntax Name ':' Literal

那么当s为syntaxdef类型的语法变量时,s:children_count()就得到4(因为产生式的右部有4个符号),而s:get_child(1)将得到一个类型为keymark值为syntax的语法变量(因为syntax关键字是右部的第一个符号),同理s:get_child(4)将得到一个类型为Literal的语法变量。OpenLua只提供了按照它们在产生式右部所处的位置来取得子节点的方式,这是因为右部完全可能同时出现好几个名字相同的符号,而只有它们在右部所占的位置才是唯一的。如果get_child的位置参数超出了相应的范围,那么返回值将是nil。s:emit()会将s所代表的语法树反解析回程序文本,并以字符串的形式返回。

每一段Lua代码都有一个与之相关的环境(enrironment),供其在执行之时寻找代码中遇到的所有全局变量,这与C++中的命名空间(namespace)非常类似。而且,一个环境本质上就是一个保存了所有全局变量name-value对的普通table,因此我们可以象操纵普通table那样操纵环境。尽管在编译期运行,转换子的转换体仍然是一段不折不扣的标准Lua代码,它同样也该有自己的环境。OpenLua为所有的转换体设置了一个统一的环境,我们称之为静态元环境(static meta-environment)或元环境(meta-environment)。已定义的语法名保存在这里,parse等编译器接口也在这里,更重要的是,标准Lua提供的所有基本函数与库函数[IFC2003]都可以在这个环境中获得。最后一点非常重要,因为它使得静态元程序拥有了与运行期程序同样丰富的编程接口,而这当然大大方便了元编程工作。因为用户自定义语法是保存在元环境中,所以转换体代码可以通过它们的名字来引用这些语法,并且语法名可与运行期代码中的变量名相同,因为它们的生命期(life time)根本不会重叠。在元环境中有一个特殊的变量叫_METAENV,它的值就是元环境本身,很显然,_METAENV._METAENV(别忘了环境就是一个table)等于_METAENV。需要特别指出,已定义的转换子并非保存在元环境中,而是另一个特殊的编译期环境,因此转换子和自定义语法、编译期函数都可以同名,并不会产生冲突。

在程序4里,parse按照metablockSyntax解析成功后得到的语法树被保存在局部变量tree中,调用tree:get_child(1)得到这颗树的第一颗子树(即metablock : block endmeta中的block)。然后利用emit将block反解析回程序文本,并调用标准Lua基本函数loadstring把这段程序文本编译并生成一个新的函数,接着利用setfenv(同样来自标准Lua)将这个新生成的函数的环境设置为_METAENV(即统一的静态元环境)。最后在保护模式下执行它,如果执行成功则返回空字符串,否则返回nil(这会导致编译器报错并退出)和出错信息。

lock和unlock是编译器提供的另外两个接口。其中lock函数的功能是为lock的调用者“加一个锁”,如果之前该调用者还没被加锁,那么将其锁上并返回true,如果已经加了锁,则什么也不做并返回false。unlock执行解锁功能,如果之前调用者确实被锁上,那么unlock将其解锁并返回true,如果原本就没被锁上,那么什么也不做并返回false。lock和unlock的配对使用,可以很容易控制函数是否可重入,这正是程序2―4的做法(是的,转换子的转换体代码会被编译器悄悄地改造成一个函数)。

这个meta转换子起什么作用呢?其实它就是让用户无需定义transformer便能方便地运行元程序。假设程序4保存在一个名为std.ol(ol是推荐使用的OpenLua源代码文件后缀名)的文件中,并且另有一个名为temp.ol的文件:

 -- temp.ol 
 import "std.ol" -- 象这样没指定路径的话,就要求std.ol在当前目录下 

 meta 
   print("Hello world from metaprogram !") 
 endmeta 

程序5

运行openlua temp.ol命令,那么你会看到这么两行输出信息 :

Hello world from metaprogram !

Compile temp.ol file successfully !

这清楚地表明了介于meta和endmeta之间的标准Lua代码是不折不扣的元程序,在编译器解析完它们之后就会立刻运行,并且不会对编译器反解析出的程序文本产生任何影响(因为在meta的定义中,成功执行这些代码后返回的是一个空字符串)。不过特别要注意的是,meta、endmeta之间不能再嵌套meta、endmeta了(这是通过lock与unlock实现的),为什么?被第一层的meta和endmeta包含住的代码是有别于普通代码的元代码,假设里头又嵌套了一层,那么被这一层包含住的代码岂非成了元元代码(meta meta-program)?而它又该在什么时候执行呢?元代码的编译期?假设再多一层呢?由此可见,meta嵌套带来的概念上的混乱远远大于其微乎其微的好处,所以作者坚定地禁止了这种写法。

meta转换子的定义展示了transformer的另一个重要作用:代码转换。这里是将meta、endmeta内部的程序文本转换成了空字符串,当然(而且是通常)我们也可通过转换子将某种结构的源代码转换成另一种结构的源代码并插回到转换子的调用点。

八、 “热切”原则

假设有一个内容为"a,b,c foo()"的程序文本,在这个输入的头部,可以成功解析出3种namelist,分别是:(1)"a";(2)"a,b";(3)"a,b,c",它们都是合法的namelist。那最终生成的语法树是选择哪种情况呢?这里就有一个很重要的原则,即“热切” (eager)原则。它的意思是,编译器会尽可能地在输入流中读取输入,只要读进的这些token最终都会被规约成目标语法树的一部分。按照“热切”原则,编译器最终会把"a,b,c"都读入,然后把它们最终规约成一个长的namelist。假设不采用eager原则,那么最后我们得到的常常是一颗语法正确但没有意义的语法树。考虑一下目标非终结符s可以为空的情况,一旦在符号栈中通过产生式s : empty进行规约生成了s,没有采用eager原则的解析器就会认为此时解析已经成功,于是返回了这颗只能生成空串的语法树。所以,应用eager原则才能有效地避免上述没什么意义的行为。并且,根据作者的使用经验来看,“热切”原则更加符合程序员的直觉。就象在本例中,绝大多数人都会认为编译器会把"a,b,c"都读出来,而实际上OpenLua也的确会这么做。