FLEX
什么是FLEX?它是一个自动化工具,可以按照定义好的规则自动生成一个C函数yylex(),也成为扫描器(Scanner)。这个C函数把文本串作为输入,按照定义好的规则分析文本串中的字符,找到符合规则的一些字符序列后,就执行在规则中定义好的动作(Action)。例如在规则中可以这样定义:如果遇到一个换行字符\n,那么就把行计数器的值加一。
Flex文件就是一个文本文件,内容包括定义好的一系列词法规则。文件的命名习惯上以小写字母l(L)来作为文件后缀。如果为了清晰,也可以用.flx或者.flex作为文件的后缀名。Flex文件完成后,就执行下列命令:
$ flex example.flex
这个命令执行后将生成一个C文件,默认文件名为lex.yy.c。这个C文件主要内容就是函数yylex()的定义。
如果要直接将这个文件编译成为一个可执行程序,还有一些要注意的地方。如果在Flex文件中没有提供main()函数的定义,那么这个C文件中不会有main()函数。此时单独编译这个C文件的时候,一定要加上-lfl的连接库参数;若提供了main()函数,就不必要提供这个连接库参数了。连接库libfl提供了一个缺省的main函数。缺省的main()函数中只是简单地调用yyflex()函数,而自己提供的main()函数则可以根据需要加入许多其他的处理代码。
Flex文件
词法规范定义文件给出了单词构成规则。词法文件在习惯上用字母l(即L的小写)来作为后缀。Flex文件由三个部分组成。或者说三个段。三个段之间用两个%%分隔。
定义段(definitions)
%%
规则段(rules)
%%
用户代码段(user code)
定义段(definitions section)
定义段包含着一些简单名字的定义(name definitions),旨在简化扫描器的规范。定义名字的方法如下:
name definition
名字可以由字母或下划线开头,后跟零个或多个字母、数字、下划线、或短横线。名字的定义则从其后的第一个非空白字符(non-white-space)开始直到行尾。下面是一个例子,定义了一个名字DIGIT,其定义就是指一个数字,如下所示:
DIGIT [0-9]
当在后面引用这个名字时,用一对花括号({})括住该名字即可。它会被展开成一对圆括号括住的该名字的定义,即:
{name} 展开成 (definition)
例如:
{DIGIT}+"."{DIGIT}*
就等价于:
([0-9])+"."([0-9])*
定义段中还可以加入启动条件(start conditions)的声明。顾名思义,启动条件就如同C语言中的条件编译一样,根据指定的启动条件去激活一条规则,并用这条规则去匹配读入的字符。关于启动条件,后面还有更详细的介绍。
规则段(rules section)
规则由模式(pattern)和动作(action)两个部分组成。模式就是一个正则表达式,FLEX加入了一些自己的扩展。而动作一般就是一些C语句。模式指出了一个单词是如何构成的,当分析出一个符合该规则的单词时,就执行相应的动作。
模式一定要位于一行的开头处,不能有缩进。而动作的开头一定要与模式在同一行。当动作是用一对花括号{}括起来时,可以将左花括号放在与规则相同的行,而其余部分则可以从下一行开始。
用户代码段(user code)
所有用户代码都被原样拷贝到文件lex.yy.c中。在这里可以定义一些辅助函数或代码,供扫描器yylex()调用,或者调用扫描器(一般来说就是main()了)。这一部分是可有可无的。如果没有的话,Flex文件中第二个%%是可以省略的。
在定义段或者规则段中,任何一行有缩进的文本或者包含在一对%{和%}之间的文本,都被原样拷贝到最后生成的C代码文件中(当然%{和%}会被移走)。在书写时%{和%}都必须在一行的开始处,不能缩进。
在规则段中,第一条规则之前的任何未缩进的文本或者在%{和%}之间的文本,可以用来为扫描器声明一些本地变量和代码。一旦进入扫描器的代码,这些代码就会被执行。规则段内其他的缩进的文本或者%{和%}之间的文本还是被原样拷贝输出,但是他们的含义是尚未有明确定义,很可能引起编译时(compile-time)错误(这一特性是为了与POSIX兼容而提供的)。
在定义段中,没有缩进的注释也会被原样拷贝到最后生成的C代码文件中,例如以/*开始的一行注释,直到遇到*/,这中间的文本会被原样拷贝输出。
模式及其分类
模式采用正则表达式来书写。正则表达式大致可以分为如下几类(从上到下,优先级依次递减):
(1)单字符匹配
* ‘x’ 匹配字符x。
* ‘.’ 匹配任意一个字符(字节),除了换行符。
* ‘[xyz]’ 匹配单个字符,这个字符是方括号中给出的字符类(character class)中的一个。
* ‘[abj-oZ]’ 匹配单个字符,这个字符是方括号中给出的字符类中的一个。与上一方式的区别是指定字符类时用到了一个范围表示法:j-o,这表示按照26个英文字母的顺序,从字母j开始一直到字母o共6个字母。这里减号(-)表示范围。如果减号本身也要作为一个匹配字符时,最好用转义字符(\)去除其特殊含义。由于花括号({})在模式中用来引用名字,以及作为模式定义之后的动作(Action)定义块的首尾界定符,因此如果要在字符类中匹配花括号,必须用转义字符(\)去除其特殊含义。下面这个例子定义了一个所有可打印字符的字符类:
[[:alnum:][:blank:]]\t+\-*/&!_'?@^`~$\\()%|.;[\]\{\}:,#<>=]
* ‘[^A-Z]’ 匹配单个字符,这个字符必须是方括号中给定字符类以外的字符。在方括号内开始处的特殊符号(^)表示否定。当字符^不在字符类的开始处时,并不具有特殊含义,而是一个普通字符。
* ‘[^A-Z\n]’ 匹配单个字符,这个字符不可以是方括号中给出的字符类中的字符。与上一方式的不同在于,这里多了一个换行符,也就是说所匹配的字符不能是26个大写字母,也不能是换行符。
根据上面的描述,在表达字符分类时,除了直接用字符以及字符范围来表达外,还有一种叫做字符类表达式的,也有同样的作用,常见的一些表达式如下:
[:alnum:] [:alpha:] [:blank:] [:cntrl:] [:digit:] [:graph:]
[:lower:] [:print:] [:punct:] [:space:] [:upper:] [:xdigit:]
每一个表达式都指示了一个字符分类,而且其名称与标准C函数isXXXX的名字对应。例如,[:alnum:]就指示了那些经由函数isalnum()检查后返回true的字符,也就是任何的字母或者数字。注意,有些系统上没有给出C函数isblank()的定义,所以flex自己定义了[:blank:]为一个空格或者一个tab。
下面所举的几个例子,都是等价的:
[[:alnum:]]
[[:alpha:][:digit:]]
[[:alpha:]0-9]
[a-zA-Z0-9]
应该注意字符类表达式的写法。一个字符类表达式是由一对[:和:]包住的,作为一个整体,在书写时不可与外层的[]混淆。
(2)重复模式的匹配
* ‘r*’ r是一个正则表达式,特殊字符`*'表示0个或多个。因此这个模式表示匹配0个或多个r。
* ‘r+’ r是一个正则表达式,特殊字符`+'表示1个或多个。因此这个模式表示匹配1个或多个r。
* ‘r?’ r是一个正则表达式,特殊字符`?'表示0个或1个。因此这个模式表示匹配0个或1个r。(从另一个角度看,就是说模式r是可选的)
* ‘r{2,5}’ r是一个正则表达式,{2,5}表示2个到5个。因此这个模式表示匹配2个到5个r。也就是说可以匹配`rr',`rrr',`rrrr',`rrrrr'四种重复的模式。
* ‘r{2,}’ r是一个正则表达式,{2,}省略了第二个数字,表示至少2个,不设上限。因此这个模式表示匹配2个及以上个r。也就是说至少可以匹配`rr',还可以匹配`rrr',`rrrr'等无限多种重复的模式。
* ‘r{4}’ r是一个正则表达式,{4}只有一个数字,表示4个。因此这个模式确切地匹配4个r,即`rrrr'。
(3)名字替换
* ‘{name}’ 这里name就是在前面的定义段给出的名字。这个模式将用这个名字的定义来匹配。
(4)平凡(plain)文本串的匹配
* ‘“[xyz]\″foo”’ 这个模式用来确切地匹配文本串:[xyz]\″foo。注意最外层的单引号所包含的是整个模式表达式,也就是说,当希望匹配字串[xyz]\″foo时,在书写规则时该字串必须用双引号括住。
(5)特殊单字符的匹配
* ‘\x’ 当x是一个`a',`b',`f',`n',`r',`t'或`v'时,它就解释为ANSI-C中的\x。否则就仍然作为一个普通字符x(一般用于诸如`*'字符的转义字符)。
* ‘\0’ 匹配一个NUL字符(ASCII码值为0)。
* ‘\123’ 匹配一个字符,其值用八进制表示为123。
* ‘\x2a’ 匹配一个字符,其值用十六进制表示为2a。
(6)组合模式的匹配
* ‘(r)’ 匹配规则表达式r,圆括号可以提高其优先级。
* ‘rs’ 匹配规则表达式r,其后紧跟着表达式s。这称为联接(concatenation)。
* ‘r|s’ 或者匹配规则表达式r,或者匹配表达式s。
* ‘r/s’ 匹配模式r,但是要求其后紧跟着模式s。当需要判断本次匹配是否为“最长匹配(longest match)时,模式s匹配的文本也会被包括进来,但完成判断后开始执行对应的动作(action)之前,这些与模式s相配的文本会被返还给输入。所以动作(action)只能看到模式r匹配到的文本。这种模式类型叫做尾部上下文(trailing context)。(有些‘r/s’组合是flex不能识别的;请参看后面deficiencies/bugs一节中的dangerous trailing context的内容。)
* ‘^r’ 匹配模式r,但是这个模式只出现在一行的开始处。也就是说,刚开始扫描时遇到的,或者说在刚扫描完一个换行字符后紧接着遇到的。
* ‘r$’ 匹配模式r,但是这个模式只在一行的尾部。也就是说,该模式就出现在换行之前。这个模式等价于r/\n。注意,flex中的换行(newline)的概念,就是C编译器中所使用的\n,flex也采用同样的符号和解释。在DOS系统中,可能必须由你自己滤除输入中的\r,或者明确地在模式中写成r/\r\n来代替r$。(在unix系统中换行是用一个字节 \n 表示的,而DOS/Windows则采用两个字节 \r\n来表示换行。)
(7)有启动条件(Start Condition)的模式匹配
* ‘<s>r’ 匹配模式r,但需要启动条件s(后面后关于启动条件的讨论)。模式‘<s1,s2,s3>r’是类似的,匹配模式r,只要有三个启动条件s1,s2,s3中的任一个即可。(启动条件简单来说,类似于C语言中的条件编译,满足了某个条件才启动这个模式参与匹配,否则不会启动该模式参与匹配。)
* ‘<*>r’ 匹配模式r,在任何启动条件下都参与匹配,即使是排斥性的条件。
[上述还需要从实践中体会其含义]
(8)文件尾匹配
* ‘<<EOF>>’ 匹配文件尾,即遇到了文件尾部。一般说来,都应该在模式中加入文件尾模式。这样可以有机会在文件扫描完成时增加一些额外的处理。
* ‘<s1,s2><<EOF>>’ 在有启动条件s1或者s2的情况下,匹配文件尾部。
一些常见规则的编写(待续)
(1)双引号字符串。
[\"]({SAFECHAR}|{RESTCHAR}|[_])*[\"]
这里需要注意的地方是中间的重复模式的写法:(r)*。r可以是一个组合模式。中间的两个名称SAFECHAR和RESTCHAR是在定义段给出的两个字符类。
[此处应在实用中不断添加]
=========================================
创建一个简单的扫描器
下列例子来自于Flex的手册。并在Windows+Cygwin+bison+flex+gcc的环境下编译运行。
(1) 编辑Flex语法文件。
/* name: example.flex */
int num_lines = 0, num_chars = 0;
%%
\n ++num_lines; ++num_chars;
. ++num_chars;
%%
int main()
{
yylex();
printf("# of lines = %d, # of chars = %d\n", num_lines, num_chars);
return 0;
}
(2) 生成扫描器的C文件。
$ flex example.flex
The output is lex.yy.c
(3) 编译生成的C文件。
编译时失败,出现了如下的问题:
# gcc -g -Wall -lfl -o scan lex.yy.c
lex.yy.c:959: warning: 'yyunput' defined but not used
/cygdrive/c/DOCUME~1/ADMINI~1.78B/LOCALS~1/Temp/ccHwCWNb.o: In function `main':
/cygdrive/c/home/sandbox/flex_exam_1/example.l:9: multiple definition of `_main'
/usr/lib/gcc/i686-pc-cygwin/3.4.4/../../../libfl.a(libmain.o):(.text+0x0): first defined here
/cygdrive/c/DOCUME~1/ADMINI~1.78B/LOCALS~1/Temp/ccHwCWNb.o: In function `yylex':
/cygdrive/c/home/sandbox/flex_exam_1/lex.yy.c:692: undefined reference to `_yywrap'
/cygdrive/c/DOCUME~1/ADMINI~1.78B/LOCALS~1/Temp/ccHwCWNb.o: In function `input':
/cygdrive/c/home/sandbox/flex_exam_1/lex.yy.c:1041: undefined reference to `_yywrap'
collect2: ld returned 1 exit status
上述消息指出两个问题:
(1)函数yywrap没有定义。
(2)自定义函数main与连接库fl中的定义冲突了。
第一个问题的解决办法是在第一段(定义段)中加上一个选项指令:
%option noyywrap
第二个问题的解决办法就是用gcc编译时不连接fl库,如下所示:
# flex example.flex
# ls
example.flex lex.yy.c
# gcc -g -Wall -o scan lex.yy.c
lex.yy.c:977: warning: 'yyunput' defined but not used
# ls
example.flex lex.yy.c scan.exe
# ./scan.exe
789
234
345# of lines = 2, # of chars = 11
修改过的代码如下:
%option noyywrap <==== 防止出现yywrap的问题
%{
int num_lines = 0, num_chars = 0;
%}
%%
\n ++num_lines; ++num_chars;
. ++num_chars;
%%
int main()
{
yylex();
printf("# of lines = %d, # of chars = %d\n",
num_lines, num_chars);
return 0;
}
更改扫描器yylex()的名字
我们还可以更改Flex自动生成的词法分析函数yylex()的名字、参数以及返回值,也就是说yylex这个名字仅仅是一个默认的名称,是可以改成其他名称的。方法很简单,只需要对宏YY_DECL做一个重定义即可:
#define YY_DECL float lexscan (float a, float b)
上述的宏定义就表明:当运行Flex生成C代码时,词法分析函数的名字叫做lexscan(不再是yylex了),有两个浮点型参数a和b,函数的返回值是浮点型。
如果与Bison联用的话,还是不要更改的好,因为Bison要求词法分析函数的名称是yylex。[应该也是可以改的,但其实际的方法还需在实践中得来。]
词法分析函数yylex()会使用全局变量yyin读取字符。
一些思考
(1)在H248协议的BNF文本中,需要分析很多的数字,有十六进制的,有十进制的,有长的数字也有短的数字。虽然在H248协议看来,各种不同的数字有着不同的意义,但是在Flex词法扫描器看来,它们有什么不同呢?特别是同样的一个0xab这样的只有两位数字的十六进制数,在H248协议和BISON看来,其有不同的含义和类型,但是在Flex看来却没有什么不同。假设Bison分别将其定义为Token_A和Token_B,那么当Flex分析出这么一个单词时,返回给Bison的数字类型是A还是B?
(2)在H248协议中,有一种表达式是由多个参数组成的,其中每个参数至多出现一次,且参数间次序是任意的。此外其中有两个参数是必须的。这种情况下如何给出Bison文法规则定义呢?
文法分析概览
利用BNF写出的文法规则,可以用来对输入的文本进行文法分析。一条BNF文法规则,左边是一个非终结符(Symbol或者non-terminal),右边则定义该非终结符是如何构成的,也称为产生式(Production),产生式中可能包含非终结符,也可能包含终结符(terminal),也可能二者都有。在所有文法规则中,必有一个开始的规则,该规则左边的部分叫做开始符号(start symbol)。一个规则的写法如下:
Symbol := Production
下面是一个BNF文法定义的例子。FN是fractional number的意思,DL是digit list的意思,S是start symbol。
S := '-' FN | FN
FN := DL | DL '.' DL
DL := D | D DL
D := '0' | '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9'
一个非终结符可能有多个产生式,相互间用竖线(|)隔开。
每一条BNF产生式,都有自己的启动集(start set)。启动集里的元素就是每个Production中的第一个部分,比如上例S规则的启动集就是{'-'}以及{FN}。
利用BNF文法来分析目标文本,其分析方法比较流行的有几种,下面作一概述[Garshol 03]。
LL(k)分析
LL分析又称为自顶向下的分析(top-down parsing),也有叫递归下降分析(recursive-descent parsing)。也是最简单的一种分析方式。它工作的方式类似于找出一个产生式可以从哪一个终结符开始。
当分析时,从起始符号开始,比较输入中的第一个终结符和启动集,看哪一个产生式规则被使用了。当然,两个启动集之间不能拥有同一个终结符。如果有的话,就没有办法决定选择哪个产生式规则了。
Ll文法通常用数字来分类,比如LL(1),LL(0)等。这个数字告诉你,在一个文法规则中的任何点可以允许一次察看的终结符的最大数量。LL(0)就不需要看任何终结符,分析器总是可以选择正确的产生式规则。它只适用于所有的非终结符都只有一个产生规则。只有一个产生规则意味着只有一个字符串。[不用看当前的终结符是什么就可以决定是哪一个产生规则,说明这个规则是为一个固定的字符串所写的。]这种文法是没有什么意义的。
最常见也是比较有用的事LL(1)文法。它只需要看一个终结符,然后就可以决定使用哪一个产生规则。而LL(2)则可以查看两个终结符,还有LL(k)文法等等。对于某个固定的k值,也存在着根本不是LL(k)的文法,而且还很普遍。
下面来分析一下本章开头给出的例子。首先看下面这条规则:
D := '0' | '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9'
上述规则有十个产生式,每个产生式的启动集是一个数字终结符构成的集合{'0'}、{'1'}、……、{'9'}。这是一个很好的LL(1)文法,因为我们只要看一个终结符,就可以选择一个正确的产生式。例如,如果看到一个终结符,其内容是3,那么就采用上面第四个产生式,即D := '3'。
接下来分析DL规则。
DL := D | D DL
上述规则有两个产生式,启动集是{D},{D}。很不幸,两个产生式的启动集相同。这就表示只看第一个输入中的第一个终结符不能选择正确的产生式。
然而可以通过欺骗来绕过这个问题:如果输入中第二个终结符不是一个数字,那么就选择第一个产生式,但如果两者都是数字就必须选择第二个产生式。换句话说,这意味着这是一条好的LL(2)文法规则。实际上这里有些东西被简化了。
再分析下FN规则吧。它的情况更糟糕。
FN := DL | DL '.' DL
它有两条产生式,而且启动集相同,均为{DL}。然而这次不像DL规则那么幸运了。咋一看,似乎通过LL(2)可以分辨应该使用哪一个产生式。但是很不幸,我们无法确定在读到终结符('.')之前,需要读多少个数字才算是DL符号的最后一个数字。[想想吧,分析器这么工作着:读入第一个终结符,一看是相同的DL符号,那么就读第二个终结符吧;读入第二个终结符,两者合起来一看,还是一样的DL符号;读入第三个终结符,前三个终结符合起来看,仍然是相同的DL符号。但是DL符号表指示数字表示没有长度限制的。]没有任何一个给定的k值,这都不符合LL(k)文法,因为数字表总能突破这个k的长度。
最后看看启动符号规则。有点意外,它产生规则的选择很简单。
S := '-' FN | FN
它有两个产生规则,两者的启动集是{'-'}和{FN}。因此,如果输入中第一个终结符是'-',那么就选择第一个产生式,否则选择第二个产生式。所以这是一个LL(1)文法。
从上述的LL分析看,只有FN和DL规则引起了问题。但是不必绝望。大部分的非LL(k)文法都可以容易地转换为LL(1)文法。下面以当前的这个例子来看看如何转换有问题的FN和DL。
对于FN符号来说,它的两个产生式都开始于DL,但是第二个产生式其后续的是一个小数点终结符('.'),以及另外一个数字表。那么这很容易解决:可以将FN改变为一个产生式,其以DL开始,后跟一个FP(fractional part)符号。而FP符号则定义成或者为空,或者为小数点后跟着一个数字表,如下所示:
FN := DL FP
FP := @ | '.' DL
上述@符号表示为空。现在FN文法没有任何问题了,因为它现在只有一个产生式。而FP也不会有问题,因为它的两个产生式的启动集是不同的:前者是输入的尾端,后者是小数点终结符。
DL符号就不是好啃的核桃了,原因在于其递归和至少需要一个D符号。解决方案就是,给DL一个产生式,由一个D后跟一个DR(digit rest)构成;而DR则有两个产生式,一个是D DR(表示更多的数字),一个是@(表示没有更多的数字)。最后本章开头的例子被转换成下面的一个完全的LL(1)文法了:
S := '-' FN | FN
FN := DL FP
FP := @ | '.' DL
DL := D DR
DR := @ | D DR
D := '0' | '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9'
LR分析
Lr分析也叫自底向上的分析(bottom-up parsing),或者叫移进-归约分析(shift-reduce parsing),相比LL分析难度更大些。它的基本原理是,首先收集输入,直到它发现可以据此利用一个符号对收集到的输入序列进行归约。可以与数学里面解方程式时的消元法进行类比。这听起来似乎很难。下面还是以一个例子来澄清。例子中将分析字符串3.14,看看是怎样从文法产生出来的。
S := '-' FN | FN
FN := DL | DL '.' DL
DL := D | D DL
D := '0' | '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9'
首先从输入中读入3。
3
然后看看是否可以将其归约为一个符号(Symbol,即非终结符)。实际上可以归约,就是说用D符号的产生式可以得到当前读入的字符串(这也是成为产生式的原因)。
很快发现,从DL符号可以产生符号D,于是又可以归约成DL。(实际上还可以进一步地归约成FN,于是这里就产生了歧义,到底应该归约成哪一个呢?这表明这个文法定义是二义性的,我们在这里就忽略这个问题,直接选择DL作为归约结果吧。)接着从输入中读入一个小数点,并试图进行归约:
D ==> 规约到DL
DL ==> 读入下一个字符
DL . ==> 规约到?
但是这次的归约尝试失败了,因为没有任何符号的定义可以产生这种形式的字符串。也就是说,这种形式不能规约到任何符号。
所以接着我们读入下一个字符1。这次可以将数字1归约到D符号。接着再读入一个字符4。4可以归约到D,继续归约到DL。这两次的读入和规约形成了D Dl这个序列,而这个序列可以归约到DL。
DL . ==> 读入下一个字符1
DL . 1 ==> 1归约到D
DL . D ==> 读入下一个字符4
DL . D 4 ==> 4归约到D
DL . D D ==> 4继续归约到DL
DL . D DL ==> D DL 归约到DL
察看文法我们可以很快地注意到,FN能产生DL . Dl这种形式的序列,所以可以做一个归约。然后注意到FN可以从S符号产生,所以可以归约到S,然后停止,整个分析结束。
DL . DL ==> 归约到FN
FN ==> 规约到S
S ==> 分析结束
可能你已经注意到,我们经常可以选择是否现在就做归约,还是等到读入更多的符号后再作不同的归约。移进-归约分析算法有很多不同的变种,按照复杂度和能力递增的顺序是:LR(0), SLR, LALR和LR(1)。LR(1)通常需要一个巨大的分析表,在实践上不具有实用性,因此LALR是最常使用的算法。SLR和LR(0)对于大部分的程序语言来说还不够强大。
Bison分析器的算法1
Bison适合上下文无关文法(Context-free grammar),并采用LALR(1)算法[Donnelly 06]的文法。
当bison读入一个终结符(token),它会将该终结符及其语意值一起压入堆栈。这个堆栈叫做分析器堆栈(parser stack)。把一个token压入堆栈通常叫做移进(shifting)。
例如,假设一个中缀计算器已经读入'1 + 5 * ',下一个准备读入的是'3',那么这个栈里就有四个元素,每个元素都是移进的一个终结符。
但堆栈并不是每读入一个终结符就分配一个栈元素给它。当已经移进的后n个终结符和组(groupings)与一个文法规则相匹配时,它们会被根据那个规则结合起来。这叫做归约(reduction)。栈中的那些终结符和组会被单个的组(grouping)替换。那个组的符号就是那个规则的结果。执行该规则的相应的动作(Action)也是归约处理的一部分,这个动作会计算这个组的语意值。
例如,如果中缀计算器的分析器堆栈包含:1 + 5 * 3,并且下一个输入字符是换行符,那么上述后3个元素可以按照下面规则归约到15:
expr: expr '*' expr;
于是堆栈中就只包含下面三个元素了:1 + 15。此刻,另一个规约也可以执行,其结果是一个单值16。然后这个新行终结符就可以被移进了。
分析器通过移进和归约尝试着缩减整个输入到单个的组。这个组的符号就是文法中的起始符号(start-symbol)。
终结符预读
Bison分析器并不总是在后n个终结符与组匹配某一规则时立即就进行归约。这种策略对于大部分语言来说并不合适。相反,当可以进行归约时,分析器有时会“预读”(looks ahead)下一个终结符来决定做什么。
当一个终结符被读进来后,并不会立即移进堆栈,而是首先作为一个预读终结符(look-ahead token)。此后,分析器开始对栈上的终结符和组执行一个或多个归约,而预读终结符仍然放在一边。当没有归约可做时,这个预读终结符才会被移进堆栈。这并不表示所有可能的归约都已经做了,这要取决于预读终结符的类型,一些规则可能选择推迟它们的使用。
下面研究一个需要做预读的案例。这里的三条规则定义了一个表达式,可以包含二元的加法运算符和一元的后缀阶乘运算符('!'),并且允许用括号进行分组。
expr: term '+' expr
| term
;
term: '(' expr ')'
| term '!'
| NUMBER
;
假定终结符'1' '+' '2'已经读入并移进堆栈,那么接下来应该做什么呢?如果接下来的终结符是')',那么前三个终结符必须归约成一个expr。这是唯一的合法情况,因为移进')'将会产生一个序列term ')',而没有任何规则允许出现这种情况。[不做归约移进')',堆栈上的元素序列是1 + 2 ),2可以归约成NUMBER,进而归约成term,与其后的 ')'形成term ')'的序列,检查所有规则发现没有任何规则定义了这种序列。]
如果下一个终结符是'!'[记住此刻它还是预读终结符],那么该终结符必须立即移进堆栈以便'2 !'可以归约成一个term。如果相反地分析器在移进这个阶乘符号之前进行归约,那么'1 + 2'就会归约成expr。这将导致不可能移进'!'终结符,因为这样的话将会产生一个expr '!'序列。同样没有任何规则定义了这种序列。
预读终结符存储在变量yychar中。它的语意值和位置,如果有的话,存储在变量yylval和yylloc中。
移进-归约冲突
假定我们正在分析一个语言,其中有if-then和if-then-else语句,对应的规则如下:
if_stmt: IF expr THEN stmt
| IF expr THEN stmt ELSE stmt
;
这里我们假设IF,THEN和ELSE是特别的关键字终结符。
当ELSE终结符读入后作为一个预读终结符时,堆栈中的内容(假设输入是合法的)正好可以归约到第一条规则上。但是把它移进堆栈也是合理的,因为那样根据第二条规则就会导致最后的归约。
在这种情况下,移进或者归约都是合法的,称为移进-归约冲突(shift-reduce conflict)。Bison的设计是,用移进来解决冲突,除非有操作符优先级声明的指令。为了解释如此选择的理由,让我们与其它可选办法进行一个比较。
既然分析器更倾向移进ELSE,那么其结果是把else子句连接到最内层的if语句,从而使得下面两种输入是等价的:
if x then if y then win (); else lose;
if x then do; if y then win (); else lose; end;
如果分析器选择归约而不是移进,那么其结果将是把else子句连接到最外层的if语句,从而导致下面两个输入是等价的:
if x then if y then win (); else lose;
if x then do; if y then win (); end; else lose;
冲突的存在是因为文法有二义性:简单的嵌套的if语句的任一种解析都是合理的。已有的惯例是这种二义性的解决是通过把else子句连接到最内层的if语句而获得的;Bison是选择移进而不是归约来实现的。(一种更清晰的做法是写出无二义性的文法,但对于这种情况来说是非常困难的。)这种特殊的二义性首次出现在Algol 60的规范中,被称作'dangling else ambiguity'。
对于可预见的合法的移进-归约冲突,为避免bison发出的警告,可以使用%expect n声明。那么只要移进-规约冲突的数量为n,就不会有警告产生。
操作符优先级
可能出现移进-归约冲突的其它地方还有算术表达式。此时移进就不总是更好的解决办法了。Bison通过声明操作符的优先级来指定何时移进何时归约。
何时需要优先级
考虑下面的二义文法片断(其二义性体现在'1 – 2 * 3'可以用两种不同的方式进行分析):
expr: expr '-' expr
| expr '*' expr
| expr '<' expr
| '(' expr ')'
...
;
假定分析器已经看到了终结符'1','-'和'2';那么应该对它们归约到减法运算规则吗?这取决于下一个终结符。当然,若下一个终结符是')',就必须归约;此时移进是非法的,因为没有任何规则可以对序列'- 2 )'进行归约,也没有以这个序列开始的什么东西。但是如果下一个终结符是'*'或者'<',那么就需要做一个选择:移进或者归约,都可以让分析得以完成,但是却有不同的结果。
为了决定Bison应该怎么做,必须考虑这两个结果。若下一个终结符即操作符op被移进,那么必然是op首先做归约,然后才有机会让前面的减法操作符做归约。其结果就是(有效的)'1 – (2 op 3)'。另一方面,若在移进op之前先对减法做归约,那结果就是'(1 – 2) op 3'。很显然,这里移进或者规约的选择取决于减法操作符'-'与下一个操作符op之间的优先级:若op是乘法操作符'*',那么就选择移进;若是关系运算符'<'则应该选择规约。
那么诸如'1 – 2 – 5'这样的输入又如何呢?是应该作为'(1 – 2) – 5' 还是应该作为'1 – (2 – 5)' ?对于大多数的操作符,我们倾向于前一种形式,称作左关联(left association)。后一种形式称作右关联(right association),对于赋值操作符来说是比较理想的。当堆栈中已经有'1 – 2' 且预读终结符是'-',此时分析器选择移进还是归约与选择左关联还是右关联是一回事:移进将会进行右关联。
指定操作符优先级
Bison允许通过声明%left和%right来指定操作符优先级。每个这样的声明都包含一列终结符,这些终结符都是操作符,它们的优先级和关联性都被声明了。%left声明让所有这些操作符左关联,而%right声明让它们右关联。第三种方案是%noassoc,它声明了这是一个语法错误,表明“在一行中”找到了两个同样的操作符。
不同操作符的优先级由它们的声明次序来决定。先声明的优先级低,后声明的优先级高。[如果有同等优先级的呢?应该是按照其关联性来决定了是移进还是规约。]
优先级例子
在本节给出的例子中,我们希望有如下的声明:
%left '<'
%left '-'
%left '*'
在更复杂的例子中有更多的操作符,同等优先级的操作符可以分成一组进行声明,如下所示:
%left '<' '>' '=' NE LE GE
%left '+' '-'
%left '*' '/'
这里NE代表not equal(不等于),LE表示小于等于,GE表示大于等于。
优先级如何工作
优先级声明的第一个效果就是赋予了终结符不同的优先级水平。第二个效果就是给某些规则赋予了优先级水平:每个规则从它的最后的终结符得到其优先级。[当已读入的终结符和组符合某个规则时,理论上讲它可以进行归约。它最后的一个终结符可能被指定了优先级,这个优先级就成为该规则的优先级。]
最终,冲突的解决是通过比较规则的优先级与它的预读终结符的优先级实现的。若该终结符的优先级高,那么就采用移进。过规则的优先级较高,那么就选择归约。若它们具有相同的优先级,那么就基于该优先级的关联性来作出选择。选项'-v'可以让Bison产生详细的输出,其中有冲突是怎样解决的信息。
并非所有的规则和终结符都具有优先级。若规则或预读终结符都没有优先级,那么缺省采用移进[解决冲突]。
与上下文相关的优先级
经常有操作符的优先级依靠上下文。起初这听起来有些奇怪(outlandish),但这的确非常普通。例如,典型地一个减号作为一元操作符有非常高的优先级,而作为二元操作符则具有较低的优先级(比乘法低)。
对于给定的终结符,声明%left,%right和%noassoc只能使用一次,所以这种方式下一个终结符只有一个优先级。对于与上下文相关的优先级,需要一个新增的机制:用于规则的%prec修饰符。
%prec修饰符声明了某个规则的优先级,通过指定某个终结符而该终结符的优先级将用于该规则。没有必要在该规则出现这个终结符。[就是说这个终结符可以是臆造的,在系统中可能并没有实际的对应体,只是为了用于指定该规则的优先级]。下面是优先级的语法:
%prec terminal-symbol
并且这个声明必须写在该规则的后面[看下面的例子]。这个声明的效果就是把该终结符所具有的优先级赋予该规则,而这个优先级将会覆盖在普通方式下推断出来的该规则的优先级。这个更改过的规则优先级会影响规则如何解决冲突。
下面就是解决一元的负号的问题。首先,定义一个名为UMINUS的虚构的终结符,并为之声明一个优先级。实际上并没有这种类型的终结符,但是这个终结符仅仅为其的优先级服务。
...
%left '+' '-'
%left '*'
%left UMINUS
现在UMINUS的优先级可如此地用于规则:
exp: ...
| expr '-' exp
...
| '-' exp %prec UMINUS
分析器的状态
函数yyparse用一个有限状态机(finite-state)实现。压入分析器堆栈的值并不是简单地终结符类型码。它们代表靠近堆栈顶部的整个的终结符和非终结符的序列。当前状态收集关于前一个输入的所有信息,而这个输入与决定下一步作什么有关。
每次预读入一个终结符后,分析器当前状态与预读终结符的类型一起,到表中查找。对应的表项可能是:移进这个预读终结符。这种情况下,它也会指定新的分析器状态,并被压入到分析器栈的顶部。或者这个表项可能是:用规则n进行归约。这就意味着一定数量的终结符或组会被从堆栈顶部取走,并用一个组取代。换句话说,那些数量的状态被从堆栈弹出,一个新的状态被压栈。
另外一个可能是:这个表项会告诉说,这个预读终结符对当前状态来说是错误的。这将导致开始一个错误处理。
归约-归约冲突
归约-归约冲突(reduce-reduce conflict)发生在有两个或以上的规则适用于同一个输入序列时。这通常表明了一个严重的文法错误。
例如,这里有一个错误的尝试,试图定义一个具有0个或多个单词(word)的组:
sequence: /* empty */ { printf (“empty sequence\n”); }
| maybeword
| sequence word { printf (“added word %s\n”, $2); }
;
maybeword: /* empty */ { printf (“empty maybeword\n”); }
| word { printf (“single word %s\n”, $1); }
;
[待续]
BISON
==Bison 语法文件内容的布局==
Bison 工具将把 Bison 语法文件作为输入。语法文件的扩展名为.y。Bison 语法文件内容的分布如下(四个部分):
%{
序言
%}
Bison 声明
%%
语法规则
%%
结尾
序言部分可定义 actions 中的C代码要用到的类型和变量,定义宏,用 #include 包含头文件等等。要在此处声明词法分析器 yylex 和错误输出器 yyerror 。还在此处定义其他 actions 中使用到的全局标识符。
Bison声明部分可以声明终结符和非终结符的名字,也可以描述操作符的优先级,以及各种符号的值语义的数据类型。各种非单个字符的记号(节点)都必须在此声明。
语法规则部分描述了如何用组件构造出一个非终结符。(这里我们用术语组件来表示一条规则中的各个组成部分。)
结尾部分可以包含你希望使用的任何的代码。通常在序言部分声明的函数就在此处定义。在简单程序中所有其余部分都可以在此处定义。
=例子一=
本例子完整实现一个采用逆波兰式语法的计算器。
==语法文件==
语法文件rpcalc.y的内容如下:
第一部分:序言和声明
/* Reverse polish notation calculator. */
%{
#define YYSTYPE double
#include <stdio.h>
#include <stdlib.h>
#include <math.h>
int yylex (void);
void yyerror (char const *);
%}
%token NUM
%% /* Grammar rules and actions follow. */
第二部分:语法规则部分
input: /* empty */
| input line
;
line: ’\n’
| exp ’\n’ { printf ("\t%.10g\n", $1); }
;
exp: NUM { $$ = $1; }
| exp exp ’+’ { $$ = $1 + $2; }
| exp exp ’-’ { $$ = $1 - $2; }
| exp exp ’*’ { $$ = $1 * $2; }
| exp exp ’/’ { $$ = $1 / $2; }
/* Exponentiation */
| exp exp ’^’ { $$ = pow ($1, $2); }
/* Unary minus */
| exp ’n’ { $$ = -$1; }
;
%%
可替换规则之间用竖线“|”连接,读作“或”。在花括号内部的是用于已经识别出来的非终结符的动作(action),用C代码写成。在动作中伪变量$$代表即将被构造的该分组的语义值。大部分动作的的主要工作就是向伪变量赋值。而各个部件的语义值则由$1、$2等来引用。
构造同一个非终结符的多个可替换规则构成了多个选择,对每一个替换规则,在后文中用“选择”来称呼。
对 input 的解释
input: /* empty */
| input line
;
上述读作:一个完整的输入或者是一个空串,或者是一个完整的输入后跟着一个输入行。“完整输入”就是由其自身定义的。
在冒号与第一个竖线之间没有任何字符,就表示为空。其含义表示input可以匹配一个空串的输入(没有记号)。这样可以处理打开计算器后就输入Ctrl-d结束输入的情况。习惯上在为空的地方加上一个注释/* empty */。
第二个选择的含义是,在读入了任意数量的行以后,可能的情况下再读入一行。左边的递归使本规则进入到一个循环,由于第一个选择是空,所以循环可以被执行0次或多次。
对 line 的解释
line: ’\n’
| exp ’\n’ { printf ("\t%.10g\n", $1); }
;
第一个选择就是一个记号,表示一个换行字符。其含义是,rpcalc 接受一个空行(可以被忽略,因此没有对应的动作)。
第二个选择就是一个表达式后跟着一个换行字符。这就使 rpcalc 变得有用起来。$1就是 exp 组的语义值,因为此处 exp 就是该选择中的第一个符号。对应的动作并不是普通的赋值给伪变量$$,这样与 line 关联的语义值就是未初始化的(因此其值是不可预测的)。倘若使用了这个值,拿这就是一个 bug。但本例中计算器并不使用这个值,
对 exp 的解释
exp: NUM { $$ = $1; }
| exp exp ’+’ { $$ = $1 + $2; }
| exp exp ’-’ { $$ = $1 - $2; }
...
;
上述形式还有一种等价形式:
exp: NUM ;
exp: exp exp ’+’ { $$ = $1 + $2; } ;
exp: exp exp ’-’ { $$ = $1 - $2; } ;
...
并不需要为每个规则都指定动作,当一条规则没有动作时,Bison 默认情况下把$1的值拷贝给$$。
==词法分析器==
词法分析器的工作是低级的分析:把字符或字符序列转换成记号。Bison 调用词法分析起来获得记号。本例只需要一个简单的词法分析器。下面就是词法分析器的代码:
/* The lexical analyzer returns a double floating point
number on the stack and the token NUM, or the numeric code
of the character read if not a number. It skips all blanks
and tabs, and returns 0 for end-of-input. */
#include <ctype.h>
int
yylex (void)
{
int c;
/* Skip white space. */
while ((c = getchar ()) == ’ ’ || c == ’\t’)
;
/* Process numbers. */
if (c == ’.’ || isdigit (c))
{
ungetc (c, stdin);
scanf ("%lf", &yylval);
return NUM;
}
/* Return end-of-input. */
if (c == EOF)
return 0;
/* Return a single char. */
return c;
}
该分析器跳过空格和制表符,然后读入数字作为双精度数字,并将他们作为NUM记号返回。不属于数字部分的任何其他字符都是一个单独的记号。注意单字符记号的记号代码就是该字符本身。
该记号的语义值被存储到全局变量 yylval,被 Bison 的解析器使用。(yylval的C数据类型是YYSTYPE,定义在语法的开头部分。)
一个为零的记号类型代码被返回,表示输入结束。(Bison 把任何的非正值识别为输入结束。)
==控制函数==
int
main (void)
{
return yyparse ();
}
控制函数的唯一目的就是调用函数 yyparse 来启动解析处理。
==错误报告例程==
当 yyparse 检测到一个错误时,将调用错误报告函数 yyerror 打印出一条错误消息。下面是本例中使用的代码。
#include <stdio.h>
/* Called by yyparse on error. */
void
yyerror (char const *s)
{
fprintf (stderr, "%s\n", s);
}
如果语法中包含有合适的错误规则,那么在 yyerror 返回后,Bison 解析器就可以从错误中恢复,并继续解析。本例没有提供错误规则,因此当遇到非法输入时,程序将退出。
==运行Bison制作解析器==
首先要考虑如何组织源代码到一个或多个文件中。本例作为一个简单程序,全部放到一个文件中是最简单的。把yylex、yyerror和main函数都放在语法文件的结尾部分就可以了。如果是一个大型工程,可能需要许多文件,并使用make工具来组织编译工作。
对于单一文件的本程序来说,用如下指令来将其转换为一个解析器:
bison rpcalc.y
Bison 将产生一个输出文件,名为rpcalc.tab.c。该输出文件中包含有供yyparse使用的代码。一些额外的代码(如yylex,yyerror,以及main)被原样输出到该文件中。最后用编译器将生成的C文件编译成可执行文件,这样计算器程序就可用了。编译命令如下:
cc -lm -o rpcalc rpcalc.tab.c
下面是使用这个逆波兰式计算器的例子,很显然这种方式不符合人类自然的思维习惯。
4 9 +
13
3 7 + 3 4 5 *+-
-13
3 7 + 3 4 5 * + - n Note the unary minus, ‘n’
13
5 6 / 4 n +
-3.166666667
3 4 ^ Exponentiation
81
6 n
-6
^D End-of-file indicator
=例子二=
本例子将实现一个中缀式计算器。
对于中缀运算符,存在优先级的概念,并有任意深度的括号嵌套层次。下面是文件“calc.y”的内容:
/* Infix notation calculator */
/* part1: prologue */
%{
#define YYSTYPE double
#include <math.h>
#include <stdio.h>
int yylex (void);
void yyerror (char const *);
%}
/* part2: bison decalarations */
%token NUM
%left '-' '+'
%left '*' '/'
%left NEG /* negation--unary minus */
%right '^' /* exponentiation */
/* part3: grammar rules */
%%
input: /* empty */
| input line
;
line: '\n'
| exp '\n' { printf("\t%.10g\n", $1); }
;
exp: NUM { $$ = $1; }
| exp '+' exp { $$ = $1 + $3; }
| exp '-' exp { $$ = $1 - $3; }
| exp '*' exp { $$ = $1 * $3; }
| exp '/' exp { $$ = $1 / $3; }
| '-' exp %prec NEG { $$ = -$2; }
| exp '^' exp { $$ = pow ($1, $3); }
| '(' exp ')' { $$ = $2; }
;
%%
/* part4: Epilogue same as the first example */
#include <ctype.h>
int
yylex (void)
{
int c;
/* Skip white space. */
while ((c = getchar ()) == ’ ’ || c == ’\t’)
;
/* Process numbers. */
if (c == ’.’ || isdigit (c))
{
ungetc (c, stdin);
scanf ("%lf", &yylval);
return NUM;
}
/* Return end-of-input. */
if (c == EOF)
return 0;
/* Return a single char. */
return c;
}
int
main (void)
{
return yyparse ();
}
#include <stdio.h>
/* Called by yyparse on error. */
void
yyerror (char const *s)
{
fprintf (stderr, "%s\n", s);
}
在语法段中引入两个重要特性:
%left 声明了记号类型,并指出他们是左关联运算符(left-associative operator)。
%right则表示是右关联运算符(right-associative operator)。
%token则声明一个没有关联性的记号类型名称。
本来单字符的记号一般不需要在这里声明,但这里是为了指出他们的关联性。
注意:运算符的优先级则由声明的行顺序决定,即越后声明的优先级越高,因此首先声明的运算符的优先级最低,最后声明的运算符优先级最高。本例中幂运算优先级最高,其次是一元取负运算符,接着是乘除运算,最低是加减运算。
另一个特性是一元取负运算符中用到的%prec。这个%prec指示bison本条规则“| '-' exp”具有与NEG相同的优先级,本例中即是次高优先级(next-to-highest)。
==简单的错误恢复==
检测到语法错误后,如何继续进行解析呢?目前已经知道可以用 yyerror 报告错误。默认情况下在调用了 yyerror 后, 函数 yyparse将返回。这样当遇到错误的输入行时计算器程序将退出。
bison 自己有一个保留关键字 error,可以用在语法规则部分。下面是一个例子:
line: '\n'
| exp '\n' { printf ("\t%.10g\n", $1); }
| error '\n' { yyerrok; }
;
当不可计算的表达式被读入后,上述第三条规则将识别出这个错误,解析将继续。yyerror 仍将被调用以打印出一条消息。第三条规则对应的动作是一个宏 yyerrok,由bison自动定义。此宏的含义是错误恢复已经完成。要注意 yyerrok 和yyerror的区别,这不是打字错误。
本例中只处理了语法错误,实际还有很多如除零错误等需要处理。
==跟踪定位计算器==
实现跟踪定位将改善错误消息。为简单起见,本例实现一个简单的整数计算器。
/* Location tracking calculator */
/* part1: prologue */
%{
#define YYSTYPE int
#include <math.h>
int yylex (void);
void yyerror (char const *);
%}
/* part2: Bison declarations */
%token NUM
%left '-' '+'
%left '*' '/'
%left NEG
%right '^'
在声明中并没有用来存储定位信息的数据类型,本例将使用默认类型:一个含四个整型成员的结构,即first_line, first_column, last_line, last_column。
是否处理位置信息,对你的语言的语法并没有影响。在这里将用位置信息来报告被零除的错误,并定位错误表达式或子表达式。
/* part3: grammar rules */
%%
input : /* empty */
| input line
;
line : '\n'
| exp '\n' { printf ("%d\n", $1); }
;
exp : NUM { $$ = $1; }
| exp '+' exp { $$ = $1 + $3; }
| exp '-' exp { $$ = $1 - $3; }
| exp '*' exp { $$ = $1 - $3; }
| exp '/' exp /* 注意:与前面例子不同的地方 */
{
if ($3)
$$ = $1 / $3;
else
{
$$ = 1;
fprintf (stderr, "%d.%d-%d.%d: division by zero",
@3.first_line, @3.firt_column,
@3.last_line, @3.last_column);
}
}
| '-' exp %prec NEG { $$ = -$2; }
| exp '^' exp { $$ = pow ($1, $3); }
| '(' exp ')' { $$ = $2; }
;
%%
伪变量@n对应规则中的部件,而伪变量@$则对应于组别。并不需要手工对@$赋值,输出解析器可以在执行每个动作对应的C代码之前自动完成赋值。这个默认行为是可以重定义的,对某些特殊规则,可以手工计算。[GNU的东西总是具有那么灵活的可配置性!]
那么词法分析器应该怎样写呢?在词法分析器中一个重要的任务是告诉解析器各个记号的位置。
为此我们必须计算输入文本中每个字符,以避免计算位置混淆或错误。
int yylex (void)
{
int c;
/* Skip white space */
while ((c = getchar ()) == ' ' || c == '\t')
++yylloc.last_column;
/* Step */
yylloc.first_line = yylloc.last_line;
yylloc.first_column = yylloc.last_column;
/* Process numbers */
if (isdigit (c))
{
yylval = c - '0';
++yylloc.last_cloumn;
while (isdigit (c = getchar ()))
{
++yyloc.last_column;
yylval = yylval * 10 + c - '0';
}
ungetc (c, stdin);
return NUM;
}
/* Return end-of-input */
if (c == EOF)
return 0;
/* Return a single char, and update location */
if (c == '\n')
{
++yyloc.last_line;
yyloc.last_column = 0;
}
else
++yylloc.last_column;
return c;
}
每次该函数返回一个记号时,解析器都知道它的数字,以及它的语义值,还有在文本中的位置。
[可以将这样来看,四个值构成成一个盒子,每一个合法的记号都应该放到一个盒子里。当读入一个较长的记号时,显然最后一列的值在增加,而开始读新的一行时,最后一行的值也要增加。]
还需要初始化yylloc,这在控制函数中完成:
int main()
{
yylloc.first_line = yylloc.last_line = 1;
yylloc.first_column = yylloc.last_column = 0;
return yyparse();
}
注意:计算位置与语法无关,因此,每个字符都必须关联一个位置,无论该字符在合法输入中,还是在注释中,或者字串中等。yylloc是一个全局变量,类型是YYLTYPE,它包含着记号的位置信息。
用bison来做语法分析,首先要将分析对象做仔细的研究。分析工作的首要任务是分清楚什么是终结符,什么是非终结符。
终结符是一组原子性的单词,表达了语法意义中不可分割的一个标记。在具体的表现形式上,可能是一个字符串,也可能是一个整数,或者是一个空格,一个换行符等等。bison只给出每个终结符的名称,并不给出其定义。Bison为每个终结符名称分配一个唯一的数字代码。
终结符的识别由专门定义的函数yylex()执行。这个函数返回识别出来的终结符的编码,且已识别的终结符可以通过全局变量yytext指针,而这个终结符的长度则存储在全局变量yyleng中。来取得这种终结符的分析最好用flex工具通过对语法文件进行扫描来识别。有些终结符有不同的具体表示。例如h248协议中的表示版本号的终结符VersionToken,既可能用字串Version表示,也可能用一个字符V表示。这种情况下,Bison中只给出终结符名称,而由Flex给出终结符的具体定义。
非终结符是一个终结符序列所构成的一个中间表达式的名字。实际上不存在这么一个原子性的标记。这种非终结符的构成方式则应该由Bison来表达。语法规则就是由终结符和非终结符一起构成的一种组成规则的表达。
Bison的文法规则中各个组成部分是有次序性的。如果在一个文法定义中,各个元素的次序是任意的,并且其中某些元素又是必须的,该怎么来编写这样的Bison文法规则呢?Bison的文法规则定义文件在命名习惯上以字母y作为后缀。
Bison实际上也是一个自动化的文法分析工具,其利用词法分析函数yylex()返回的词法标记返回其ID,执行每一条文法规则后定义的动作。Bison是不能自动地生成词法分析函数的。一般简单的程序里,一般在文法规则定义文件的末尾添加该函数的定义。但是在较复杂的大型程序里,则利用自动词法生成工具flex生成yylex()的定义。
Bison与Flex联用时,Bison只定义标记的ID。Flex则需要知道这些词法标记的ID,才能在识别到一个词法标记时返回这个ID给Bison。Bison传递这些ID给Flex的方法,就是在调用bison命令时使用参数-d。使用这个参数后,Bison会生成一个独立的头文件,该文件的名称形式为name.tab.h。在Flex的词法规则文件中,在定义区段里包含这个头文件即可。如下例所示:
%{
#include “name.tab.h”
%}
%%
[0-9]+ yylval = atoi(yytext); return TOK_NUMBER;
yylex()只需要每次识别出一个token就马上返回这个token的ID即可。上例中返回的token的ID就是TOK_NUMBER。此外,一个token的语义值可以由yylex()计算出来后放在全局变量yylval中。下面是具有多种语义值类型的例子:
{DIGIT}+ { yylval.Number = new CNumberLiteralNode(yytext);
return T_NUMBER_LITERAL;
}
根据Bison文法定义文件自动生成的C代码,给出了文法分析函数yyparse()的定义。然而该代码还不是一个完整的C程序,还需要程序员提供几个额外的函数。一个是词法分析函数yylex(),另外一个就是报错函数yyerror()。报错函数被yyparse()调用,以便在遇到错误时汇报错误。此外,一个完整的C程序还需要程序员提供一个main()函数作为程序的入口。在这个main()函数中,一定要调用yyparse(),否则分析工作就不会启动。
报错函数yyerror()的编写
这个函数的原型如下:
int yyerror (const char* msg);
yyparse()函数遇到了错误时,可能会把字串syntax error或者memory exhausted作为参数传递给yyerror()。一个简单的例子如下:
int yyerror( const char* msg)
{
fprintf (stderr, “%s\n”, msg);
return 0;
}
Flex将识别到词法标记记录到变量yytext中,长度记录在yyleng中。函数yylex()的返回值是一个整型,就是词法标记的ID。但是yylex()识别出来的字符串也可能需要返回给Bison。那么怎么返回呢?
现在做一个练习:定义一个非常简单的计算器,这个计算器只能做一个整数的加法。这个计算器不做任何的错误处理。
首先给出Bison的文法定义文件:
参考文献
文献目录
1: Lars Marius Garshol, BNF and EBNF: What are they and hwo do they work?, 2003-07-21, http://www.garshol.priv.no/download/text/bnf.html
2: Charles Donnelly, Richard Stallman, Bison: The Yacc-compatible Parser Generator, 2006-05-30
1本章内容译自Bison手册第5章,仅有少量文字未翻译。