Stephen C. Johnson
Bell Laboratories
Murray Hill, New Jersey 07974
翻译:寒蝉退士
译者声明:
译者对译文不做任何担保,译者对译文不拥有任何权利并且不负担任何责任和义务。
原文:http://cm.bell-labs.com/7thEdMan/vol2/yacc.bun摘要
计算机程序的输入通常有某种结构;实际上,可以认为每个需要输入的计算机程序都定义了一门它所接受的“输入语言”。输入语言可以像编程语言那么复杂,或者像一序列的数那么简单。不幸的是,通常的输入设施是有限的、难于使用的,并经常疏于检查它们的输入的有效性。
Yacc 提供了一个通用工具来描述计算机程序的输入。Yacc 用户规定它的输入的结构,连同在识别每个这种结构的时候调用的代码。Yacc 把这样的规定转换成操作输入处理的一个子例程;用这个子例程处理用户应用中的多数控制流经常是方便和适当的。
Yacc 生成的输入子例程调用用户提供的一个例程来返回下一个基本输入项目(item)。所以,用户可以以单个的输入字符的方式,或以更高层构造如名字和数的方式来规定它的输入。通过用户提供的例程还可以处理惯用的特征如注释和(行)接续约定,这些东西典型的违反容易的文法规定。
Yacc 是用可移植的 C 语言写成的。接受的规定类别是非常一般性的: 带有去歧义规则的 LALR(1) 文法。
除了用于 C、APL、Pascal、RATFOR 等编译器之外,Yacc 还用于非常规语言,包括一个照相排版机语言、一些桌面计算器语言、一个文档检索系统、和一个 Fortran 调试系统。
July 31,1978
Yacc 提供了一个通用工具来在计算机程序的输入上施加结构。Yacc 用户准备输入处理的规定;它包括描述输入结构的规则,在识别了这些规则的时候调用的代码,和做基本输入的一个低层例程。Yacc 接着生成一个函数来控制输入处理。这个函数叫做解析器(parser),它调用用户提供的低层输入例程(词法分析器(analyzer))来从输入流中选取基本项目(叫做记号(token))。依据叫做文法规则的输入结构规则来组织这些记号;在识别了这些规则中的某一个的时候,接着调用为这个规则提供的叫做动作的用户代码;动作有能力返回值并使用其他动作的值。
Yacc 是用 C[1] 语言的一个可移植方言写成的,并且动作和输出的例程也一样使用 C 语言。此外,Yacc 的很多语法约定依从 C 语言。
输入规定的心脏是一组文法规则。每个规则描述一个允许的结构并给它一个名字。例如,下面的文法规则
date : month_name day ',' year ;
这里的 date、month_name、day、和 year 表示在输入处理中它感兴趣的那些结构;month_name、day 和 year 大概是在其他地方定义的。逗号“,”包围在单引号之中;这暗示了逗号在输入中是作为文字(literal)出现的。冒号和分号只是充当规则中的标点符号,而对控制输入没有意义。所以,有着正确定义的输入
July 4, 1776
可以匹配上述规则。
输入处理的一个重要部分是完成词法分析器。这个用户例程读取输入流,识别低层结构,并把这些记号传达到解析器。出于历史原因,词法分析器识别的结构叫做终结符(terminal symbol),而解析器识别的结构叫做非终结符(nonterminal symbol)。为了避免混淆,终结符通常会称呼为记号。
在使用词法分析器还是使用文法解析器之间有相当可观的回旋余地。例如,规则
month_name : 'J' 'a' 'n' ; month_name : 'F' 'e' 'b' ; . . . month_name : 'D' 'e' 'c' ;
可以用于上述例子。这个文法解析器只需要识别单个字母,而 month_name 将是一个非终结符。这种低层规则趋向于浪费时间和空间,并可能使规则复杂得超越了 Yacc 的处理能力。通常的,词法分析器将识别月份名字,并返回见到了 month_name 的一个指示;在这种情况下,month_name 将是一个记号。
文字字符比如“,”也必须通过词法分析器来传递,所以也被当作记号。
规定文件是非常灵活的。向上述例子增加规则是相对容易的
date : month '/' day '/' year ;
允许
7 / 4 / 1776
作为
July 4, 1776
的同义字。在多数情况下,这个新规则可以“滑入”到工作系统中,并带有最小的努力,和混乱现存输入的很小的危险性。
读取中的输入可能无法满足这个规定。检测到这些错误可以同从左至右扫描理论上能做到的一样早;所以,不只是充分的减少了读取并计算不良数据的可能性,而且通常可以快速的发现不良数据。作为输入规定的一部分提供的错误处理,允许不良数据的再入,或在跳过不良数据之后继续做输入处理。
在一些情况下,Yacc 在给出某一组规定的时候无法生成解析器。例如,规定可能自相矛盾,或者它们要求比 Yacc 提供的更强力的识别机制。前者情况表示设计错误;后者情况经常可以通过使词法分析器更强力,或重写某些文法规则来修正。尽管 Yacc 不能处理所有可能的规定,它的能力可以顺利的同类似系统相比较;此外,Yacc 难于处理的构造经常也是难于人类处理的。一些用户报告对他们的输入明确表述有效的 Yacc 规定,暴露了程序开发中早期的概念或设计错误。
Yacc 底层的理论已经在其他地方[2, 3, 4]描述了。Yacc 已经在大量的实际应用中广泛的使用了,包括 lint[5]、可移植 C 编译器[6]、和一个排版数学的系统[7]。
下面的章节描述准备 Yacc 规定的基本过程;第 1 节描述准备文法规则,第 2 节描述准备与这些规则相关联的用户提供的动作,而第 3 节准备词法分析器。第 4 节描述解析器的操作。第 5 节讨论 Yacc 有可能无法从规定生成解析器的各种原因和对此要做些什么。第 6 节描述处理在算术表达式中操作符(operator)优先级(precedence)的简单机制。第 7 节讨论错误检测和修复。第 8 节讨论 Yacc 生成的解析器的操作环境和特殊特征。第 9 节给出增进规定的样式和效率的一些建议。第 10 节讨论一些高级主题,而第 11 节给出致谢。附录 A 有一个简单的例子,而附录 B 给出 Yacc 输入语法的总结。附录 C 给出使用 Yacc 的某些高级特征的一个例子,最后,附录 D 描述出于同 Yacc 老版本的历史延续性而提供的不再活跃支持的机制和语法。
名字称谓的是记号或非终结符二者之一。Yacc 同样的要求声明记号名字。此外,出于在第 3 节中讨论的原因,经常需要把词法分析器包括为规定文件的一部分;同样也可以包括其他有用的程序。所以,每个规定文件都由三段组成: 声明、(文法)规则和程序。使用双重百分号“%%” 来分隔各段。(百分号“%”在 Yacc 规定中一般用做转义(escape)字符。)
换句话说,完整的规定文件看起来如下
声明 %% 规则 %% 程序
声明段可以为空。此外,如果省略了程序段,第二个 %% 号也可以省略;
所以,最小的合法 Yacc 规定是
%% 规则
除了不能出现在名字或多字符保留符号中之外,空白、tab 和换行被忽略。注释可以出现在名字是合法的任何地方;它们被包围在 /* . . . */ 中,同 C 和 PL/I 语言一样。
规则段由一个或多个文法规则构成。文法规则有如下格式:
A : BODY ;
A 表示一个非终结符名字,而 BODY 代表一序列的零个或更多的名字和文字。冒号和分号是 Yacc 标点符号。
名字可以有任意长度,并由字母、点“.”、下划线“_”和不开头的数字组成。区分大写和小写字母。在文法规则主体中使用的名字可以代表记号或非终结符。
文字由包围在单引号“'”中的字符组成。同 C 语言一样,反斜杠“\”在文字中是转义字符,并识别所有 C 转义。所以
'\n' 换行 '\r' 回车 '\'' 单引号 ``''' '\\' 反斜杠 ``\'' '\t' tab '\b' backspace '\f' form feed '\xxx' ``xxx'' 八进制数
出于一些技术原因,NUL 字符('\0' 或 0)在文法规则中不应该使用。
如果有一些文法规则有相同的左手端,可以使用竖杠“|”来避免重写左手端。此外,在规则末端的分号如果在竖杠之前则可以去掉。所以给 Yacc 的文法规则
A : B C D ; A : E F ; A : G ;
可以写为
A : B C D | E F | G ;
在文法规则段中有相同左手端的所有文法规则出现在一起不是必须的,但是这使输入更加可读并易于改变。
如果一个非终结符匹配空字符串,则可以用明显的方式来指示:
empty : ;
代表记号的名字必须被声明;最简单的就是在声明段中写
%token name1 name2 . . .
。(更多的讨论参见章节 3、5 和 6)。假定没有在声明段中定义的每个名字都是非终结符。每个非终结符都必须至少在一个规则的左手端出现。
在所有非终结符中,有一个叫做开始符号的特别重要。解析器就是设计用来识别这个开始符号;所以这个符号代表文法规则描述的最大的最一般的结构。缺省的,在规则段中使用这个开始符号作为第一个文法规则。在声明段中使用 %start 关键字显式的声明这个开始字符是可能的,并在实际上是需要的:
%start symbol
通过叫做结束标记(endmarker)的特殊记号向解析器通知输入结束。如果直到但不包括这个结束标记的记号形成了同开始符号相匹配的一个结构,则解析器函数在见到结束标记之后返回到它的调用者。如果在任何其他上下文中见到这个结束标记,都是一个错误。
在适当的时候返回结束标记是用户提供的词法分析器的工作;参见下面的第 3 节。通常结束标记代表某种相当明显的 I/O 状态,比如“文件结束”或“记录结束”。
对于每个文法规则,用户都可以关联上在输入处理中每次识别了这个规则的时候进行的动作。这些动作可以返回值,并可以获得从前面动作返回的值。此外,词法分析器可以返回记号的值,如果需要的话。
动作是任意的 C 语句,同样可以做输入输出,调用子程序,和更改外部向量和变量。动作被指定为包围在花括号“{”和“}”中的一个或多个语句。例如,
A : '(' B ')' { hello( 1, "abc" ); }
和
XXX : YYY ZZZ { printf("a message\n"); flag = 25; }
是带有动作的文法规则。
为了便利在动作和解析器之间的通信,对动作语句要做稍微的改动。在这个上下文中使用美元符号“$”作为给 Yacc 的一个信号。
要返回值,动作通常把伪变量(pseudo-variable)“$$”设置为某个值。例如,不做任何事但返回值 1 的动作是
{ $$ = 1; }
要获得前面动作和词法分析器的返回值,动作必须使用伪变量 $1、$2、. . .,它们提及的是规则右手端的部件(component)的返回值,它们是从左至右读取的。所以,如果规则是
A : B C D ;
例如,这里的 $2 拥有 C 返回的值,而 $3 是 D 返回的值。
作为一个更具体的例子,考虑规则
expr : '(' expr ')' ;
这个规则返回的值通常是圆括号中的 expr 的值。这可以指示为
expr : '(' expr ')' { $$ = $2 ; }
缺省的,规则的值是其中第一个元素($1)的值。所以,如下形式的文法规则
A : B ;
经常不需要显式的动作。
在上述例子中,所有动作都在规则的末端。有时,需要在一个规则被完全解析之前获得控制权。Yacc 允许在规则中间同末端一样写动作。假定这个动作(原文误作规则)返回一个值,它右面的动作可以通过平常的机制访问它。依次的,它可以访问它左面动作的返回值。所以,规则
A : B { $$ = 1; } C { x = $2; y = $3; } ;
在效果上是设置 x 为 1,设置 y 为 C 返回的值。
Yacc 通过制造一个新的非终结符名字,和把这个名字匹配到空字符串的一个新规则,来处理不终结一个规则的动作。内部动作是通过识别这个增加的规则而触发的动作。Yacc 实际上对待上述例子如同它被写为:
$ACT : /* empty */ { $$ = 1; } ; A : B $ACT C { x = $2; y = $3; } ;
在很多应用中,动作不做直接的输出;转而在内存中构造一个一个数据结构,比如解析树,并在生成输出之前向它应用变换。给出建造并维护想要的树结构的例程,分析树就特别易于构造。例如,假定写了一个 C 函数 node,调用
node( L, n1, n2 )
建立带有标签 L 和后代节点的 n1 和 n2 的一个节点,并返回这个新建节点的索引。在规定中可以通过提供如下动作来构造析树:
expr : expr '+' expr { $$ = node( '+', $1, $3 ); }
用户可以定义动作使用的其他变量。这种声明和定义可以出现在声明段中,包围在标号“%{”和“%}”之间。这些声明和定义有全局作用域,所以它们可以被动作语句和词法分析器所知晓。例如,
%{ int variable = 0; %}
可以放置到声明段中,使 variable 对于所有动作都是可以访问的。Yacc 解析器只使用以“yy”开始的名字;用户应当避免使用这种名字。
在这些例子中,所有变量都是整数: 对其他类型的值的讨论可以在章节 10 中找到。
用户必须提供一个词法分析器来读取输入流并把记号(带有值,如果需要的话)传达到解析器。词法分析器使叫做 yylex 的整数值的函数。 这个函数返回一个整数的记号编号,它表示读取的记号的种类。如果这个记号关联着一个值,应当把它赋予外部变量 yylval。
为使通信得以发生,解析器和词法分析器必须在记号编号上达成一致。编号可以由 Yacc 或用户来选择。在这两种情况下,使用 C 语言的“# define”机制允许词法分析器使用符号来返回这些编号。例如,假定在 Yacc 规定文件的声明段中已经定义记号名字 DIGIT。词法分析器的相关部分可能看起来如下:
yylex(){ extern int yylval; int c; . . . c = getchar(); . . . switch( c ) { . . . case '0': case '1': . . . case '9': yylval = c-'0'; return( DIGIT ); . . . } . . .
它的意图是返回一个 DIGIT 记号编号,和等于这个数字的数值的一个值。倘若词法分析器代码位于规定文件的程序段,标识符 DIGIT 将被定义为与记号 DIGIT 关联的记号编号。
这种机制导致清晰的、易于修改的词法分析器;唯一的缺点是在文法中需要避免使用任何在 C 语言或解析器中保留的或有意义的记号名字;例如,使用记号名字 if 或 while 就一定会导致编译词法分析器时出现严峻的困难。记号名字 error 保留给错误处理,不应该随便使用(参见章节 7)。
同上所述,记号编号可以由 Yacc 或用户来选择。在缺省的条件下,编号由 Yacc 选择。文字字符的缺省记号编号是它在本地字符集中的字符数值。其他名字赋予从 257 开始的记号编号。
要把一个记号编号赋予一个记号(包括文字),可以在声明段中记号或文字的第一次出现时直接跟随着一个非负整数。这个整数被接受为这个名字或文字的记号编号。不通过这种机制定义的名字和文字保持它们的缺省定义。所有记号编号都是不同的是很重要的。
出于历史原因,结束标记必须有记号编号 0 或负数。这个记号编号不能由用户重定义;所以,所有词法分析器在到达它们的输入结束处的时候应当准备返回 0 或负数作为记号编号。
构造词法分析器的一个有用的工具是 Mike Lesk[8]开发的 Lex 程序。这些词法分析器设计用来与 Yacc 解析器紧密协调工作。这些词法分析器的规定使用正则表达式而不是文法规则。可以轻易的用 Lex 生成非常复杂的词法分析器,但是仍有一些语言(比如 FORTRAN)不适应任何理论框架,它的词法分析器必须手工制作。
Yacc 把规定文件转换成 C 程序,它依据给出的规定解析输入。做从规定到解析器转换的算法是复杂的,就不在这里讨论了(更多信息参见引用)。但是,解析器自身就相对简单了,理解它是如何工作的,尽管不是严格必须的,但会使错误修复和歧义处置更加易于理解。
Yacc 提供的解析器是由带有一个栈的有穷状态自动机组成。解析器自身还有能力读取和记住(叫做超前(lookahead)记号)下一个输入记号。当前状态总是在栈顶。有穷状态自动机的状态是一个给定的小整数标签(label);最初时,机器是在状态 0 下,栈只包含状态 0,没有读取超前记号。
机器对它只能获得四个动作,叫做移进(shift)、归约(reduce)、接受和错误。解析器的移动按如下规则进行:
1. 基于它的当前状态,解析器决定是否需要一个超前记号来决定应当做什么动作;如果需要并且没有读取,则调用 yylex 来获得下一个记号。
2. 使用当前状态,和超前记号(如果需要的话),解析器决定它的下一个状态,并完成它。这可能导致状态压入栈中,或从栈中弹出来,和导致超前记号被处理或保留。
移进动作是解析器做的最常见的动作。在做移进动作的时候,这里总是有一个超前记号。例如,在状态 56 下有这么一个动作:
IF shift 34
这是说,在状态 56 下,如果超前字符是 IF,则当前状态(56)在栈中被压下去,而状态 34 成为当前状态(在栈顶)。超前字符被清除。
归约动作防止栈无限制的增长。在解析器已经见到一个文法的右手端的时候做归约动作是适当的,它准备好宣布它已经见到这个规则的一个实例(instance),用这个规则的左手端替换它的右手端。有可能需要参考超前记号来决定是否归约,但是通常不需要;实际上,缺省动作(表示为“.”)经常是一个归约动作。
归约动作与单独的文法规则相关联。文法规则也以小整数给出,这导致了一些混淆。动作
. reduce 18
提及的是文法规则 18,而动作
IF shift 34
提及的是状态 34。
假定要归约的规则是
A : x y z ;
归约动作依赖于左手端符号(symbol)(这里是 A),和右手端符号的数目(这里是 3)。要归约,首先从栈顶中弹出三个状态(一般的,弹出的状态数目等于规则右手端符号的数目)。在效果上,这些状态识在识别 x、y 和 z 的时候压入栈中的,并不再有任何用处。在弹出这些状态之后,开始处理这个规则之前,分析器处在暴露(uncovered)状态下。使用这个暴露状态,和在规则左手端的符号,进行实效上的移进 A。获得一个新状态,压入栈中,并继续分析。在处理左手端符号和记号的普通移进之间有一个重要的区别,所以这个动作叫做跳转(goto)动作。特别是,移进清除超前记号,而跳转不影响它。在任何情况下,暴露状态都包含一个条目比如:
A goto 20
导致状态 20 被压入栈中,并成为当前状态。
在效果上,归约动作在解析器中“把钟拨回”,从栈中弹出状态,以此回到首次见到这个规则的右手端的那个状态。解析器接着运转,如同它已经在此时见到了左手端那样。如果这个规则的右手端为空,则不从栈中弹出状态: 暴露状态实际上就是当前状态。
归约动作在用户提供的动作和值的处置中也是很重要的。在一个规则被归约的时候,在调整栈之前执行这个规则提供的代码。除了持有状态栈之外,还有另一个栈与它并行运行,它持有从词法分析器和这些动作返回的值。在发生移进的时候,把外部变量 yylval 复制到值栈顶上。在从用户代码返回之后,完成归约。在做跳转动作的时候,把外部变量 yyval 复制到到值栈顶上。伪变量 $1、$2 等提及的就是这个值栈。
其他两个解析器动作在概念上非常简单。接受动作指示整个输入已经查看完了并且它与规定相匹配。这个动作只在超前记号是结束标记的时候出现,并指示出解析器已经成功的完成了它的工作。在另一方面,错误动作表示解析器不能再继续依据规定做解析的状况。已经见到的输入记号,与超前记号一起,不能遵循导致合法输入的任何东西。解析器报告一个错误,并尝试恢复状态并重新开始解析: 错误修复(相对于错误检测)将在第 7 节中叙述。
是给出例子的时候了! 考虑下列规定
%token DING DONG DELL %% rhyme : sound place ; sound : DING DONG ; place : DELL ;
在使用 -v 选项调用 Yacc 的时候,生成一个叫做 y.output 的文件,它包含对解析器的人类可读的描述。对应于上述文法的 y.output 文件(去除了结尾处的一些统计)是:
state 0 $accept : _rhyme $end DING shift 3 . error rhyme goto 1 sound goto 2 state 1 $accept : rhyme_$end $end accept . error state 2 rhyme : sound_place DELL shift 5 . error place goto 4 state 3 sound : DING_DONG DONG shift 6 . error state 4 rhyme : sound place_ (1) . reduce 1 state 5 place : DELL_ (3) . reduce 3 state 6 sound : DING DONG_ (2) . reduce 2
注意,除了在每个状态的给出动作之外,在每个状态中还有对正在处理中的解析规则的描述。使用 _ 字符指示在每个规则中见到了什么,和什么仍未出现。假定输入是
DING DONG DELL
跟踪解析器在处理这个输入期间的步骤是有教益的。
最初,当前状态是 0。解析器需要参照输入来在状态 0 下能获得的动作中做出抉择,所以读入了第一个记号 DING,它成为超前记号。在状态 0 下对记号 DING 的动作是“shift 3”,所以状态 3 被压入栈中,超前记号被清除。状态 3 成为当前状态。读入下一个记号 DONG,它成为超前记号。在状态 3 下对记号 DONG 的动作是“shift 6”所以状态 6 被压入栈中,超前记号被清除。栈现在包含 0、3 和 6。在 状态 6 下,不用参考超前记号,解析器按规则 2 归约。
sound : DING DONG
这个规则在右手端有两个符号。所以从栈中弹出两个状态 6 和 3,暴露了状态 0。参照状态 0 的描述,查找对 sound 的跳转,获得了
sound goto 2
;所以状态 2 压入中,成为当前状态。
在状态 2 下,必须读取下一个记号 DELL。动作是“shift 5”,所以状态 5 被压入栈中,栈中现在有 0、2 和 5,超前记号被清除。在状态 5 下,唯一的动作是按规则 3 归约。它在右手端有一个符号,所以从栈中弹出一个状态 5,暴露了状态 2。在状态 2 下对规则 3 的左手端 place 做跳转到状态 4。现在栈包含 0、2 和 4。在状态 4 下,唯一的动作是按规则 1 归约。规则 1 右手端有两个符号,所以从栈中弹出两个状态,再次暴露了状态 0。在状态 0 下,对 rhyme 有一个跳转导致分析器进入状态 1。在状态 1 下,读取输入,获得了结束标记,它在 y.output 中用“$end”来指示。在状态 1 下在见到结束标记时的动作是接受,成功的结束了解析。
读者可能急切的想知道在面对不正确的字符串比如 DING DONG DONG、DING DONG、DING DONG DELL DELL 等的时候解析器如何工作。在这个和其他简单例子中多花点时间,在更复杂的上下文中出现问题的时候解决起来就快速了。
如果某些输入字符串可以按两种或更多方式来构造,则这组文法是有歧义的。例如,文法规则
expr : expr '-' expr
是表达算术表达式的一种自然的方式,把两个其他表达式放在一起并在中间的放一个减号就形成了算术表达式。不幸的是,这个文法规则没有完全的规定构造所有复杂输入的方式。例如,如果输入是
expr - expr - expr
规则允许这个输入被构造为
( expr - expr ) - expr
或者是
expr - ( expr - expr )
(第一个叫左结合(association),第二个叫右结合)。
Yacc 尝试建造解析器的时候检测这种歧义。考虑在给出如下输入的时候解析器所面对的问题是有教益的
expr - expr - expr
当解析器读到第二个 expr 的时候,它已经见到的输入是:
expr - expr
匹配上述文法规则的右手端。解析器可以通过应用这个规则来归约输入;在应用了这个规则之后,输入被归约成 expr(规则的左手端)。解析器可以接着读取输入的最后部分:
- expr
并再次归约。这种效果是采用了左结合解释。
可供选择的,在解析器见到
expr - expr
它可以延迟应用规则,并继续读取输入直到它见到
expr - expr - expr
它可以接着向最右的三个符号应用规则,把它们归约成 expr 并留下
expr - expr
现在这个规则可以再次归约;这种效果是采用了右结合解释。所以,读取了
expr - expr
分析器可以做两种合法的事情,一次移进或一次归约,在它们之间无法抉择,这叫做移进/归约冲突。也可能发生解析器选择两个合法归约动作的事情;这叫做归约/归约冲突。注意永远不会有什么“移进/移进”冲突。
在有移进/归约或归约/归约冲突的时候,Yacc 仍然生成解析器。它是通过在需要选择地方选择有效步骤之一而完成的。描述在一个给定状况下做如何抉择的规则叫做去歧义规则。
Yacc 缺省的调用两个去歧义规则:
1. 在移进/归约冲突中,缺省是做移进。
2. 在归约/归约冲突中,缺省是归约(按输入顺序)更早先的文法规则。
规则 1 暗示了只要有选择就推迟归约、以利于移进。规则 2 给予用户对解析器在这种情况下的行为的非常粗糙的控制,但是归约/归约冲突应当尽可能的避免。
引发冲突的原因可能是输入或逻辑中的错误,也可能因为尽管文法规则是一致的、但要求比 Yacc 能构造的更加复杂的解析器。使用在规则内的动作也可能导致冲突,如果必须在解析器可以确定识别了哪个规则之前做这个动作的话。在这些情况下,去歧义规则的应用是不适当的,并导致不正确的解析器。为此,Yacc 总是报告规则 1 和规则 2 解决的移进/归约和归约/归约冲突的数目。
一般的说,只要有可能应用去歧义规则生成正确的解析器,就有可能重写文法规则,使对同样的输入没有冲突。为此,多数以前的解析器生成器认为冲突是致命错误。我们的经验是这种重写是有些不自然的,并生成更慢的解析器;所以 Yacc 即使在有冲突存在情况下还是生成解析器。
去歧义规则的一个例子是,考虑涉及“if-then-else”构造的编程语言的一个片段:
stat : IF '(' cond ')' stat | IF '(' cond ')' stat ELSE stat ;
在这些规则中,IF 和 ELSE 是记号,cond 是描述条件(逻辑)表达式的非终结符号,而 stat 是描述语句的非终结符号。第一个规则叫做简单-if 规则,第二个叫做 if-else 规则。
这两个规则形成一个歧义构造,因为如下形式的输入
IF ( C1 ) IF ( C2 ) S1 ELSE S2
可以依据两种方式构造:
IF ( C1 ) { IF ( C2 ) S1 } ELSE S2
或
IF ( C1 ) { IF ( C2 ) S1 ELSE S2 }
第二个解释是多数编程语言对这个构造的解释。每个 ELSE 都与最近的前面的“没有 ELSE 的” IF 相结合。在这个例子中,考虑解析器见到
IF ( C1 ) IF ( C2 ) S1
并正在查看 ELSE 的状况。它可以立即按简单-if 规则归约得到
IF ( C1 ) stat
并接着读取余下的输入,
ELSE S2
并按 if-else 规则规约
IF ( C1 ) stat ELSE S2
。这导致输入的上述组合的第一种情况。
在另一方面,ELSE 可以被移进,读取 S2,并接着把
IF ( C1 ) IF ( C2 ) S1 ELSE S2
的右面部分按 if-else 规则规约得到
IF ( C1 ) stat
它可以按简单-if 规则规约。这导致输入的上述组合的第二种情况,这是通常想要的。
一旦解析器可以做两种有效的事情 - 就有移进/规约冲突。应用去歧义规则 1 告诉解析器在这种情况下做移进,这导致想要的组合。
这种移进/归约冲突只发生在特定的当前输入符号 ELSE,和特定的已经见到的输入的时候,比如
IF ( C1 ) IF ( C2 ) S1
的情况下。一般的说,有很多种冲突,每个都是同一个输入符号和一组以前读入的输入相关联。使用解析器的状态来表现这些以前读入的输入。
通过检查冗余(-v)选项输出文件 Yacc 的冲突消息是最好理解的。例如,对应于上述冲突的输出可能是:
23: shift/reduce conflict (shift 45, reduce 18) on ELSE state 23 stat : IF ( cond ) stat_ (18) stat : IF ( cond ) stat_ELSE stat ELSE shift 45 . reduce 18
第一行描述这个冲突,给出状态和输入符号。随后是普通的状态描述,给出在这个状态中活跃的文法规则和解析器动作。回想下划线标记出了文法规则中已经见到的部分。所以在这个例子中,在状态 23 中解析器已经见到的输入对应于
IF ( cond ) stat
并且展示了在此时活跃的两个文法规则。解析器可以做两种可能的事情。如果输入符号是 ELSE,可能移进到状态 45。状态 45 在它的描述中有下面这一行
stat : IF ( cond ) stat ELSE_stat
因为在这个状态下 ELSE 已经被移进。回到状态 23 中,“.”描述了一个可供选择的动作,如果输入符号在上述动作中没有明确的提及到则做这个动作;所以,在这种情况下,如果输入符号不是 ELSE,则解析器将按文法规则 18 归约:
stat : IF '(' cond ')' stat
再次注意紧随“shift”命令之后的数提及的是其他状态,而紧随“归约”命令的数提及的是文法规则编号。在 y.output 文件中,规则编号在可以被归约的这些规则之后打印。在多数状态中,最有可能存在归约动作的是缺省命令。遇到未期望的移进/归约冲突的用户可以查看冗余输出来决定缺省动作是否合适。在真正的艰苦条件下,用户可能需要知道比这里覆盖的更多的解析器的行为和构造。在这种情况下,可以参考理论文献[2, 3, 4];咨询本地技术领袖也是合适的。
在一种常见的情况下,上述解决冲突的规则是不充分的;这发生在分析算术表达式中。算术表达式中多数经常使用的构造可以用操作符优先级概念(notion)、连同左或右结合性(associativity)的信息来自然的描述。使用带有适当的去歧义规则的歧义文法建造的解析器,比使用无歧义文法构造出的解析器更快速和容易写。基本概念是对想要的所有二元或一元操作符写如下形式的文法规则
expr : expr OP expr
和
expr : UNARY expr
。这建立了一个非常有歧义的文法,带有很多解析冲突。作为去歧义规则,用户指定所有操作符的优先级或粘连强度,和二元操作符的结合性。这种信息足够 Yacc 依据这些规则解决解析冲突,并构造实现了想要的优先级和结合性的解析器。
在声明段中把优先级和结合性附加到记号上。在开始于 Yacc 关键字: %left、%right 或 %nonassoc,并跟随着记号列表的记号定义行中完成这项工作。所有在同一行的记号都被假定有相同的优先级级别和结合性;这些行按递增的优先级或粘连强度次序列出。所以
%left '+' '-' %left '*' '/'
描述了四个算术操作符(算符)的优先级和结合性。加法和减法是左结合的,并有比星号和斜杠更低的优先级,它们都是左结合的。使用关键字 %right 描述右结合操作符,使用关键字 %nonassoc 描述不能与自身结合的操作符,如 Fortran 的 .LT.;所以,
A .LT. B .LT. C
在 Fortran 中是非法的,这种操作符在 Yacc 中用关键字 %nonassoc 描述。作为这些声明的行为的例子,描述
%right '=' %left '+' '-' %left '*' '/' %% expr : expr '=' expr | expr '+' expr | expr '-' expr | expr '*' expr | expr '/' expr | NAME ;
可以用来构造输入
a = b = c*d - e - f*g
为如下:
a = ( b = ( ((c*d)-e) - (f*g) ) )
在使用这个机制的时候,一元操作符一般必须给出一个优先级。有时一元操作符和二元操作符有相同的符号名字表示,但是有不同的优先级。一个例子是一元和二元‘-’;一元减法可以给予同乘法相同的结合强度,甚至更高,而二元减法有比乘法更低的结合强度。关键字 %prec 改变与特定文法规则关联的优先级级别。%prec 直接出现在文法规则主体之后,在动作或结束的分号之前,并跟从一个记号名字或文字。它导致文法规则的优先级变成随后的记号名字或文字的优先级。例如,要使一元减法与乘法有相同的优先级,则规则类似于:
%left '+' '-' %left '*' '/' %% expr : expr '+' expr | expr '-' expr | expr '*' expr | expr '/' expr | '-' expr %prec '*' | NAME ;
用 %left、%right 和 %nonassoc 声明的记号不需要但可以用 %token 做同样的声明。
Yacc 使用优先级和结合性来解决解析冲突;它们引起去歧异规则。形式上,规则工作如下:
1. 为有这种声明的这些记号纪录优先级和结合性。
2. 优先级和结合性与每个文法规则相关联;它是这个规则主体的最后的记号或文字的优先级和结合性。如果使用了 %prec 构造,它替代这种缺省。一些文法规则可以没有与之关联的优先级和结合性。
3. 在有归约/归约冲突时候,或者有移进/归约冲突并且输入符号或者文法规则之一没有优先级和结合性的时候,则使用在章节开始处给出的两个去歧义规则,并报告冲突。
4. 如果有移进/归约冲突,并且文法规则和输入符号有与之关联的优先级和结合性,则以有利于关联着更高优先级的动作(移进或归约)的方式解决冲突。如果优先级相同,则使用结合性;左结合暗示归约,右结合暗示移进,而无结合暗示错误。
用优先级解决的冲突不计数到 Yacc 报告的移进/规约冲突中。这意味着在优先级规定中的错误可以掩饰在输入文法中的错误;对优先级最好持保守的态度,并以本质上的“烹调书”方式使用它们,直到获得了某些经验。在判定解析器实际上是否在做打算让它做的事情时 y.output 文件是非常有用的。
错误处理是一个非常困难的领域,很多问题是语义问题。例如,当找到一个错误的时候,可能必须收回解析树存储,删除或更改符号表条目,和典型的设置开关来避免生成进一步的输出。
在找到一个错误的时候就停止所有处理是很少能被接受;继续扫描输入来找到进一步的语法错误是更加有用的。这导致在一个错误之后使解析器“重新启动”的问题。做这件事的常规的算法涉及到从输入字符串中丢弃一些记号,和尝试调整解析器让输入可以继续。
为了允许用户在这个过程中做某些控制,Yacc 提供了一个简单但相当一般性的特征。记号名字“error”保留给了错误处理。这个名字可以用在文法规则中;在效果上,它提出了预期的错误和修复发生的位置。解析器弹出它的栈直到进入记号“error”合法的一个状态。它接着表现得如同记号“error”是当前超前记号一样,并进行遇到的动作。超前记号接着重置为导致这个错误的记号。如果没有指定特殊的错误规则,在检测到错误的时候处理停止。
为了防止连锁(cascade)的错误消息,解析器在检测到一个错误之后,保持在错误状态下直到成功的读取并移进了三个记号。如果在解析器已经在错误状态下的时候检测到一个错误,则不给出消息,并且安静的删除输入记号。
作为一个例子,如下形式的规则
stat : error
在效果上,意味着在语法错误的时候,解析器将尝试跳过在其中见到错误的语句。更确切的,解析器将向前扫描,查找可能合法的遵从一个语句的三个记号,并在其中第一个上开始处理;如果不能充分的辨别出语句的开始,它可能在一个语句的中作出失败的开始,并结束于报告第二个错误,而这里实际上没有错误。
对这些特殊的错误规则可以使用动作。这些动作可以尝试重新初始化表格,收回符号表空间等。
上面的错误规则是非常一般的,但是难于控制。稍微容易些的是
stat : error ';'
在这里,在有一个错误的时候,解析器尝试跳过这个语句,并跳至下一个‘;’。在这个错误之后和下一个‘;’之前的所有记号都不移进并被丢弃。在见到‘;’的时候,这个规则将被归约,并进行与之相关联的任何“清除”动作。
错误规则的另一种形式出现在交互式应用中,在这里可能需要允许在一个错误之后重新录入一行。一种可能的错误规则是
input : error '\n' { printf( "Reenter last line: " ); } input { $$ = $4; }
这种方式有一个潜在的困难;解析器在承认它已经在错误之后正确的重新同步之前,必须正确的处理三个输入记号。如果重新录入的行在前两个记号中包含错误,解析器会删除这些讨厌的记号,而不给出消息;这明显的是不可接受的。为此,可以使用一种机制来强制解析器相信错误已经被完全修复了。语句
yyerrok ;
是一个动作,重置分析器为正常状态。最后的例子可写为更好的
input : error '\n' { yyerrok; printf( "Reenter last line: " ); } input { $$ = $4; } ;
同上面提及的一样,在“error”符号之后立即见到的记号是修复了错误的输入记号。有时,这是不恰当的;例如,错误修复动作可能自己承担找到恢复输入的正确位置的工作。在这种情况下,必须清除前面的超前记号。在动作中
yyclearin ;
语句就有这种效果。例如,假定在错误之后的动作要调用用户提供的某种复杂的重新同步例程,尝试把输入前进到下一个有效的语句的开始处。在调用这个例程之后,yylex 返回的下一个记号大概会是一个合法语句的第一个记号;旧的非法记号必须被清除,这可以用类似下面的规则来做
stat : error { resynch(); yyerrok ; yyclearin ; } ;
无可否认这些机制是粗糙的,但是允许解析器从很多错误中简单而有效的恢复过来;此外,用户可以获得控制权去安排通过程序的其他部分做必需的错误处置动作。
在用户向 Yacc 输入规定的时候,在多数系统上输出文件是叫做 y.tab.c 的一个 C 程序文件(因为本地文件系统惯例,它的名字在不同安装中可能是不同的)。Yacc 生成的函数叫 yyparse;它是整数值的函数。在调用的时候,它依次重复的调用用户提供的词法分析器 yylex(参见章节 3)来获得输入记号。最终,要么是检测到一个错误,在这种情况下(如果没有错误修复是可能的) yyparse 返回值 1,要么词法分析器返回结束标记记号并且解析器接受。在这种情况下,yyparse 返回值 0。
用户必须为解析器提供特定数量的环境来获得一个工作的程序。例如,同每个 C 程序一样,必须被定义程序调用的 main(),它最终调用 yyparse。此外,叫做 yyerror 的一个例程在检测到语法错误的时候打印一个消息。
用户必须以某种形式提供这两个例程。为了减轻使用 Yacc 的起初努力,提供了带有缺省版本的 main 和 yyerror 的一个库。这个库的名字是依赖系统的;在很多系统上使用到装载器的 -ly 参数来访问这个库。在下面的源代码中展示这些缺省程序作的琐事:
main(){ return( yyparse() ); }
和
# include <stdio.h> yyerror(s) char *s; { fprintf( stderr, "%s\n", s ); }
到 yyerror 的参数是包含错误信息的字符串,通常是字符串“syntax error”。 一般的应用可能需要更好的消息。通常,程序跟踪输入行数,并与检测到的语法错误一起打印。外部整数变量 yychar 在检测到错误的时候包含超前记号的编号;这对给出更好的诊断有好处。因为 main 程序(需要读参数等等)大多数时候是用户提供的。Yacc 库只在小项目或大点的项目的早期阶段有用。
外部整数变量 yydebug 通常设置为 0。如果设置为非零的值,解析器会输出对它的动作的一个冗余的描述,包括对已经读入哪个输入符号和解析器动作是什么的讨论。依赖于操作系统,可以通过使用调试系统来设置这个变量。
本节包含对准备有效的、易于变更的和清晰的规定的混杂的提示。单独的小节都或多或少的独立。
提供带有实质动作的规则并保持一个可读的规定文件是困难的。下列样式提示归功于 Brian Kernighan。
a. 为记号名字使用全部的大写字母,为非终结名字使用全部的小写字母。这个规则伴有标题“出错时知道是谁的过失”。
b. 把文法规则和动作放在分开的行上。这允许更改任何一个而不会自动的需要改变另一个。
c. 把有相同左手端的所有规则放置在一起。只放置左手端一次,并让所有后面的规则以一个竖杠开始。
d. 只在带有给定的左手端的最后一个的规则之后放置一个分号;并把分号放置到单独的行中。这允许容易的添加新规则。
e. 用两个 tab 停顿缩进规则主体,用三个 tab 停顿缩紧动作主体。
在附录 A 中的例子用这种样式写成,本文正文中的例子也是这样(在空间允许的地方)。用户必须整理出对这个格式上的问题的自己的意见;但是,中心的问题是使规则在动作代码的沼泽中是明显可见的。
Yacc 解析器使用的算法鼓励所谓的“左递归”文法规则: 即如下形式的规则
name : name rest_of_rule ;
在写序列和列表规定的时候经常出现这种规则:
list : item | list ',' item ;
和
seq : item | seq item ;
在每种情况下,第一个规则只对第一个项目归约,而第二个规则对第二个和所有随后的项目归约。
使用右递归规则,比如
seq : item | item seq ;
解析器会更大,项目将从右至左的见到和归约。更严重的是,如果读的是非常长的序列,解析器的内部栈就有溢出的危险。所以,只要合乎情理用户就应当使用左递归。
有零个元素的序列是否有任何意义是值得考虑的,如果有,考虑写带有空规则的序列规定:
seq : /* empty */ | seq item ;
再一次,第一个规则将总是正好在读第一个项目之前归约一次,接着第二个规则为读到的每个项目归约一次。允许空序列经常导致增强了的一般性。但是,如果要求 Yacc 去决定它见到的是那个空序列就可能发生冲突,此时它还没有见到足够的东西去知晓!
一些词法决定依赖于上下文。例如,词法分析器通常需要删除空白,但在被引用的字符串中的空白不能删除。声明中的名字可以录入到符号表中,但表达式中的名字不能录入。
处理这种状况的一种方式是建立一个全局标志(flag),词法分析器检查它,动作设置它。例如,假设由 0 或多个声明、和随后的 0 或多个语句组成的一个程序。考虑:
%{ int dflag; %} ... other declarations ... %% prog : decls stats ; decls : /* empty */ { dflag = 1; } | decls declaration ; stats : /* empty */ { dflag = 0; } | stats statement ; ... other rules ...
标志 dflag 在读语句的时候是 0,在读声明的时候是 1,在第一语句中第一个记号除外。 这个记号必须在解析器可以断定声明段落已经结束而语句段落已经开始之前被它见到。在很多情况下,这个单一的记号例外不影响词法扫描。
这种“后门”方式可能精细到有害的程度。尽管它提出了做某些困难的事情的方法,除非万不得已,最好用其他方式。
一些编程语言允许用户使用通常被保留的字如“if”作为标签或变量名字,假如这种用法不与这些名字在编程语言中法定用法相冲突的话。在 Yacc 的框架中这么做是非常困难的;难于向词法分析器传递信息来告诉它“‘if’的这个实例是关键字,而那个实例是变量”。使用前一小节描述的机制,用户可以做极力的尝试,但这是困难的。
有好几个令这件事情简单些的建议在讨论中。不过在讨论出结果之前,关键字最好被保留;就是说,禁止用作变量名字。总之,偏好如此有着强大的风格上的理由。
本节讨论 Yacc 的一些高级特征。
解析器的错误和接受动作可以在动作中使用宏 YYACCEPT 和 YYERROR 来模拟。YYACCEPT 导致 yyparse 返回值 0;YYERROR 导致解析器表现得如同当前输入符号有语法错误一样;调用 yyerror,错误修复就发生。可以使用这些机制来模拟多个结束标记或做上下文相关的语法检查的解析器。
动作可以引用当前规则左面的规则的动作返回值。这种机制几乎和普通的动作完全相同,使用跟随着一个数字的美元符号,但是在这种情况下数字可以是 0 或负数。考虑
sent : adj noun verb adj noun { look at the sentence . . . } ; adj : THE { $$ = THE; } | YOUNG { $$ = YOUNG; } . . . ; noun : DOG { $$ = DOG; } | CRONE { if( $0 == YOUNG ){ printf( "what?\n" ); } $$ = CRONE; } ; . . .
在紧随 CRONE 的动作中,做了前面移进的记号不是 YOUNG 的检查。明显的,这只有在知晓输入中先于 noun 的是什么的时候才是可能的。这里有着不同寻常的无组织特色。然而,有时这种机制能省去大量的麻烦,特别是在要从在其他方面正规的结构中排除掉一些结合的时候。
缺省的,动作和词法分析器返回的值是整数。Yacc 还支持其他类型的值,包括结构。此外,Yacc 跟踪类型,并插入正确的联合成员名字,所以结果的解析器将做严格的类型检查。Yacc 值栈(参见章节 4)被声明为各种想要的值类型的联合。用户声明这个联合,并把联合成员名字关联到有值的每个记号和非终结符上。在通过 $$ 或 $n 构造引用这个值的时候,Yacc 将自动的插入正确的联合成员名字,所以不发生多余的转换。此外,类型检查命令比如 Lint[5] 对解析器代码的反应将是非常平静的。
用三种方式来提供这种类型机制。首先,有一种方式定义联合;这必须由用户来做,因为其他程序特别是词法分析器必须知道联合成员名字。其次,有一种方式把联合成员名字关联记号和非终结符上。最后,有一种机制用来描述 Yacc 不能轻易决定其类型的那些少见的值的类型。
要声明联合,用户可在声明段中包含:
%union { body of union ... }
这声明了 Yacc 值栈,并且外部变量 yylval 和 yyval 有等于这个联合的类型。如果使用 -d 选项调用 Yacc,则把联合声明复制到 y.tab.h 文件中。二者选一的,联合也可以在头文件中声明,使用 typedef 来定义变量 YYSTYPE 去表示这个联合。所以,头文件可以有如下定义:
typedef union { body of union ... } YYSTYPE;
头文件必须通过使用 %{ 和 %} 包含在声明段中。
一旦定义了 YYSTYPE,必须把联合成员名字关联到各种终结和非终结名字上。使用构造
< name >
来指示一个联合成员名字。如果它跟随着关键字 %token、%left、%right 和 %nonassoc 之一,则把联合成员名字关联到列出的记号上。所以写
%left <optype> '+' '-'
将导致对这两个记号返回值的任何引用都被标签上联合成员名字 optype。使用另一个类似的关键字 %type 把联合成员名字关联到非终结符上。所以,你可以写
%type <nodetype> expr stat
仍残留着一些个例,在其中这些机制是不充分的。如果在规则内部有一个动作,这个动作返回的值没有先验的类型。类似的,到左端上下文的值的引用(比如 $0 - 参见前面小节)没有留给 Yacc 知道这种类型的轻易的方式。在这种情况下,通过在第一个 $ 之后紧接着的 < 和 > 之间插入联合成员名字,可以这种引用上施加一个类型。这种用法的一个例子是
rule : aaa { $<intval>$ = 3; } bbb { fun( $<intval>2, $<other>0 ); } ;
不建议这种语法,但这种情形很少出现。
在附录 C 中给出一个样例规定。在这小节中的设施在使用之前是不被触发的: 特别是,使用 %type 就会开启这种机制。在使用它们的时候,有一种非常严格的检查级别。例如,使用 $n 或 $$ 来引用没有定义类型的一些东西会被诊断出来。如果这些设施没有被触发,使用 Yacc 值栈来持有 int 类型,历史上同样是真的。
Yacc 在很大程度上归功于用户们有刺激性的捐献,在他们对“再多一个特征”的无尽的探求中,他们驱使我超越我的本意,并经常超越我的能力。他们令人气恼的不情愿学会用我的方式做事情,经常导致我按他们的方式做事情;多数这种时候,他们是对的。B. W. Kernighan、P. J. Plauger、S. I. Feldman、C. Imagna、M. E. Lesk 和 A. Snyder 在当前的 Yacc 版本中能发现某些他们的想法。C. B. Haley 贡献了错误修复算法。D. M. Ritchie、B. W. Kernighan 和 M. O. Harris 帮助我把本文档翻译成了英文。还要特别感谢 Al Aho,因为他把这座山和其它有趣的东西搬到了穆罕默德面前。
1. B. W. Kernighan and D. M. Ritchie, The C Programming Language, Prentice-Hall, Englewood Cliffs, New Jersey, 1978.
2. A. V. Aho and S. C. Johnson, "LR Parsing," Comp. Surveys, vol. 6, no. 2, pp. 99-124, June 1974.
3. A. V. Aho, S. C. Johnson, and J. D. Ullman, "Deterministic Parsing of Ambiguous Grammars," Comm. Assoc. Comp. Mach., vol. 18, no. 8, pp. 441-452, August 1975.
4. A. V. Aho and J. D. Ullman, Principles of Compiler Design, Addison-Wesley, Reading, Mass., 1977.
5. S. C. Johnson, "Lint, a C Program Checker," Comp. Sci. Tech. Rep. No. 65, 1978 .]. updated version TM 78-1273-3
6. S. C. Johnson, "A Portable Compiler: Theory and Practice," Proc. 5th ACM Symp. on Principles of Programming Languages, pp. 97-104, January 1978.
7. B. W. Kernighan and L. L. Cherry, "A System for Typesetting Mathematics," Comm. Assoc. Comp. Mach., vol. 18, pp. 151-157, Bell Laboratories, Murray Hill, New Jersey, March 1975 .].
8. M. E. Lesk, "Lex - A Lexical Analyzer Generator," Comp. Sci. Tech. Rep. No. 39, Bell Laboratories, Murray Hill, New Jersey, October 1975 .].
这个例子给出的是对一个小型桌面计算器的完整的 Yacc 规定;这个计算器有 26 个寄存器,标签为“a”到“z”,并接受算术表达式,允许的操作符有 +、-、*、/、%(模操作符)、&(逐位与)、|(逐位或)和赋值。如果顶层的表达式是个赋值,则不打印这个值;否则打印。同 C 语言一样,以 0(零)开始的整数假定为八进制;否则假定为十进制。
作为 Yacc 规定的一个例子,这个桌面计算器的任务是展示如何使用优先级和歧义,并演示了简单的错误修复。主要的过度简化是词法分析阶段比多数应用都要简单,并且输出是立即生成的,一行接一行。注意这里以语法规则的方式读取十进制和八进制整数;这项工作最好由词法分析器来完成。
%{ # include <stdio.h> # include <ctype.h> int regs[26]; int base; %} %start list %token DIGIT LETTER %left '|' %left '&' %left '+' '-' %left '*' '/' '%' %left UMINUS /* supplies precedence for unary minus */ %% /* beginning of rules section */ list : /* empty */ | list stat '\n' | list error '\n' { yyerrok; } ; stat : expr { printf( "%d\n", $1 ); } | LETTER '=' expr { regs[$1] = $3; } ; expr : '(' expr ')' { $$ = $2; } | expr '+' expr { $$ = $1 + $3; } | expr '-' expr { $$ = $1 - $3; } | expr '*' expr { $$ = $1 * $3; } | expr '/' expr { $$ = $1 / $3; } | expr '%' expr { $$ = $1 % $3; } | expr '&' expr { $$ = $1 & $3; } | expr '|' expr { $$ = $1 | $3; } | '-' expr %prec UMINUS { $$ = - $2; } | LETTER { $$ = regs[$1]; } | number ; number : DIGIT { $$ = $1; base = ($1==0) ? 8 : 10; } | number DIGIT { $$ = base * $1 + $2; } ; %% /* start of programs */ yylex() { /* lexical analysis routine */ /* returns LETTER for a lower case letter, yylval = 0 through 25 */ /* return DIGIT for a digit, yylval = 0 through 9 */ /* all other characters are returned immediately */ int c; while( (c=getchar()) == ' ' ) {/* skip blanks */ } /* c is now nonblank */ if( islower( c ) ) { yylval = c - 'a'; return ( LETTER ); } if( isdigit( c ) ) { yylval = c - '0'; return( DIGIT ); } return( c ); }
这个附录用 Yacc 规定来描述 Yacc 的上下文相关的输入语法。不考虑上下文依赖。作为讽刺,Yacc 输入规定语言作为 LR(2) 文法来规定是最自然的;有粘性的部分出现在规则中见到了标识符,而它紧随在一个动作后面时候。如果这个标识符跟随着一个冒号,则它是下一个规则的开始;否则它是当前规则的延续,这只发生规则中嵌入了动作的时候。在实现的时候,词法分析器在见到标识符之后做超前查看,并决定下一个记号(跳过空白、换行、注释等)是否是冒号。如果是,它返回记号 C_IDENTIFIER。否则,它返回 IDENTIFIER。文字(被引用的字符串)也返回为 IDENTIFIER,而决不担当 C_IDENTIFIER 的角色。
/* grammar for the input to Yacc */ /* basic entities */ %token IDENTIFIER /* includes identifiers and literals */ %token C_IDENTIFIER /* identifier (but not literal) followed by colon */ %token NUMBER /* [0-9]+ */ /* reserved words: %type => TYPE, %left => LEFT, etc. */ %token LEFT RIGHT NONASSOC TOKEN PREC TYPE START UNION %token MARK /* the %% mark */ %token LCURL /* the %{ mark */ %token RCURL /* the %} mark */ /* ascii character literals stand for themselves */ %start spec %% spec : defs MARK rules tail ; tail : MARK { In this action, eat up the rest of the file } | /* empty: the second MARK is optional */ ; defs : /* empty */ | defs def ; def : START IDENTIFIER | UNION { Copy union definition to output } | LCURL { Copy C code to output file } RCURL | ndefs rword tag nlist ; rword : TOKEN | LEFT | RIGHT | NONASSOC | TYPE ; tag : /* empty: union tag is optional */ | '<' IDENTIFIER '>' ; nlist : nmno | nlist nmno | nlist ',' nmno ; nmno : IDENTIFIER /* NOTE: literal illegal with %type */ | IDENTIFIER NUMBER /* NOTE: illegal with %type */ ; /* rules section */ rules : C_IDENTIFIER rbody prec | rules rule ; rule : C_IDENTIFIER rbody prec | '|' rbody prec ; rbody : /* empty */ | rbody IDENTIFIER | rbody act ; act : '{' { Copy action, translate $$, etc. } '}' ; prec : /* empty */ | PREC IDENTIFIER | PREC IDENTIFIER act | prec ';' ;
这个附录给出使用在第 10 节中讨论的高级特征的文法例子。修改附录 A 的桌面计算器的例子来提供做浮点区间算术的桌面计算器。计算器理解浮点常量、算术操作符 +、-、*、/、一元 - 和 = (赋值),并有从“a”到“z”的 26 个浮点变量。此外,它还理解区间,写为
( x , y )
这里的 x 小于等于 y。还可以使用从“A”到“Z”的 26 个区间值变量。用法类似于附录 A;赋值不返回值,并不打印东西,而表达式打印(浮点或区间)值。
这个例子探索了 Yacc 和 C 的一些有趣的特征。区间表示为由存储为双精度的左右端点值组成的结构。通过使用 typedef 给这个结构一个类型名字 INTERVAL。Yacc 值栈还可以包含浮点标量和整数(用做进入持有变量值的数组的索引)。注意全部这些策略强烈的依赖于能够用 C 语言赋值结构和联合。实际上,很多动作可以同样的调用返回结构的函数。
值得注意的还有使用 YYERROR 来处理的错误状况: 除以包含 0 的区间,和以错误次序表述的区间。在效果上,Yacc 的错误修复机制是用来丢弃那些讨厌的行的余下部分的。
除了在值栈上类型的混合,这个文法还展示了有趣的语法运用,跟踪中介表达式的类型(就是标量或区间)。注意如果上下文需要一个区间值,标量可以自动的提升为区间。这在通过 Yacc 运行这个文法的时候导致大量的冲突: 18 个移进/归约和 26 个归约/归约冲突。查看两个输入行就能见到这个问题:
2.5 + ( 3.5 - 4. )
和
2.5 + ( 3.5 , 4. )
注意在第二个例子中 2.5 要用在一个区间值表达式中,但是这个事实是不被知晓的一直到读了“,”;此时,2.5 已经完成了,而解析器不能回去并改变它的想法。更一般的,可能需要超前查看任意数目的记号来决定是否把一个标量转换成区间。通过对每个二元区间值操作符使用两个规则来避免这个问题: 在左操作数(operand)是标量时一个,在左操作数是区间值时一个。在第二种情况下,右操作数必须是一个区间,所以将自动的应用转换。尽管有了这种逃避,仍有很多情况可以应用也可以不用转换,导致上述冲突。可以通过在规定文件中首先列出生产标量的规则来解决;在这种方式下,在保持标量值表达式的标量值直到它们必须被强制成为区间的方向上解决冲突。
处理多种类型的这种方式是非常有教益的,但不是很一般性的。如果有很多种表达式类型而不是只有两种,需要的规则数目将戏剧性的增长,冲突数目会更有戏剧性。所以,尽管这个例子是有教益的,在更加正规的编程语言环境中把类型信息保持为值的一部分、而不是作为文法的一部分是更加实用的。
最后说一下词法分析。唯一不寻常的特征是浮点常量的处置。使用 C 库例程 atof 来做从字符串到双精度值的实际转换。如果词法分析器检测到一个错误,它通过返回在文法中是非法的一个记号来作为响应,激发在解析器中的一个语法错误,并接着做错误修复。
%{ # include <stdio.h> # include <ctype.h> typedef struct interval { double lo, hi; } INTERVAL; INTERVAL vmul(), vdiv(); double atof(); double dreg[ 26 ]; INTERVAL vreg[ 26 ]; %} %start lines %union { int ival; double dval; INTERVAL vval; } %token <ival> DREG VREG /* indices into dreg, vreg arrays */ %token <dval> CONST /* floating point constant */ %type <dval> dexp /* expression */ %type <vval> vexp /* interval expression */ /* precedence information about the operators */ %left '+' '-' %left '*' '/' %left UMINUS /* precedence for unary minus */ %% lines : /* empty */ | lines line ; line : dexp '\n' { printf( "%15.8f\n", $1 ); } | vexp '\n' { printf( "(%15.8f , %15.8f )\n", $1.lo, $1.hi ); } | DREG '=' dexp '\n' { dreg[$1] = $3; } | VREG '=' vexp '\n' { vreg[$1] = $3; } | error '\n' { yyerrok; } ; dexp : CONST | DREG { $$ = dreg[$1]; } | dexp '+' dexp { $$ = $1 + $3; } | dexp '-' dexp { $$ = $1 - $3; } | dexp '*' dexp { $$ = $1 * $3; } | dexp '/' dexp { $$ = $1 / $3; } | '-' dexp %prec UMINUS { $$ = - $2; } | '(' dexp ')' { $$ = $2; } ; vexp : dexp { $$.hi = $$.lo = $1; } | '(' dexp ',' dexp ')' { $$.lo = $2; $$.hi = $4; if( $$.lo > $$.hi ){ printf( "interval out of order\n" ); YYERROR; } } | VREG { $$ = vreg[$1]; } | vexp '+' vexp { $$.hi = $1.hi + $3.hi; $$.lo = $1.lo + $3.lo; } | dexp '+' vexp { $$.hi = $1 + $3.hi; $$.lo = $1 + $3.lo; } | vexp '-' vexp { $$.hi = $1.hi - $3.lo; $$.lo = $1.lo - $3.hi; } | dexp '-' vexp { $$.hi = $1 - $3.lo; $$.lo = $1 - $3.hi; } | vexp '*' vexp { $$ = vmul( $1.lo, $1.hi, $3 ); } | dexp '*' vexp { $$ = vmul( $1, $1, $3 ); } | vexp '/' vexp { if( dcheck( $3 ) ) YYERROR; $$ = vdiv( $1.lo, $1.hi, $3 ); } | dexp '/' vexp { if( dcheck( $3 ) ) YYERROR; $$ = vdiv( $1, $1, $3 ); } | '-' vexp %prec UMINUS { $$.hi = -$2.lo; $$.lo = -$2.hi; } | '(' vexp ')' { $$ = $2; } ; %% # define BSZ 50 /* buffer size for floating point numbers */ /* lexical analysis */ yylex(){ register c; while( (c=getchar()) == ' ' ){ /* skip over blanks */ } if( isupper( c ) ){ yylval.ival = c - 'A'; return( VREG ); } if( islower( c ) ){ yylval.ival = c - 'a'; return( DREG ); } if( isdigit( c ) || c=='.' ){ /* gobble up digits, points, exponents */ char buf[BSZ+1], *cp = buf; int dot = 0, exp = 0; for( ; (cp-buf)<BSZ ; ++cp,c=getchar() ){ *cp = c; if( isdigit( c ) ) continue; if( c == '.' ){ if( dot++ || exp ) return( '.' ); /* will cause syntax error */ continue; } if( c == 'e' ){ if( exp++ ) return( 'e' ); /* will cause syntax error */ continue; } /* end of number */ break; } *cp = '\0'; if( (cp-buf) >= BSZ ) printf( "constant too long: truncated\n" ); else ungetc( c, stdin ); /* push back last char read */ yylval.dval = atof( buf ); return( CONST ); } return( c ); } INTERVAL hilo( a, b, c, d ) double a, b, c, d; { /* returns the smallest interval containing a, b, c, and d */ /* used by *, / routines */ INTERVAL v; if( a>b ) { v.hi = a; v.lo = b; } else { v.hi = b; v.lo = a; } if( c>d ) { if( c>v.hi ) v.hi = c; if( d<v.lo ) v.lo = d; } else { if( d>v.hi ) v.hi = d; if( c<v.lo ) v.lo = c; } return( v ); } INTERVAL vmul( a, b, v ) double a, b; INTERVAL v; { return( hilo( a*v.hi, a*v.lo, b*v.hi, b*v.lo ) ); } dcheck( v ) INTERVAL v; { if( v.hi >= 0. && v.lo <= 0. ){ printf( "divisor interval contains 0.\n" ); return( 1 ); } return( 0 ); } INTERVAL vdiv( a, b, v ) double a, b; INTERVAL v; { return( hilo( a/v.hi, a/v.lo, b/v.hi, b/v.lo ) ); }
本附录涉及为历史连贯性而支持的同义字和特征,但处于各种原因不鼓励使用。
1. 文字也可以用双引号“"”定界。
2. 文字也可以多于一个字符长。如果所有字符都是字母、数字、或 _,则定义文字的类型编号,如同文字没有包围它的引号一样。否则,难于找到这种文字的值。
多字符文字的使用误导了不熟悉 Yacc 的人,因为它暗示了 Yacc 正在做实际上词法分析器做的工作。
3. 在 % 合法的多数位置,反斜杠“\”也可以用。特别是,\\ 同于 %%,\left 同于 %left 等等。
4. 还有一些其他的同义字:
%< 同于 %left
%> 同于 %right
%binary 和 %2 同于 %nonassoc
%0 和 %term 同于 %token
%= 同于 %prec5. 动作也可以有如下形式
={ . . . }并且如果动作是一个单一的 C 语句则花括号也可以去除掉。6. 在 %{ 和 %} 之间的 C 代码惯常被允许在规则段的头部出现,同在声明段中一样。