从开始学计算理论,就对形式语言,编译原理很感兴趣,所以大学对这门课学的也算是最好了。自己也实现过一些简单的词法分析器之类的东西,不过也都是学习目的的,质量一般 后来一直在Linux学习,对lex/yacc研究过一点时间,设计过一个小的脚本引擎,可以做一些比较复杂的数学运算,这个可以参考我的这篇博客<hoc>。工作以后,平台 变成了java,跟C渐渐的离得远了,也喜欢上java这个提供语言级别的面向对象的语言以前的一些东西用顺手了,一时习惯还改不过来,于是就开始找lex/yacc的替代品。
研究过一段时间的antlr,觉得还行,特别是那个GUI做的非常好,但是跟lex/yacc在用法上还是有些差别的,最近才遇到了javacc,觉得跟lex/yacc的写法相当像,就试着 写了一个简单的桌面计算器(desktop-calculator),大家可以参考一下。 (从下载到javacc.zip到写出这个计算器,大约花了我一个下午的时间,所以你可以很快 的学会javacc,并在实际中应用。)用这类工具的好处是,只要你理解形式语言,那么就可以很方便的做出一个分析起来,而不用关心其实现细节(如词法分析等部分是十分
枯燥且无意义的)
一来这是一个比较经典的例子,通过它的学习,可以迅速掌握一个编译器生成器,或者叫语言解析器生成器。二来最近项目中用到了一些类似的东西,正好趁此机会记下来 以便将来参考。
四则运算的计算器的规则非常清晰,可以看一下这个巴克斯范式(BNF)
expr ::= term ((+|-) term)* term ::= factor ((*|/) factor)* factor ::= number | expr number ::= [0-9]+...
形式语言的好处就在于其清晰,准确和强大。如果你对正则表达式比较收悉,那么可以很容易掌握形式语言。(正则表达式其实就是一个精简而强大的形式语言实例)
首先,我们需要定义在文法描述中需要用到的所有记号(token),这是分析器能认识到的最小单元
TOKEN:{ <ADD : "+"> | <SUB : "-"> | <MUL : "*"> | <DIV : "/"> | <LPAREN : "("> | <RPAREN : ")"> | <NUMBER : ["0"-"9"] (["0"-"9"])* | (["0"-"9"])+ "." (["0"-"9"])* (<EXPONENT>)? | "." (["0"-"9"])+ (<EXPONENT>)? | (["0"-"9"])+ <EXPONENT> > | <#EXPONENT: ["e","E"] (["+","-"])? (["0"-"9"])+ > }
javacc的记号描述很简单,就是普通的正则表达式,当然用引号引起来的简单单词也算是正则表达式,如记号ADD,就是"+",这样,当解析器遇到"+"时就返回一个ADD记号,方便在内部使用。
这里详细说一下NUMBER记号,数字怎么表示呢?
我们用自然语言可以描述成:以0到9中的任一个数字开头,后边可以有任意多位(包括0位)数字(此为整数),或者以至少一位数字,后边跟一个小数点,然后又有任意多位的数字,如果使用科学 计数法,则这个数字串后边还可以跟一个E,E后边又可以有正号(+)或者负号(-),也可以没有,然后,后边又是至少一位数字,或者……
可以看到,自然语言的描述冗长且不容易理解,我们看看形式语言的描述:首先定义一些符号的意义,如
"|" | 表示或者 |
0-9 | 表示0到9的一个数字 |
[] | 表示一个区间 |
"?" | 表示其前的区间中的元素重复一次或零次 |
"+" | 表示其前的区间中的元素重复至少一次 |
"*" | 表示其前的区间中的元素重复零次或多次 |
() | 表示一个分组,是一个整 |
好了,有了这些定义,我们再看看如何用形式语言描述一个浮点数或者整数:
number ::= [0-9]([0-9])* | [0-9]+ '.' ([0-9])+ exponent? | '.' ([0-9])+ exponent? | ([0-9])+ exponent? exponent ::= [e,E]([+,-])?([0-9])+
可以看到,形式语言在描述这种抽象概念上比自然语言要好得多。一旦掌握了形式语言就可以很容易的理解各种复杂的规则。
词法分析器的一个功能就是剔除源码中的空白字符,比如,空格,制表符,回车换行等
SKIP:{ " " | "\t" | "\r" | "\n" }
有了BNF,我们看看在javacc中,如何表现这些规则:
void expr():{} { term()((<ADD>|<SUB>)term())* } void term():{} { factor()((<MUL>|<DIV>)factor())* } void factor():{} { <NUMBER>| <LPAREN>expr()<RPAREN> }
javacc基本忠实的体现了BNF的规则定义,当然,现在这个解析器的文法部分(词法分析和语法分析) 已经算是结束了,但是它还不能完成任何计算,因为它没有语义的定义部分,即当发现了 此语法现象后该做什么是没有定义的。
相信大家都已经注意到,每个非终结符后边都有两个大括号{},第一个目前为空。在javacc中,每个非终结符都最终会被翻译成一个方法,(至于怎么翻译的,你可以自己看看它生成的代 码,当年我曾痛苦在yacc生成的一堆yy_*中徜徉过一段时间,现在是实在看不下去了,javacc生成的代码中到处都是jj_*,唉,一个yy,一个jj,生成的代码是看不成了), 第一个空的大括号中即为将来你自己填写的一些关于这个方法的一些临时变量,包括返回值等信息。
好了,我们看看加入了语义解释后的代码:
double expr(): { double temp = 0; double first, second; } { //你可以在非终结符前插入变量,等号等,在其后可以插入普通的java代码 //插入代码后看起来可能不够清晰,可以参看上边的形式定义 first=term(){temp = first;} (<ADD>second=term(){temp = first + second;}| <SUB>second=term(){temp = first - second;})* {return temp;}//最后,应当返回某值 } double term(): { double temp = 0; double first, second; } { first=factor(){temp = first;} (<MUL>second=factor(){temp = first * second;}| <DIV>second=factor(){temp = first / second;})* {return temp;} } double factor(): { double temp = 0; Token token; } { token=<NUMBER>{ return Double.parseDouble(token.image); } | <LPAREN> temp=expr() <RPAREN>{ return temp; } }
好了,主体部分已经建立好了,我们再来看看声明信息等(形式语言的学习是重点,其余的 都比较简单易学,而且不同的cc都提供大同小异的功能)
PARSER_BEGIN(CalcParser) import java.io.StringReader; import java.io.Reader; public class CalcParser{ public CalcParser(String expr){ this((Reader)(new StringReader(expr))); } public static void main(String[] args){ try{ CalcParser parser = new CalcParser(args[0]); System.out.println(parser.expr()); }catch(Exception e){ System.out.println("error : "+e.getMessage()); } } } PARSER_END(CalcParser)
每个分析器需要一个名字,这个名字定义在PARSER_BEGIN(xxx)中,而且应保证与下面的类声明保持一致:
public class xxx{}
现在,我们可以用javacc提供的命令行程序来生成我们的计算器,需要注意的是,javacc生成的是java源码,且不再依赖于javacc,你可以将你的分析器源码放在任何地方使用。
$ javacc CalcParser.jj(你看看这文件名后缀)
可以看到有类似的输出;
你现在可以试着输入一些表达式让计算器进行计算了。(事例结果见后)
如果想要加入一些别的功能,比如,计算表达式的正弦,余弦函数?很简单,我们可以使用 java.lang.Math中提供的一些数学函数。
对规则稍事修改,即可完成我们的需求。当然,这个计算器的还是比较简单的,比如,不能回溯(这个以后再说),不支持负数,不支持幂计算。但是,如果通过此文,你对形式语言有了比较 好的理解的话,这些问题都是很容易解决的。
下面这些代码是我再上边的这个四则运算计算器的基础上加入了少量规则而成的一个微型函数计算器,其表达式格式类似于JSP中的EL表达式,你可以对其进行扩展,从而使之更加有趣。
PARSER_BEGIN(CalcParser) import java.io.StringReader; import java.io.Reader; public class CalcParser{ public CalcParser(String expr){ this((Reader)(new StringReader(expr))); } public static void main(String[] args){ try{ CalcParser parser = new CalcParser(args[0]); System.out.println(parser.elexpr()); }catch(Exception e){ System.out.println("error : "+e.getMessage()); } } } PARSER_END(CalcParser) //声明到此结束 SKIP:{ " " | "\t" | "\r" | "\n" } TOKEN:{ <ADD : "+"> | <SUB : "-"> | <MUL : "*"> | <DIV : "/"> | <MOD : "%"> | <LPAREN : "("> | <RPAREN : ")"> | <NUMBER : ["0"-"9"] (["0"-"9"])* | (["0"-"9"])+ "." (["0"-"9"])* (<EXPONENT>)? | "." (["0"-"9"])+ (<EXPONENT>)? | (["0"-"9"])+ <EXPONENT> > | <#EXPONENT: ["e","E"] (["+","-"])? (["0"-"9"])+ > | <EXPRPREFIX: "${"> | <EXPRSUFFIX: "}"> | <SIN: "sin"> | <COS: "cos"> } //记号部分声明到此结束,下面是语法声明,包括语义解释 double elexpr(): { double temp = 0; } { <EXPRPREFIX>temp=expr()<EXPRSUFFIX> {return temp;} } double expr(): { double temp = 0; double first, second; } { first=term(){temp = first;} (<ADD>second=term(){temp = first + second;}| <SUB>second=term(){temp = first - second;})* {return temp;} } double term(): { double temp = 0; double first, second; } { first=factor(){temp = first;} (<MUL>second=factor(){temp = first * second;}| <DIV>second=factor(){temp = first / second;})* {return temp;} } double factor(): { double temp = 0; Token token; } { token=<NUMBER>{ return Double.parseDouble(token.image); } | <LPAREN> temp=expr() <RPAREN>{ return temp; } | <SIN><LPAREN>temp=expr()<RPAREN>{ return java.lang.Math.sin(temp); } | <COS><LPAREN>temp=expr()<RPAREN>{ return java.lang.Math.sin(temp); } //如果有兴趣,可以加入更多的java.lang.Math中的数学函数, //当然,你也可以加入自己实现的一些方法,如返回前一个运算结果 //记录历史信息等等。 }
演示计算过程,如:
关于JJTree后边再说吧,最重要的还是上边提到的形式语言,它是一切的基础。