原文地址:
http://www.flipcode.com/articles/scripting_issue03.shtml
作者:Jan Niestadt
译者:Tony Qu
介绍 第二部分的程序运行得很好,它把程序转换为符号(token),所有的关键字、操作符、标点符号、标识符和常数都马上被识别和记录下来。当然,你可以键入
{ this ) = "pointless" + ;
程序会马上接受这段代码,并且生成一个符号列表,虽然以上的代码并不是我们所需要的,因为我也不知道这段代码究竟能干什么。接下来,我们必须让程序能够识别语法结构。
我们用解析器来实现这个功能,它可用来获得程序的结构并且检查语法错误。
一些语言理论
我们怎么才能告诉解析器我们的语言是怎么样的呢?我们可以用一种叫Backus-Naur表单(BNF)的方法来指定语法,这种定义方法通过使用程序组成
的基本概念来构建语法。例如,表达式可以出现在其他东西中,如标识符和字符串中。在BNF中,是这样写的:
expression: identifier | string;
声明可以是一个打印或输入声明
statement
: PRINT expression END_STMT
| INPUT identifier END_STMT
;
(记住 PRINT, INPUT和END_STMT是我们的词法分析器返回的符号)
现在,一个程序可以用一个声明列表来描述
program: | program statement;
以上声明的意思是程序可以是空的,也可以是包含一个声明程序。这是一个很好的递归声明定义。
所以,我们在BNF中定义的语言包含以下声明:
print a;
print "Hello";
input name;
以下是非法输入
input "Hello";
因为我们定义了输入声明,所以input之后只能有标识符,而不是字符串常量
随着BNF的使用,我们现在可以正式定义我们整个语言的语法了,当然这还不包含文法,因此以下的声明:
a = (b == c);
它将被解析器接收,但它没有任何意义,因为我们试图把布尔值赋给一个字符串。文法将在下一步检测。
好了,现在我们知道了足够的语言规范来创建我们的解析器!
看上去很相似 解析器也是用一个叫Yacc的外部程序生成的,Yacc是一个标准的Linux工具,就像Lex一样。我们将使用一个改进的叫作Bison的版本。Bison的用户手册可以在
这里找到。
事实上,Yacc文件(扩展名为.y)的布局和与Lex文件很像。
<definitions>
%%
<rules>
%%
<user_code>
文
件段中包含符号(token)定义,类型信息和yylval
union的定义,就像我们在前一篇文章中看到的那样,这是为什么我们使用union,Yacc使用相同的union在不同的“语言概念”,如表达式、声
明和程序之间传递信息。从这些定义,Yacc会生成一个lexsymb.h文件(事实上,它会创建一个parse.cpp.h,但是parse.bat过
程会把文件重命名 。)
文件段中也会包含一些初始化代码,它们位于标志%{和}%之间,还是和Lex文件很相似,但这段不是在本部分使用的,但允许你包含任何你需要的其他代码。
rules段是根据BNF来指定的,这在上一篇文章中已有相关解释,这里就不再多提了。
在Yacc中有一个问题,那就是你的语言规范必须是LR(1)文法的,LR(1)文法的含义在“龙书”中有相关的解释(见章节4.5关于自低向上的解
析),但是解析器必须能够通过查看当前解析器的符号判断语法规则来,但只允许向后查看一个符号之后。下面的规则将产生一个转换/减少冲突
(shift/reduce conflict) :
A:
| B C
| B C D
| D E F
;
当
从输入文件读到B时,继续向后查看一个C,有两个这样的组合,我们可以把它归为一组(最终,这两种替代表达式组合都会被归为A),此时该冲突不会发生。问
题是第二个替代表达式以D结尾,而第三个是以D开始的,当解析器读到C时,它将无法判断是把输入归类为A2还是A1后面跟着A3,因此虽然完整的语法定义
不会引起无法分析的情况,但由于解析器只能向后查看一个符号,所以会出现问题。Yacc把这种冲突叫作半模糊转换/减少冲突(shift/reduce)
或减少/减少(reduce/reduce)冲突。
好了,我不想让这些吓到你,让我们来看看规则,声明规则是最重要的:
statement
: END_STMT {puts ("Empty statement");}
| expression END_STMT {puts ("Expression statement");}
| PRINT expression END_STMT {puts ("Print statement");}
| INPUT identifier END_STMT {puts ("Input statement");}
| if_statement {puts ("If statement");}
| compound_statement {puts ("Compound statement");}
| error END_STMT {puts ("Error statement");}
正
如你看到的,这里定义了我们的语言中所有的声明类型,紧跟在声明后面的代码是用来告诉解析器当找到匹配的表达式时该做什么。在我看来,这种规则很直观,有
一点要注意,错误声明是用来告诉Yacc当遇到一个解析错误时该怎么做(比如遇到一个非法的符号或者不匹配的符号)。在这种情况下,它会寻找下一个
END_STMT符号,并且继续解析下去。解析错误总是被报告给在main.cpp中定义的yyerror()函数,这样我们的编译器就可以用合适的方式
处理错误。如果你没有在.y文件中定义一个错误规则,你的解析器会在遇到错误时停下来,这似乎并不是很好。
或许你想知道为什么会有这么多不同的表达式规则:表达式、相等表达式(equal_expression)、赋值表达式
(assign_expression)、截取表达式(concat_expression)和简单表达式,这是为了指定操作符的优先级。如果解析器看到
以下声明
if (a == b + c)
它就应该知道这时不应该计算a==b,而是让字符串c加上一个布尔值,不同的表达式规则保证了只有唯一的方法可以解析该表达式,只要你多看它几次,它就会工作。
另一个问题解析以下表达式:
if (a == b) if (c == d) e = f; else g = h;
解析器不知道else属于哪个if声明,它可能认为你的意思是
if (a == b) {if (c == d) e = f;} else g = h;
但在所有的语言转换中规定把else和最近的if进行组合
因为你无法通过改变规则解决这个问题,Yacc将会报告一个转换/减少冲突,Yacc通过向定义部分增加一行信息解决这个冲突
%expect 1
意思是Yacc会期待一个冲突。Yacc是通过把else和最近的if关联起来解决这个冲突的,就像我们需要的那样。
只要你理解BNF,Yacc文件的其它部分都是可自解释,如果有任何不清楚的部分,你可以发邮件给我或者在留言板上留下你的问题。
Yacc文件可以用一下命名进行编译
bison --defines --verbose -o parse.cpp
如果你在编译过程中,发现有任何冲突,可以通过查看parse.cpp.out文件了解详细的冲突内容。(即使你没有看到任何错误,查看这个文件也是很有意义的事)如果你无法解决冲突,请把你的.y文件发给我,我会帮你看看的。
如果一切顺利的话(就像样例代码做的那样),在parse.cpp中会有一个可用的词法分析器。我们的新程序唯一要做的就是调用yyparse()函数,整个的输入文件会被解析。
再试一次example.str,看看它是如何处理错误的。有错误吗?是的,我在第十三行漏掉了一个分号,Yeah!它工作得很好。
欢呼
我们今天做了很多事——我们学习了正规语言理论;如何在yacc中使用这些理论;为什么Yacc对于它支持的语法如此挑剔;以及如何处理操作符。最终,我们完成了一个解析器。
好了,最艰难的时刻已经过去,如果你理解的话,接下来的事都是小菜一碟。然而,如果你对我所说的LR(1)文法感到迷茫的话,可以留言或者发邮件给我,我
会设法帮你解释清楚。当然,我也欢迎其它的问题和评论,只要我知道你们确实在读本文就行。
未来有什么在等待着我们?下一次,我们将写两个新的组件:符号表和文法树。到那时,你有一周的时间来体验代码。提示:尝试让你的编译器接受类C的while声明。