2.2 试做一个计算器

向不了解yacc/lex的人介绍其功能的话,与其一上来就举“用yacc/lex制作编程语言”的例子,不如先从一些简单的例子讲起比较好。所以我们以一个简单的“计算器”为例做介绍。

将计算器作为yacc/lex的示例实在有些老套,但是又很实用。Windows都会预装一个带有图形界面的计算器软件,仔细想来,PC上明明有好用的键盘,却还要用鼠标去一个一个点计算器上的按钮未免有些傻 [4]。正因为Windows的计算器不好用,所以有很多人会选择使用普通的计算器。可明明眼前就摆着高性能的电脑,偏要用买来的计算器,同样也显得很奇怪,更不要说还有“附带计算器功能的鼠标垫”这种创意产品,就更加本末倒置了。无论是Windows自带的计算器,还是从日杂店买来的计算器,都从上面看不到运算符号的优先顺序,也无法直接计算带括号的式子(因为看不到前一个输入的值)。几十个数值求和时,你会不会担心万一中间输错了该怎么办?我经常会。

而我们要制作的计算器,会通过命令行方式启动,可以通过键盘输入整个算式,然后直接显示计算结果。因此可以直观地看到运算的优先顺序,比如输入

1 + 2 * 3

会得到结果7而不是9。因为能看到整个算式,所以还可以很容易地检查有没有输错。

计算器的指令名为 mycalc 。一个实际运行的例子是这样的(% 是命令提示符):

% mycalc ←启动mycalc

1+2  ←输入算式

>>3.000000 ←显示结果

1+2*3 ←乘法与加法的混合运算

>>7.000000 ←按运算优先顺序输出结果

虽然只用了整数,却输出“3.000000”这样的结果,这是因为 mycalc在内部完全使用 double进行运算。

2.2.1 lex

lex是自动生成词法分析器的工具,通过输入扩展名为 .l的文件,输出词法分析器的C语言代码。而flex则是增强版的lex,而且是免费的。

词法分析器是将输入的字符串分割为记号的程序,因此必须首先定义 mycalc所用到的记号。

mycalc所用到的记号包括下列项目:

运算符。在mycalc中可以使用四则运算,即 +、 -、 *、 /。

整数。如 123等。

实数。如 123.456等。

换行符。一个算式输入后,接着输入换行符就会执行计算,因此这里的换行符也应当设置为记号。

在lex中,使用 正则表达式 定义记号。

为 mycalc所编写的输入文件mycalc.l如代码清单2-1所示。

代码清单2-1 mycalc.l

1: %{

2: #include <stdio.h>

3: #include "y.tab.h"

4:

5: int

6: yywrap(void)

7: {

8: return 1;

9: }

10: %}

11: %%

12: "+"   return ADD;

13: "-"   return SUB;

14: "*"   return MUL;

15: "/"   return DIV;

16: "\n"   return CR;

17: ([1-9][0-9]*)|0|([0-9]+.[0-9]+) {

18: double temp;

19: sscanf(yytext, "%lf", &temp);

20: yylval.double_value = temp;

21: return DOUBLE_LITERAL;

22: }

23: [ \t] ;

24: . {

25: fprintf(stderr, "lexical error.\n");

26: exit(1);

27: }

28: %%

代码第11行为 %%,此行之前的部分叫作 定义区块 。在定义区块内,可以定义初始状态或者为正则表达式命名(即使不知道正则表达式的具体内容也可以命名)等。在本例中放置了一些C代码。

第2行至第9行,使用%{和%} 包裹的部分,是想让生成的词法分析器将这部分代码原样输出。后续程序所需的头文件 #include 等都包含在这里,比如第三行用 #include包含进来的y.tab.h头文件,就是之后yacc自动生成的头文件。下面的 ADD、 SUB、 MUL、 DIV、 CR、 DOUBLE_LITERAL等都是在y.tab.h中用 #define 定义的宏,其原始出处则定义于mycalc.y 文件中(详见2.2.3 节)。这些宏将记号的种类区分开来。顺便附上表2-1说明记号的具体含义。

表2-1 记号及其含义

figure_0040_0004

第5行到第9行定义了一个名为 yywrap() 的函数。如果没有这个函数的话,就必须手动链接lex的库文件,在不同环境下编译时比较麻烦,因此最好写上。本书不会再对这个函数做深入说明,简单知道其作用,直接使用就可以了。

第12行到27行是 规则区块 。读了代码就能明白,这一部分是使用正则表达式 [5]去描述记号。

在规则区块中遵循这样的书写方式:一个正则表达式的后面紧跟若干个空格,后接C代码。如果输入的字符串匹配正则表达式,则执行后面的C代码。这里的C代码部分称为动作(action)。

在第12~16行中,会找到四则运算符以及换行符,然后通过 return返回其特征符。所谓特征符,就是上文所述在y.tab.h中用 #define定义、用来区别记号种类的代号。

之前提到了这么多次“记号”,其实我们所说的记号是一个总称,包含三部分含义,分别是:

1. 记号的种类

比如说计算器中的 123.456 这个记号,这个记号的种类是一个实数(DOUBLE_LITERAL)。

2. 记号的原始字符

一个记号会包含输入的原始字符,比如 123.456 这个记号的原始字符就是 123.456。

3. 记号的值

123.456这个记号代表的是实数123.456的值的意思。

对于 + 或 - 这样的记号来说,只需要关注其记号种类就可以了,而如果是 DOUBLE_LITERAL记号,记号的种类与记号的值都必须传递给解析器。

第17行的正则表达式,是一个匹配“数值”用的正则表达式。表达式匹配成功的结果,即上面列举的记号三要素中,“记号的原始字符”会在相应动作中被名为 yytext的全局变量(这算是lex的一个丑的设计)引用,并进一步使用第19行的 sscanf()进行解析。

动作解析出的值会存放在名为 yylval的全局变量中(又丑一次)。这个全局变量 yylval本质上是一个联合体(union),可以存放各种类型记号的值(在这个计算器程序中只有 double类型)。联合体的定义部分写在yacc的定义文件mycalc.y中。

到第28行,又出现了一次 %%。这表示规则区块的结束,这之后的代码则被称为 用户代码区块 。在用户代码区块中可以编写任意的C代码(例子中没有写)。与定义区块不同,用户代码区块无需使用 %{ %}包裹。

2.2.2 简单正则表达式讲座

lex通过正则表达式定义记号。正则表达式在Perl、Ruby语言中广泛使用,而UNIX用户也经常会在编辑器或 grep命令中接触到。本书的读者可能未必都有这样的技术背景,所以我们在本书涉及的范围内,对正则表达式做一些简单的说明。

在代码清单2-1的第17行中有这样一个正则表达式(初看可能稍微有点复杂):

([1-9][0-9]*)|0|([0-9]+.[0-9]+)

首先, [与 ]表示匹配此范围内的任意字符。而 [ ]还支持使用连接符的缩写方式。比如写 1-9与写 123456789是完全一样的。

最初圆括号中的 [1-9]代表匹配1~9中任意一个数字。其后的 [0-9]代表匹配0~9的任意一个数字。

在此之后的*,代表匹配前面的字符 [6]0次或多次。

因此, [1-9][0-9]*这个正则表达式,整体代表以 1~9 开头(只有 1位),后接 0个以上的 0~9 的字符。而这正是我们在 mycalc 中对整数所做的定义。

在表2-2中列举了若干字符串,并展示其是否匹配我们所制定的整数规则。

表2-2 字符串的匹配

figure_0042_0005

如上表所示, mycalc不会将 012这样的输入作为数值接收,这完全符合我们的预期。

但是将0也排除的话还是有问题的,程序必须能接受所有的实数。因此在正则表达式中又使用了 |。 |代表“或”的意思。

第17行的正则表达式就被修正为:

([1-9][0-9]*)|0|([0-9]+.[0-9]+)

现在应该可以明白前半段正则表达式的整体意思了,即将整数(0以外)的正则表达式与0通过 |分成两部分然后并列(加上圆括号 ()是将这部分作为一个集合才能通过 |与0并列)。

后半部分的 [0-9]+.[0-9]+ 中用到了 +。 * 是代表“匹配前面的字符0次或多次”,而 + 则是“匹配前面的字符1 次或多次”,因此这部分整体代表“0~9的数字至少出现一次,后接小数点 .后又接至少一位0~9数字”。这些与前面整合起来,共同构成了 mycalc对于实数的定义。

小数点的书写不是只写一个 .而是写成了 .,因为 .在正则表达式中有特殊的含义(后文即将介绍),所以需要使用 \转义。 [ ]、 *、 +、 .等这些在正则表达式中有特殊含义的字符称为 元字符 ,元字符可以像上文那样用 \或双引号 [8] 进行转义。代码的第12~14行,就是使用双引号转义的方法对乘法和加法的运算符进行了定义。

第23行的正则表达式 [ \t] 是对空格以及制表符进行匹配,对应动作为空,因此可以忽略每一行的空白字符。

第24行的 .会匹配任意一个字符。这里用于检测是否输入了程序不允许的字符。

首先,lex将输入的字符串分割到若干个记号中时,会尽可能选择较长的匹配。比如C语言中同时有 +运算符和 ++运算符,那么当输入 ++时,lex不会匹配为两个 +运算符,而是返回一个 ++(如果不按这个逻辑,程序很难正常工作)。如果两个规则出现同样长度的匹配时,会优先匹配前一个规则。也就是说,如果输入字符直到最后一条规则(匹配任意字符)才匹配成功的话,说明这个字符不符合前面所有的规则,是错误的输入。

在表2-3中列举了一些常用的元字符。元字符以外的字符直接书写就可以了。

表2-3 lex中正则表达式的元字符

figure_0043_0006

2.2.3 yacc

yacc是自动生成语法分析器的工具,输入扩展名为 .y的文件,就会输出语法分析器的C语言代码。bison则是GNU项目所发布的yacc的功能扩充版。

mycalc中yacc的输入文件mycalc.y如代码清单2-2所示。

代码清单2-2 mycalc.y

1: %{

2: #include <stdio.h>

3: #include <stdlib.h>

4: #define YYDEBUG 1

5: %}

6: %union {

7: int   int_value;

8: double  double_value;

9: }

10: %token <double_value> DOUBLE_LITERAL

11: %token ADD SUB MUL DIV CR

12: %type <double_value> expression term primary_expression

13: %%

14: line_list

15: : line

16: | line_list line

17: ;

18: line

19: : expression CR

20: {

21:  printf(">>%lf\n", $1);

22: }

23: expression

24: : term

25: | expression ADD term

26: {

27:  $$ = $1 + $3;

28: }

29: | expression SUB term

30: {

31:  $$ = $1 - $3;

32: }

33: ;

34: term

35: : primary_expression

36: | term MUL primary_expression

37: {

38:  $$ = $1 * $3;

39: }

40: | term DIV primary_expression

41: {

42:  $$ = $1 / $3;

43: }

44: ;

45: primary_expression

46: : DOUBLE_LITERAL

47: ;

48: %%

49: int

50: yyerror(char const *str)

51: {

52: extern char *yytext;

53: fprintf(stderr, "parser error near %s\n", yytext);

54: return 0;

55: }

56:

57: int main(void)

58: {

59: extern int yyparse(void);

60: extern FILE *yyin;

61:

62: yyin = stdin;

63: if (yyparse()) {

64:  fprintf(stderr, "Error ! Error ! Error !\n");

65:  exit(1);

66: }

67: }

第1~5行与lex相同,使用 %{%}包裹了一些C代码。

第4行有一句 #define YYDEBUG 1,这样将全局变量 yydebug设置为一个非零值后会开启Debug模式,可以看到程序运行中语法分析的状态。我们现在还不必关心这个。

第6~9行声明了记号以及 非终结符 的类型。正如前文所写,记号不仅需要包含类型,还需要包含值。记号可能会有很多类型,这些类型都声明在集合中。本例中为了方便说明,定义了一个 int类型的 int_value和 double类型的 double_value,不过目前还没有用到 int_value。

非终结符是由多个记号共同构成的,即代码中的 line_list、 line、expression、 term这些部分。为了分割非终结符,非终结符最后都会以一个特殊记号结尾。这种记号称作 终结符 。

第10~11行是记号的声明。 mycalc 所用到的记号类型都在这里定义。 ADD、 SUB、 MUL、 DIV、 CR 等记号只需要包含记号的类型就可以了,而值为 DOUBLE_LITERAL 的记号,其类型被指定为 <double_value>。这里的 double_value是来自上面代码中 %union集合的一个成员名。

第12行声明了非终结符的类型。

与lex一样,13行的 %%为分界,之后是 规则区块 。yacc的规则区块,由 语法规则 以及C语言编写的相应动作两部分构成。

在yacc中,会使用类似BNF(巴科斯范式,Backus Normal Form)的规范来编写语法规则。

计算器程序因为规则部分中混杂了动作,阅读起来有点难度,所以在代码清单2-3中,仅仅将规则部分抽出,并加入了注释。

代码清单2-3 计算器的语法规则

1: line_list / 多行的规则 /

2: : line / 单行 /

3: | line_list line / 或者是一个多行后接单行 /

4: ;

5: line / 单行的规则 /

6: : expression CR / 一个表达式后接换行符 /

7: ;

8: expression / 表达式的规则 /

9: : term / 和项 /

10: | expression ADD term / 或 表达式 + 和项 /

11: | expression SUB term / 或 表达式 - 和项 /

12: ;

13: term / 和项的规则 /

14: : primary_expression / 一元表达式 /

15: | term MUL primary_expression /或和项 一元表达式 */

16: | term DIV primary_expression /或和项 /一元表达式 /

17: ;

18: primary_expression / 一元表达式的规则 /

19: : DOUBLE_LITERAL / 实数的字面常量 /

20: ;

为了看得更清楚,可以将语法规则简化为下面的格式:

A

: B C

| D

;

即A的定义是B与C的组合,或者为D。

第1~4行的书写方式,是为了表示该语法规则在程序中可能会出现一次以上。在 mycalc中,输入一行语句然后敲回车键后就会执行运算,之后还可以继续输入语句,所以需要设计成支持出现一次以上的模式。

另外,请注意在上面的计算器的语法规则中,语法规则本身就包含了运算符的优先顺序以及结合规律。如果不考虑运算符的优先顺序(乘法应该比加法优先执行),上文的语法规则应该写成这样。

expression / 表达式的规则 /

: primary_expression / 一元表达式 /

| expression ADD expression / 或 表达式 + 表达式 /

| expression SUB expression / 或 表达式 - 表达式 /

| expression MUL expression / 或 表达式 表达式 */

| expression DIV expression / 或 表达式 / 表达式 /

;

primary_expression / 一元表达式的规则 /

: DOUBLE_LITERAL / 实数的字面常量 /

;

那么在这样的语法规则下,yacc是如何运作的呢?我们以代码清单2-3为例一起来看看吧。

大体上可以这样说,yacc所做的工作,可以想象成一个类似“俄罗斯方块”的过程。

首先,yacc生成的解析器会保存在程序内部的栈,在这个栈中,记号就会像俄罗斯方块中的方块一样,一个个堆积起来。

比如输入1 + 2 * 3,词法分析器分割出来的记号(最初是 1)会由右边进入栈并堆积到左边。

figure_0047_0007

像这样一个记号进入并堆积的过程,叫作移进(shift)。

mycalc 所有的计算都是采用 double 类型,所以记号 1 即是 DOUBLE_LITERAL。当记号进入的同时,会触发我们定义的规则:

primary_expression

: DOUBLE_LITERAL

然后记号会被换成 primary_expression。

figure_0048_0008

类似这样触发某个规则并进行置换的过程,叫作 归约 (reduce)。primary_expression将进一步触发规则:

term

: primary_expression

然后归约为 term。

figure_0048_0009

再进一步根据规则:

expression

: term

最终被归约为一个 expression。

figure_0048_0010

接下来,记号 +进入。在进入过程中,由于没有匹配到任何一个规则,所以只好老老实实地进行移进而不做任何归约。

figure_0048_0011

接下来是记号 2进入。

figure_0048_0012

经过上述同样的规则,记号 2(DOUBLE_LITERAL)会经过 primary_expression被归约为 term。

figure_0049_0013

这里记号 2本应该匹配到如下的规则:

expression

| expression ADD term

yacc和俄罗斯方块一样,可以预先读取下一个要进入的记号,这里我们就可以知道下一个进入的会是 *,因此应当考虑到记号 2会匹配到 term规则的可能性。

term

| term MUL primary_expression

归约完毕后再一次移进。

figure_0049_0014

接下来记号 3进入,

figure_0049_0015

被归约为 primary_expression后,

figure_0049_0016

term、 *、 primary_expression这一部分将匹配规则:

term

| term MUL primary_expression

被归约为 term。

figure_0049_0017

之后, expression、 +、 term又会匹配规则

expression

| expression ADD term

最终被归约为 expression。

figure_0050_0018

每次触发归约时,yacc都会运行该规则的相应动作。比如乘法对应执行的规则如下文所示。

34: term

36: | term MUL primary_expression

37: {

38:  $$ = $1 * $3;

39: }

动作是使用C语言书写的,但与普通的C语言又略有不同,掺杂了一些 $$、 $1、 $3之类的表达式。

这些表达式中, $1、 $3的意思是分别保存了 term与 primary_expression的值。即yacc输出解析器的代码时,栈中相应位置的元素将会转换为一个能表述元素特征的数组引用。由于这里的 $2是乘法运算符(*),并不存在记号值,因此这里引用 $2的话就会报错。

$1与 $3进行乘法运算,然后将其结果赋给 $$,这个结果值将保留在栈中。在这个例子中,执行的计算为 2 * 3,所以其结果值 6会保留在栈中(如图2-3)。

figure_0050_0019

图2-3 归约发生时栈的动作

咦, $1与 $3对应的应该是 term和 primary_expression,而不是2与3这样的 DOUBLE_LITERAL数值才对呀,为什么会作为2*3来计算呢?

可能会有人提出上面的疑问吧。这是因为如果没有书写动作,yacc会自动补全一个 { $$ = $1 } 的动作。当 DOUBLE_LITERAL 被归约为 primary_expression、 primary_expression 被 归 约 为 term 的 时 候, DOUBLE_LITERAL包含的数值也会被继承。

34: term

35: : primary_expression

{

$$ = $1; / 自动补全的动作 /

}

45: primary_expression

46: : DOUBLE_LITERAL

{

$$ = $1; / 自动补全的动作 /

}

$$与 $1的数据类型,分别与其对应的记号或者非终结符的类型一致。比如, DOUBLE_LITERAL对应的记号被定义为:

9: %token <double_value>  DOUBLE_LITERAL

expression、 term、 primary_expression的类型则为:

11: %type <double_value> expression term primary_expression

这里的类型被指定为 <double_value>,其实是使用了在 %union部分声明的联合体中的 double_value成员。

由于我们以计算器为例,计算器的动作会继续计算得出的值,但仅靠这些还不足以制作编程语言。因为编程语言中都会包含简单的循环,而语法分析只会运行一次,所以动作还要支持循环处理同一处代码才行。

因此在实际的编程语言中,会从动作中构建分析树。这部分处理的方法会在后面的章节中介绍。

2.2.4 生成执行文件

接下来,让我们实际编译并链接计算器的源代码,生成执行文件吧。

在标准的UNIX中,按顺序执行下面的指令(%是命令行提示符),就会输出名为mycalc的执行文件。

% yacc -dv mycalc.y ←运行yacc

% lex mycalc.l ←运行lex

% cc -o mycalc y.tab.c lex.yy.c ←使用C编译器编译

如果在Windows的环境下,参考1.6.1节中的说明,需要安装gcc、bison、flex,然后运行下面的指令(C:\Test>是命令行提示符)。

C:\Test>bison —yacc -dv mycalc.y ←用bison代替yacc并运行

C:\Test>flex mycalc.l   ←运行flex

C:\Test>gcc -o mycalc y.tab.c lex.yy.c ←使用C编译器编译

这个过程中会生成若干文件。其流程以图片表示的话,如图2-4所示。

figure_0052_0020

图2-4 yacc/lex的编译

y.tab.c中包含yacc生成的语法分析器的代码,lex.yy.c是词法分析器的代码。为了将mycalc.y中定义的记号及联合体传递给lex.yy.c,yacc会生成y.tab.h这个头文件。

此外,作为C语言程序当然要有 main()函数,在mycalc中 main()位于mycalc.y的用户代码区块(第49行以后),最终编译器会负责合并代码,所以这里的 main()与其他.c文件分离也不要紧。在 main()函数中的全局变量 yyin可以设定输入文件,调用 yyparse()函数。

由于我们使用bison替代了yacc,默认生成的文件就不是y.tab.c和y.tab.h,而是mycalc.tab.c和mycalc.tab.h。所以在上例中添加了—yacc参数,可以让bison生成与yacc同名的文件。本书为了统一,bison会始终带上 —yacc参数。

2.2.5 理解冲突所代表的含义

实际用yacc试做一下解析器,可能会被 冲突  (conflict)问题困扰。所谓冲突,就是遇到语法中模糊不清的地方时,yacc报出的错误。

比如C语言的if语句,就很明显有语法模糊问题。

if (a == 0)

if (b == 0)

printf(在这里a与b都为0\n);

else

printf(这里是a非0?错!\n);

上面的代码中,我们不清楚最后的 else 对应的究竟是哪一个 if,这就是冲突。

yacc运行时,遇到下面任意一种情况都会发生冲突。

同时可以进行多个归约。

满足移进的规则,同时又满足归约的规则。

前者称为归约/归约(reduce/reduce)冲突,后者称为移进/归约(shift/reduce)冲突。

即便发生冲突,yacc仍然会生成解析器。如果存在归约/归约冲突,则优先匹配前面的语法规则,移进/归约冲突会优先匹配移进规则。很多书会写归约/归约冲突是致命错误,而移进/归约冲突则允许,这两者的确是存在严重程度的差别,但是在我来看,无论发生哪一种冲突都是难以容忍的,我恨不得消灭代码中所有的冲突问题。

yacc运行时可以附带 -v参数,标准yacc会生成y.output文件(bison则会将输入文件名中的扩展名.y替换为.output并生成)。

这个y.output文件会包含所有的语法规则、解析器、所有可能的分支状态以及编译器启动信息。

那么我们实际做出一个冲突,然后观察一下y.output文件吧。

将代码清单2-2中的语法规则

23: expression

24: : term

25: | expression ADD term ←将这里的ADD

更改为下文所示:

23: expression

24: : term

25: | expression MUL term ←更改为MUL

变更后会产生3个移进/归约冲突。

% yacc -dv mycalc.y

conflicts: 3 shift/reduce

然后再看y.output文件。

根据yacc或bison的版本与语言设置,y.output的输出会有微妙的区别。这里以bison2.3英文模式为例(为了节约纸张将空行都去掉了)。日语环境下,“Grammar”会变成“文法”等,错误信息也都会显示为日语。

总体来说,y.output文件的前半部分看起来基本都会是下面这个样子(代码清单2-4)。

代码清单2-4 y.output(前半部分)

Terminals which are not used ←没有使用ADD的警告

ADD

State 5 conflicts: 1 shift/reduce ←冲突信息(见下文)

State 14 conflicts: 1 shift/reduce

State 15 conflicts: 1 shift/reduce

Grammar

0 $accept: line_list $end

1 line_list: line

2 | line_list line

3 line: expression CR

4 expression: term

5 | expression MUL term

6 |expression SUB term

7 term: primary_expression

8 | term MUL primary_expression

9 | term DIV primary_expression

10 primary_expression: DOUBLE_LITERAL

首先将 ADD改为 MUL后,开头会出现“ADD没有被使用”的警告。下面会有3行冲突信息,此处后文会细说。再后面的“Grammar”跟着的是mycalc.y中定义的语法规则。

mycalc.y中可以使用 |(竖线,表示或)书写下面这样的语法规则:

line_list : line

| line_list line

实际上与下面这种写法的语法规则是完全一样的。

line_list : line

line_list : line_list line

y.output文件中,会给每一行规则附上编号。

上面的规则0,是yacc自动附加的规则, $accept代表输入的内容, $end代表输入结束,目前还不需要特别在意这个问题。

y.output文件的后半部分会将解析器可能遇到的所有“状态”全部列举出来(代码清单2-5)。

代码清单2-5 y.output(后半部分)

state 0

0 $accept: . line_list $end

DOUBLE_LITERAL shift, and go to state 1

line_list   go to state 2

line    go to state 3

expression   go to state 4

term    go to state 5

primary_expression go to state 6

state 1

10 primary_expression: DOUBLE_LITERAL .

$default reduce using rule 10 (primary_expression)

state 2

0 $accept: line_list . $end

2 line_list: line_list . line

$end   shift, and go to state 7

DOUBLE_LITERAL shift, and go to state 1

line    go to state 8

expression   go to state 4

term    go to state 5

primary_expression go to state 6

state 3

1 line_list: line .

$default reduce using rule 1 (line_list)

state 4

3 line: expression . CR

5 expression: expression . MUL term

6   | expression . SUB term

SUB shift, and go to state 9

MUL shift, and go to state 10

CR shift, and go to state 11

下略

前文中有一个俄罗斯方块的比喻,读者通过那个比喻可能会这样理解:解析器在一个记号进入栈后,从栈的开头一个字符一个字符扫描,如果发现这个记号满足已有的某个规则,就去匹配该规则。但其实这样做会让编译器变得很慢。

现实中,yacc在生成解析器的阶段,就已经将解析器所能遇到的所有状态都列举出来,并做成了一个解析对照表(Parse Table),表中记录了“状态A下某种记号进入后会转换到状态B”这样的映射关系,y.output的后半部分就罗列了所有可能的映射关系。听说这有点像麻将中的听牌,不过因为我不玩麻将,所以也不是很清楚。

有了这些列举出的状态,当记号进入后,就可以很容易找到在何种状态下移进,或者根据何种规则归约。那么对于之前我们故意做出的冲突,在我的环境下,y.output开头会输出如下信息:

State 5 conflicts: 1 shift/reduce

State 14 conflicts: 1 shift/reduce

State 15 conflicts: 1 shift/reduce

可以看到state 5引起了冲突,我们来看一下:

state 5

4 expression: term .

8 term: term . MUL primary_expression

9 | term . DIV primary_expression

MUL shift, and go to state 12

DIV shift, and go to state 13

MUL  [reduce using rule 4 (expression)]

$default reduce using rule 4 (expression)

上例中,第一行的 state 5即为状态的编号,1个空行后接下来的3行是y.output前半部分中输出的语法规则(语法规则还附加了编号)。语法规则中间有 .,代表记号在当前规则下能被转换到哪个程度。比如state 5这个状态下,记号最多被转换为 term,然后需要等待下一个记号进行归约。

再空一行,接下来记录的是当前状态下,下一个记号进入时将如何变化。具体来讲,这里当 MUL(*)记号进入后会进行移进并转换为state 12。如果进入的是 DIV(/),则同样进行移进并转移到state 13。

再经过1个空行后下一行是:

MUL  [reduce using rule 4 (expression)]

意思是当 MUL 进入后,可以按照规则 4 进行归约。这也就是移进 / 归约冲突。

yacc 默认移进优先,所以 MUL 进入后会转移到状态12。在y.output 中state 12是这样的:

state 12

8 term: term MUL . primary_expression

DOUBLE_LITERAL shift, and go to state 1

primary_expression go to state 16

而如果是归约的情况,所要参照的规则4是这样的:

4 expression: term

也就是说,当记号被转换为term 后,下一个记号 进入以后,yacc 会报告有冲突发生,冲突的原因是当前term既可以继续进行移进,也可以归约为expression并结束。而这正是由于我们将 ADD修改为 MUL后, 的运算优先级被降低而引发的混乱。

对y.output阅读方法的说明就此告一段落,其实在实践中想要探明冲突原因并解决冲突,是比较有难度的。

因此即便表面上看起来都一样的编程语法,根据语法规则的书写方式不同, yacc可能报冲突也可能不报冲突(可以参考后文讲到的LALR(1)语法规定)。比如本节开头所说的C语言中 if else语法模糊的问题,在Java中就从语法规则入手回避了这个冲突。而类似这样的小问题以及相应的解决“窍门”,在自制编程语言中数不胜数,所以从现实出发,模仿已有的编程语言时,最好也要多多参考其语法规则。

2.2.6 错误处理

前面的小节中介绍了如何应对语法规则中的冲突问题,即编译器制作阶段的错误处理。而在自制编程语言时,还要考虑到使用这门语言编程的人如果犯了错误该怎么办。

在稍微旧一点的编译器书籍中,常常能读到下面这样的文字。

让用户自己不断的编译,既浪费CPU资源也浪费时间,因此编译器最好只经过一次编译就能看到大部分错误信息。

但是程序中的一个错误,其原因可能是由于其他错误引起的(可能会引起错误信息的雪崩现象)。一边不希望无用的错误信息出现,一边又想尽可能多地显示错误的原因,这其中的平衡点是很难掌握的。

然而时至今日,CPU已经谈不上什么“浪费资源”了,因为在多数情况下,编译过程往往一瞬间就能结束。那么即便一次编译显示出很多错误信息,用户一般也只会看第一条(我就是这样),这样的话,“编译器最好只经过一次编译就能看到大部分错误信息”也就没什么意义了。

所以最省事的解决方法之一就是,一旦出错,立即使用 exit()退出。之后章节中的crowbar和Diksam都是这样处理的。

但是对于计算器来说,是需要与用户互动,如果输错了一点程序就强制退出的话,对用户也太不友好了。

因此我们可以利用yacc的功能实现一个简单的错误恢复机制。

首先在mycalc.y的非终结符 line的语法规则中,追加下面的部分。

line

: expression CR

{

printf(">>%lf\n", $1);

}

| error CR

{

yyclearin;

yyerrok;

}

;

这里新出现的是 error记号。 error记号是匹配错误的特殊记号。 error可以后接 CR(换行符),这样书写可以匹配包含了错误的所有记号以及行尾。

动作中的 yyclearin会丢弃预读的记号,而 yyerrok则会通知yacc程序已经从错误状态恢复了。

既然是有交互的工具,一般都会使用换行来分割每次的会话,每一个错误信息后加上换行符应该会显得更美观吧。