jslex生成lex 词法分析析性能怎么样

您的位置: >>
  承玉曾经写过一篇文章,文中提到:
从本质上看模板也是一个微型语言,因此可以从 DSL 的角度着手,使用工具快速构建一个适合于特定前端框架的模板引擎。
  本文讨论的话题和承玉的差不多,相信大家都知道coffeescript,handlerbars。承玉的DSL和handlerbars类似,我完成了一个的解析,更接近coffeescript的编译。在此,与大家分享一些经验,如果你也希望知道coffeescript语法解析如何完成的,那么,本文应该对你有所启示。
  让我们回顾一下2010年D2的时候,Hedger介绍了Closure Compiler,老赵的jscex,他们有一个共同点,都是对js进行编译,让js运行更快或者提供一起额外的功能。编译这么一个似乎和JavaScript没有关系的话题,却逐渐被越来越多的人提起。
  本文主要介绍如何用js写一个编译器,这看起来似乎很高级,实际上,编译原理很复杂,写一个编译器却不怎么难,在做这个模板编译之前,我个人对于编译原理完全不知道的,因为看到coffeescript语法是Jison生成的,然后尝试了一下。写一个编译器,其实就是把一些语法规则翻译成计算机能够理解的结构,计算机所能理解语法规则有专门的描述语言,Yacc + Lex。IBM上有如此描述:
Lex 和 Yacc 是 UNIX 两个非常重要的、功能强大的工具。事实上,如果你熟练掌握Lex 和 Yacc 的话,它们的强大功能使创建 FORTRAN 和 C 的编译器如同儿戏。
  Yacc + Lex的一个实现是,09年Zach Carter为了研究编译原理课程,用js完成了Bison的实现, 承玉的类似。故事就讲到这里,什么是Yacc,Lex,Bison,Jison,Kison,都不重要,重要的是,这些技术使得我们可以使用简单的方式完成复杂的字符串解析(比如编译)任务。现在我们要来实现一个编译器了,看完就知道这一切了。
  在此声明,对于编译的理解仅限于个人理解,如有不对之处,欢迎指正。关于什么是编译,最初编译是指源码转换为二进制机器码的过程,编译可以是高级语言到低级语言的过程,从后面的一个含义上来看,编译可以看作是把一段源码,依据一个语法规则,输出结果的过程,也是本文所指的编译。
  Lex & Yacc
  Lex和Yacc主要用于解决编译中的第一个问题,源文件从字符串变得有意义(结构化数据)。这个过程,又分为两个步骤:
源文件拆分成各种标志(tokens) Lex
构造数据结构 Yacc
  学习英语的时候,我们都会遇到语法问题,对于陌生的语言,需要知道其语法规则,计算机语法规则与自然语言类似,只是自然语言是与上下文有关的语言,比起计算机语言复杂得多。与上下文无关,其实就是语言的符号意义是确定的。扯远了,举个例子,一个正常的英语句子:
I have a dream.
  回到英文课堂,老师会说,句子是由主语+谓语+冠词+宾语构成,这个句子构成的元素是,主语I,谓语have,宾语dream,句号表示句子的结束。这样的一个语法规则,让计算机理解,需要依据上面的两个步骤:
识别单词,也就是英语中的主语、谓语和宾语,好吧这些背单词的时候记住就行,标点符号也是词法元素。
语法识别,上面的句子对应的语法是:谓语 + 主语 + 冠词 + 宾语 + 问号 =& 句子
  词法识别和英语学习中背单词一样,计算机通过正则在字符串中匹配词,构成语言的基本结构,这些结构按照一定组合规则构成语法。Yacc所做的,是把扫描一串字符串,识别其中的词,把词和所描述的语法一一对照,然后能够得到一些结构化的数据,比如上面英语,计算机就能够知道,这是一个完整的句子,三个主要成分是I、have、dream,至于这个句子什么意思,你应该如何处理,这是编译过程的第二步了。
  velocity syntax
  上面简单描述了一下原理,现在开始写语法规则分析器吧。写编译器就是把一套语法规则描述清楚,就像翻译一篇说明书。当然,我们首先需要能明白说明书的意义,本文以velocity模板语言为例,velocity是Java实现的一套模板,是阿里集体后端webx框架的模板语言,语法规则,可以大致看下语法,或者点击在线尝试vm解释过程。
  vm(velocity简称)语法规则很简单,大概开5分钟就能学会,vm虽然简单,但是也是一套比较基本的计算机语言的实现了,对比下,英语我们学习了10年,还没能学好,vm只需要5分钟,自然语言的复杂度,比起计算机语言实在不是一个数量级。
#set( $foo = "Velocity" )
Hello $foo World!
  vm语法分为两部分,第一部分是vm语法内容,另一部分是字符串,模板语言都是如此,字符串部分无需考虑,原样输出即可,vm语法主要是前者结构分析。上面的vm输出Hello Velocity World!。语法部分,主要分为两部分References和Directives。
  References 和 Literal
  References是vm中变量,解析时References输出为变量对应的值,模板语言最基本的功能也就是变量替换,vm同样如此,只是vm还有一些其他复杂的功能。Literal和js里面的字面量一直,是vm里面的基本数据结构。vm是一个模板语言,变量的值可以来自外部,而且是主要数据来源,References和Literal这两者构成了vm语法的基本数据。
  References基本形式是$foo,或者加一些修饰符$!{foo}。复杂形式是,变量+属性,支持的属性方式有三种:
Properties 最普通的属性$foo.bar
Methods 方法$foo.bar(),因为方法是有参数的,参数由References和Literal构成
Index 索引$foo['bar'],index可以是字符串,也可以是变量References
  上面三种方式和js的对象属性查找方式一样,因为存在Methods和Index,方法和Index本身又可以包含References,引用的组成部分可以是引用。这样式描述形成了递归,语法一般都是通过递归的形式来相互包含。引用(References)里包含自身,这如果使用普通的字符串匹配,逻辑上会有些晕。
  Literal是基本的数据结构,分为字符串、map(js中的对象)、数字、数组。map的值由Literal 或者References构成,数组元素同样,字符串和数组相对简单,可以直接从源文件中匹配得到。到此,应该大致明白编译的复杂了吧,就这些基本的数据结构相互包含,要理清其中结构,还是很麻烦的吧,虽然我们可以一眼就知道这些结构,如何让计算机明白,就不那么容易了。不过,通过yacc,我们只需要描述清楚这些结构就行,怎么理清其中关系,Jison会自动处理的。
  Directives
  前面引用和字面量部分,是vm中关系最复杂的结构了,Directives是一些指令,包括逻辑结构和循环,模块之间引用加载等运算。这些结构比较好搞定,一般都是各自不相干,不像上面,相互引用,纠缠不清。vm解析,最复杂的还是在于引用的确定。
  Directives分为单行指令和多行指令,单行指令作用范围是一句,比如#set、#parse,多行指令主要是#macro,#foreach,ifelseelseif,这些都是通过#end来结束,这样的区分可以在语法分析阶段完成,也可以在后期处理。
  语法分析
  本文有些长,已经开始靠近目标了。上面描述语法的过程,是非常重要的,使用yacc描述语法规则,就是对输入源分类的过程。经过上面的分析,yacc的已经差不多构思好了,接下来把规则用yacc语法写下来就好。
  在写yacc描述之前,需要做一件是,lex词法分析。词法分析就是要找到上面说的References、Literal、Directives的基本元素。新建一个文件velocity.l,开始写lex描述。
  References
  从References开始,vm里面引用的最主要的特征是符号$,首先假设有一个vm字符串:
hello $foo world
  其中,$foo是References,很明显References是美元符号开头,$后面跟字母,这里需要引入状态码的概念,因为$后面的字母的意义和$前面的字母意义是不一样的,那么当扫描到$以后,可说我们处于不同的状态,区分好状态,就可以专心处理之和vm语法,否则同样的一个字符,意义就不一样。这个状态,我们用mu表示,状态吗可以随意命名,使用mu,是有渊源的,handlerbars的lex文件因为继承了Mustache语法,mu表示Mustache语法开始,我参考了handlerbars,所以用mu。
  velocity.l写下:
[^#]*?/"$"
{ this.begin("mu"); if(yytext) return 'CONTENT'; }
{ return 'DOLLAR'; }
{ return 'DOLLAR'; }
&INITIAL&&&EOF&&
{ return 'EOF'; }
  %x声明有的状态码,状态码和字符串或者正则表达式组合成一个特征,比如&mu&"$",双引号表示字符串,这个特征描述表示,mu状态下,遇到$,返回DOLLAR。我们用DOLLAR描述$,至于为什么我们要给$一个名字,再次回到英语中,我们会把单词分为名词、动词,各种分类,语法规则不会直接处理某个特定的词如何组合,而是规定某一类词的组合规则,比如,最普通的句子,主语+谓语+宾语,主语一般是名词,谓语是动词,宾语也是名词,这样描述要简单得多,lex词法分析是给字符做最小粒度的分类,最终,一个vm输入源码,可以归纳到一个分类里,符合英语语法的字符串,我们统称为英语。
  特征都使用全大写字母,这是一种约定,因为在yacc描述中,语法规则名都用小写。%%后面第一行,[^#]*?/"$",这是一个正则表达式,正则分为两个部分,第一部分 [^#]*?匹配所有不是符号#的字符,后面一部分"$",中间反斜杠分割,是一个向后断言,匹配美元符号前面所有不是符号#的字符,也就是遇到没有符号的时候,后面通过 this.begin开始状态mu。这里使用到yytext,就是前面正则所匹配到的内容,有个细节,这个匹配去除了#,因为#是另一种状态Directives的开始,这里暂时只讨论引用识别。最后一行,表示结束返回,这个无需理解。
  引用的最基本形式,$ + 字母,美元符号识别了,接下来识别后面的字母,使用正则表达式
&mu&[a-zA-Z][a-zA-Z0-9_]*
{ return 'ID'; }
  如此,我们可以用这两条规则,开始写第一条yacc语法规则了:
: DOLLAR ID
{ $$ = {type: "references", id: $2} }
  上面描述的是reference,由lex中返回的DOLLAR和ID组合成为一个reference,大括号里面写的是js代码,用于构造结构化数据,需要什么样的数据可以自己随便搞,$$表示返回结果, $1是DOLLAR词对应的字符串,也就是$,$2表示第二个词,也就是ID。复杂的reference可以继续写:
: DOLLAR ID
| DOLLAR ID attributes
attributes
: attribute
| attributes attribute
| property
: BRACKET literal CLOSE_BRACKET
| BRACKET reference CLOSE_BRACKET
  reference在原来的基础下,增加了attributes,attributes是由一个或者多个属性组成,在yacc中,使用attributes attribute来描述多个属性的情况,规则直接包含自身的情况还是非常常见的。attribute由 method,index,property 组成,继续拆分,index是两个中括号加一个literal或者 reference 组成,我们可以继续对literal进行分类,同样的描述。我们回到了上面对vm 语法描述的那个分类过程只不过,现在我们使用yacc的语法描述,前面使用的是自然语言。
  解析过程
  上面讲了那么多,现在来总结一下Jison解析一个字符串的过程。用一张图表示吧:
  词汇分析过程就是上面所描述的了,一个lex文件,构成一个词汇表,通过从左到右的扫描输入源,依次匹配词汇表里面定义的模式,然后构成一个个词汇。得到词汇之后,那什么是语法呢,还记得英语语法吗?在计算机里面,语法就是上面所描述的,词汇的组合,规定了词汇的组合形式,比如DOLLAR ID组成一个reference,写yacc语法规则就是不断的对语法进行分类,直到所有的分类最底层都是lex中的词汇,然后语法规则也就ok了。程序会自动根据yacc文件所有定义的规则,分析得到输入源对应的数据结构。
  velocity最终的语法描述在。
  状态码
  上面简要描述了yacc和lex工作原理过程,实际中,还是会遇到一些有意思的问题。在写vm解析器的时候,最麻烦的事情是,如何保证括号和中括号的匹配,首先看一段vm字符串:
$foo.bar($foo.name("foo")[1])
$foo.bar([)]
  经过分析,我发现括号匹配的一个特点是,括号的闭合状态下,它的前一个状态肯定是括号开始,中括号同样如此。因此,我在velocity.l中再引入两种状态,i, c,分别表示括号开始和中括号开始,在匹配到括号或者中括号结束的时候,判断前面的一个状态是否是符号的开始,这样,就能保证括号和中括号的配对。
  在lex词汇分析中,状态码是一个堆栈,这个堆栈通过this.begin开始一个状态,this.popStat退出一个状态,词汇可以是多种状态和正则表达式进行组合,状态的开始和结束,需要自己控制,否则可能会有问题。
  解析最终得到一个对象,这个对象的构造是根据velocity.yy而生成的。如何选择合适的数据结构,这个是很很重要的,后面的语法树解释过程,完全取决于解析器所返回的语法树。在velocity的语法树,最终得到的是一个一维数组,数组的元素分为字符串和对象两种,如果是对象,那么是vm语法需要进行分析解释的。
  语法树解释
  得到输入源语法结构之后的工作,相对而言就容易了,这其中会涉及到两个点,我个人觉得比较有意思的。第一个是局部变量,在vm语法中,有一个指令#macro,这个是vm的函数定义,由函数,自然有形参和实参,在函数执行过程中,形参是局部变量,只在函数解析过程中有效,#foreach也会形成一个局部变量,在foreach中有一个内部变量$foreach.index, $foreach.count, $foreach.hasNext这样的局部变量。
  局部变量的实现,可以参考lex语法分析过程,在语法树解释过程中,增加一个状态码,当进入一个foreach或者macro的时候,生成一个全局唯一的条件id,并且在状态中压入当前的条件id,当foreach和macro运行结束后,推出一个状态。foreach和macro控制状态,同时构造一个数据空间,贮存临时变量,在vm解析过程中,所有的变量查找和设置,都是通过同样的一个函数。当一个变量查询时,检测到存在状态时,首先依次根据状态码,找到对应状态下的局部变量,如果需要查询的变量在局部环境中找到,那么返回局部对象对应的值,如果是这是值,同样如此。这样的实现和js所中的函数执行上下文有点类似,可以继续想象一下如何实现避包,实现避包其实只需要在一个函数中返回一个函数,这样的语法在vm中没有,不过如果真的可以返回一个函数,那么只需要在这个函数和当前函数执行所对应的状态放在一起,并且不释放状态对象的局部变量,那么避包也就有了。
  本文到此要结束了,不知道是否有说明白,具体实现细节可以参考velocity.js。
Web前端热门文章
Web前端最新文章用25行JavaScript语句实现一个简单的编译器
原文:Implementing a Simple Compiler on 25 Lines of Java
作者:Minko Gechev
译者:夜风轻扬
译者注:即使对于专业程序员来说,构造一个编译器也是颇具挑战性的任务,本文将会引导你抽丝剥茧,一探究竟!
“关于 Angular 2 和 Type 项目中的静态代码分析”[1]中,我研究了编译器前端的基本知识,解释了词法分析、语法分析和抽象语法树等各个阶段。
最近我发表了“开发静态类型编程语言[2] ”。本文展示了一个简单的、静态类型的函数式编程语言,它是受到了lambda微积分的启发。我将编译器开发的前端部分外包给了解析器生成器,并将注意力放在用于类型检查的模块的后端,以及用于代码生成的模块。
为什么我需要这个?
你可能想“为什么我需要知道如何开发编译器?”,有几个重要的原因:
你将更好地理解所使用的编程语言是如何工作的。这将使你能够借助该语言开发出更高效的程序。
经常的,为了不同的目的,你不得不重用下面所述的模块(例如,对配置文件进行解析、对网络消息进行解析等等)。
创建一个DSL。在你的项目中创建一个特定领域的语言可能非常方便,以便简化任务,否则,使用通用编程语言,解决同样问题,你可能需要花费更多的时间。
我们要讨论的范围是什么?
在这篇博文中,我们将介绍从端到端的基础知识。我们将用25行Java代码开发一个非常简单的编译器!我们的编译器将会包含:
词法分析模块
语法分析模块 解析器将基于EBNF语法 我们将使用递归下行解析算法来开发解析器
代码生成器
我们将要探讨的语言对于开发有实际意义的软件程序并不是特别有用,但是它可以很容易地扩展成一个。
整个实现可以在我的GitHub介绍[3]中找到。
引入一种简单的前缀语言
下面是我们语言中的一个示例表达式的样子:
mul3sub2sum 134
在本文的最后,我们将能经过任何编译器的典型阶段,将这些表达式转换为Java语句。
为简单起见,我们需要遵循一些规则:
我们只有函数:mul,sub,sum,div。
每个字符串标记都被空格隔开。
我们只支持自然数。
为了探究上述表达式背后的语义,我们定义一些Java函数:
constmul= (...operands)=&operands.reduce ((a, c) =& a * c, 1); constsub= (...operands)=&operands.reduce ((a, c) =& a - c); constsum= (...operands)=&operands.reduce ((a, c) =& a + c, 0);
mul接受多个操作数,并通过 =&操作符传递。函数只是将它们相乘,例如mul(2、3、4)==24。sub分别减去传递的参数,sum则是计算参数的总和。
上述的表达式可以转换为下列的Java表达式:
mul(3, sub(2, sum(1, 3, 4)))
3 * (2 - (1 + 3 + 4))
现在,在我们了解了语义之后,让我们从编译器的前端开始吧!
注意:类似的前缀表达式可以通过基于堆栈的算法进行简单的评估,但是在这种情况下,我们将关注概念而不是实现。
开发编译器的前端
任何编译器的前端通常都有词法分析模块[4]和语法分析模块[5]。在这一节中,我们将在几行Java代码中构建两个模块!
开发一个词法分析器
词法分析阶段负责将程序的输入字符串(或字符流)划分为称为标记的小块。标记通常包含关于它们类型的信息(如果它们是数字、操作符、关键字、标识符等)、它们所代表程序的子串以及它们在程序中的位置。该位置通常用于报告用户友好的错误,以防出现无效的语法结构。
例如下列的Java程序:
if(foo) { bar(); }
一个示例的Java词汇分析器将生成输出:
[ { lexeme: 'if', type: 'keyword', position: { row: 0, col: 0} }, { lexeme: '(', type: 'open_paran', position: { row: 0, col: 3} }, { lexeme: 'foo', type: 'identifier', position: { row: 0, col: 4} }, ...]
我们将保持我们的词法分析器尽可能简单。事实上,我们可以通过一条 Java语句实现它:
const lex = str =& str .split( ' ') .map(s =& s .trim()) .filter(s =& s .length) ;
在这里,我们使用单一的空格来划分字符串,然后将产生的子串映射成修理版本并且过滤掉空串。
针对一个表达式调用lexer将产生一个字符串数组:
lex( 'mul 3 sub 2 sum 1 3 4') // [ "mul", "3", "sub", "2", "sum", "1", "3", "4"]
这完全实现了我们的目标!
现在让我们进入语法分析的阶段!
开发一个语法分析器
语法分析器(通常称为解析器)是一个编译器的模块,该编译器从一个标记列表(或流)中生成一个抽象语法树6。在这个过程中,语法分析器会对无效程序产生语法错误。
通常,解析器是基于语法[7]实现的。以下是我们语言的语法:
digit = 0| 1| 2| 3| 4| 5| 6| 7| 8| 9num = digit+op = sum | sub | mul | divexpr = num | op expr+
语法中包含有数字,这些数字组合在一起可以形成数字(num);有4个操作;一个表达式可以是一个数字,或者是操作,后面跟着一个或多个表达式。我们将把语法中的个体定义(例如num和op)作为规则。
这就是所谓的EBNF 语法[6]。稍微看一下语法,试着去理解它,然后完全忘记它!在解释解析器之后,我们将回到语法,你将看到所有东西是如何连接在一起的!
如前所述,解析器是一种工具,它将标记的列表转换为AST。
例如,对于我们的表达式:
mul3sub2sum 134
解析器将根据上面的语法生成下面的AST:
让我们来研究一下这个算法吧!
constOp = Symbol( 'op'); constNum = Symbol( 'num'); constparse = tokens =&{ letc = 0; constpeek= ()=&tokens[c]; constconsume= ()=&tokens[c++]; constparseNum= ()=&({ val: parseInt(consume()), type: Num }); constparseOp= ()=&{ constnode = { val: consume(), type: Op, expr: [] }; while(peek()) node.expr.push(parseExpr()); return }; constparseExpr= ()=&/\d/.test(peek()) ? parseNum() : parseOp(); returnparseExpr(); };
坏消息是,有很多事情正在发生。好消息是,这是编译器中最复杂的部分!
让我们把代码分成各个部分,然后一步步查看每一个步骤。
节点类型 constOp = Symbol( 'op'); constNum = Symbol( 'num');
首先,我们定义了在AST中将要使用的不同节点类型,我们只需要数字和操作。例如,数字节点42将会是这样的:
{ type:Num, val: 42}
运算符sum,应用到2、 3、 4,将会是这样的:
{ type:Op, val: 'sum', expr: [{ type: Num, va: 2}, { type:Num, va: 3}, { type:Num, va: 4}] }
这是多么简单啊!
语法分析器
在声明了节点类型之后,我们定义了一个名为parse的函数,该函数接受一个称为标记的参数。在它的内部,我们定义了另外五个函数:
- peek-返回与标记元素关联的本地变量c 的当前值。
- consum-返回与c本地变量和增量c的当前值相关联的标记元素。
- parseNum-获取当前的标记(即调用peek()),将其解析为一个自然数字,并返回一个新的数字标记。
- parseOp-我们会稍微研究一下。
- parseExpr - 检查当前标记与正则表达式/\d/(即是一个数字)是否匹配,如果成功则调用parseNum,否则返回parseOp。
parseOp可能是上面解析器中最复杂的函数。这是因为存在循环和间接递归的情况。这里是它再一次的定义为了可以逐行对进行解释:
constparseOp= ()=&{ constnode = { val: consume(), type: Op, expr: [] }; while(peek()) node.expr.push(parseExpr()); return };
当peek()的返回值不是数值时, parseExpr会调用parseOp,我们知道这是一个运算符,所以我们创建一个新的操作节点。注意,我们不会执行任何进一步的验证,但是,在实际的编程语言中,我们希望这样做,如果遇到一个未知的标记时,会报告语法错误。
无论如何,在节点声明中,我们将“子表达式”的列表设置为空列表(也就是[]),把操作名设置为peek()的值,把节点类型设置为Op。随后,通过将当前解析的表达式推到给定节点的子表达式列表中,我们循环遍历所有标记。最后,我们返回该节点。
牢记 while (peek()) node.expr.push(parseExpr());执行一个间接递归。我们得到如下表达式:
- 首先,调用parseExpr,结果会发现当期的标记(就是tokens[0])不是一个数(是sum),所以它会调用 parseOp。
- 在parseOp创建一个操作节点后,并且由于调用consume(),c值增加。
- 下一步,parseOp将会遍历节点,对于tokens[c],这里c对于1,将会调用parseExpr。
- parseExpr会发现当前的节点不是一个数,所以会调用 parseOp。
- parseOp会创建另外一个操作节点并且将c加1,并对所有的标记再来一次循环。
- parseOp 将会调用parseExpr,这时 c 不等于 2了。
- tokens[2] 是 “2”,parseExpr将会调用 parseNum,创立一个数值节点, 并将 c 变量加1。
- parseNum将会返回数节点并且推入到表达式数组的最后一个操作节点中,该节点通过最新一次的 parseOp 调用生成。
- 最后一次的 parseOp调用将会返回操作节点,因为 peek()将会返回undefined( parseNum将 c加到 3,tokens[3] === undefined)。
- 最后一次parseOp调用返回的节点将会返回给最外层的parseOp调用该调用也会返回操作节点。
- 最后,parseExpr 将会返回根操作节点。
产生的AST如下所示:
{ type: "Op",val: "sum", expr: [{ type: "Op",val: "sum", expr: [{ type: "Num",val: 2}] }] }
最后一步是遍历这棵树并生成Java!
递归下降解析
现在,让我们将单独的函数与上面定义的语法联系起来,看看为什么语法有意义。让我们来看看EBNF语法的规则:
digit = 0| 1| 2| 3| 4| 5| 6| 7| 8| 9num = digit+op = sum | sub | mul | divexpr = num | op expr+
现在它们更清晰一些了?expr看起来很像parseExpr,这里我们或者解析出一个数或者是一个操作。类似的,op expr+看起来很像parseOp和num类似parseNum。实际上,解析器常常直接从语法中生成解析器,因为它们都与递归下降解析算法[8]有直接的联系。
事实上,我们刚刚开发了一个简单的递归下降解析器!我们的解析器非常简单(我们语法中只有4个产生式规则),但是你可以想象一个真实的编程语言的解析器是多么复杂。
相较于编写真正的解析器,观察其简化模型,开发一种语言的语法是非常方便的。解析器包含大量细节(例如,你的开发语言的许多语法结构),这与极其简化和简约的语法形成了鲜明的对比。
开发编译器
在本文的这一部分中,我们将遍历语言的AST并生成Java。整个编译器只有7行Java代码(字面上的!)
consttranspile = ast =&{ constopMap = { sum: '+', mul: '*', sub: '-', div: '/'}; consttranspileNode = ast =&ast.type === Num ? transpileNum(ast) : transpileOp(ast); consttranspileNum = ast =&ast. consttranspileOp = ast =&` (${ast.expr.map(transpileNode).join(' '+ opMap[ast.val] + ' ')})`; returntranspileNode(ast); };
让我们来逐行探究一下实现的细节。
首先,我们定义了一个函数称为transpile。它接受由解析器生成的AST作为参数。然后在opMap中,我们第一了数据操作和语言中操作的映射。基本上,sum 映射为+,mul映射为*,等等。
下一步,我们定义函数transpileNode,该函数接受AST节点。如果节点是是一个数,我们调用transpileNum函数,否则,调用transpileOp。
最后,我们定义两个函数,来处理单个节点的转译:
- transpileNum - 把一个数转换成Java 中的数 (简单的直接返回这个数)。
- 将操作转换为Java算术运算。注意这里有一个间接的递归(transpileOp-&transpileNode-&transpileOp)。对于每个操作节点,我们都希望首先转换其子表达式。我们通过调用 transpileNode 函数来做到这一点。
在transpile函数体的最后一行,我们返回transpileNode的结果,形成这棵树的根节点。
将一切都结合在一起
以下是我们如何将所有部分连接在一起的方法:
constprogram= 'mul 3 sub 2 sum 1 3 4'; transpile(parse(lex( program))); // (3 * (2 - (1 + 3 + 4)))
我们调用 lex(program),生成标记列表,此后我们将这些标记传递给 parse函数,生成抽象语法树(AST),最后,我们将AST转译成Java!
本文详细介绍了一种非常简单的编译器(或transpile)的开发,将前缀表达式编译为Java。虽然这只是解释了编译器开发的一些基本知识,但是我们介绍了一些非常重要的概念:
源代码生成
递归下降解析
开发静态类型化编程语言
构造一个简单的解释器
编译器:准则、技术和工具(第二版)
类型和编程语言
Static Code Analysis of Angular 2 and Type Projects - https://mgv.io/ng-static-analysis.
Developing Statically Typed Programming Language - https://mgv.io/typed-lambda
Tiny Compiler - https://mgv.io/tiny-compiler
Lexical Analysis - https://mgv.io/wiki-lex
Syntax Analysis - https://mgv.io/wiki-parser
Abstract Syntax Tree - https://mgv.io/wiki-case-ast
EBNF grammar - https://mgv.io/wiki-ebnf
Recursive Descent Parsing - https://mgv.io/wiki-recursive-descent-parser
责任编辑:
声明:本文由入驻搜狐号的作者撰写,除搜狐官方账号外,观点仅代表作者本人,不代表搜狐立场。
今日搜狐热点}

我要回帖

更多关于 lex 词法分析 的文章

更多推荐

版权声明:文章内容来源于网络,版权归原作者所有,如有侵权请点击这里与我们联系,我们将及时删除。

点击添加站长微信