简介
如果你有Unix环境的编程经验,想必你肯定遇到过神秘的Lex和YACC工具,在GUN/Linux中,又分别称作Flex和Bison,其中Flex是由Vern Paxon实现的Lex版本,Bison是GUN版本的YACC.我们统一称他们为Lex和YACC,这些新版本是向上兼容的,因此你可以在我们的示例中使用Flex以及Bison.
这两个程序是非常有用的,但是跟C编译器一样,它的用户手册上即不会解释C语言,也不会告诉你如何使用C语言。YACC与Lex一起使用时非常有用,然而,Bison用户手册并没有介绍如何将Lex代码集成到Bison程序里。
Lex
Lex 程序生成的的文件被称作分词器。它是一个函数,输入为字符流,只要发现一段字符能够匹配一个关键字,就会采取对应的动作。一个非常简单的示例:
%{
#include <stdio.h>
%}
%%
stop printf("Stop command received\n");
start printf("Start command received\n");
%%
位于%{和%}之间的第一个段原封不动的导出到输出程序。因为使用了printf,因此我们需要stdio.h。
段之间被%%分割了开来,第二段的第一行起于stop健值,表示当从输入流中读取到stop时就会执行后面的printf("Stop command received\n");
除了stop,我们还定义了start,作用与stop一样。
段以%%结束。
为了编译Example1,执行
$ lex example1.lt
cc lex.yy.c -o example -ll
** 请注意:如果你使用Flex,请用Lex替代之,可能你还要将-ll替换成-lfl.至少RetHat 6.x以及SuSE需要。**
上面的命令会生成程序example1,如果你运行它,它会等待你的输入。只要你的输入内容与定义的键值(stop和start)不匹配,就会将它们输出。如果你输入stop,它会输出 Stop command received。
以EOF(^D)结束输入。
也许你想知道程序为什么能运行,因为我们压根没有定义main函数。其实main函数在libl(liblex)中被定义,通过 -ll被引入了进来。
正则匹配
上面的示例的实用效果不佳,接下来的亦然。不过它会在Lex中引用正则,这点将会在后面的示例中非常有用。
Example 2:
%{
#include <stdio.h>
%}
%%
[0123456789]+ printf("NUMBER\n");
[a-zA-Z][a-zA-Z0-9]* printf("WORD\n");
%%
上面这个Lex文件描述两种匹配的符号:WORD和NUMBER。学习正则表达式可能有一点困难,但只须花点功夫便可轻松的理解它们。来看下NUMBER的匹配:
[0123456789]+
意思是:一系列的一个或多个取自于0123456789中的字符。简便写法是:
[0-9]+
WORD的匹配:
[a-zA-Z][z-zA-Z0-9]*
第一个部分(第一个方括号内)仅匹配一个介与'a'和'z'之间的字符,或者说,一个字母。这个初始的字母后面需要跟0个多更多的字符,这些字符即可以是字母也可以是数字。为何此处使用星号呢?
+意思是1个或更多的匹配,但是一个WORD可以仅由一个字符组成,即已经匹配的第一个部分。因此第二人部分或许是0个匹配,因此用'*'。
这样我们就模仿了大部分编程语言中变量必须由一个字母开头,但是后面可以有数字。例如,'temperature1'是个合法的名字,但是'1temperature'不是。
尝试编译Example2,方法于Example1一样。输入一些文字,以下是一些样例:
$ ./example2
foo WORD bar WORD 123 NUMBER bar123 WORD 123bar NUMBER WORD
Flex的用户手册上关于正则表述式描述的很详细。perl用户手册(perler)关于正则部分也很有用,尽管Flex没有实现perl的全部。
** 确认你没有创建形如[0-9]这样可以匹配模式,否则你的lexer会重复的匹配空字段串。*
一个更复杂的类C语法示例
假设下面是一个我们想解析的文件:
logging {
category lame?servers { null; };
category cname { null; };
};
zone "." {
type hint;
file "/etc/bind/db.root";
};
这个文件中有以下几类符号(tokens)
WORDs ,如zone和type
FILENAMEs ,如/etc/bind/db.root
QUOTEs ,如包括文件名的符号
OBRACEs ,左花括号{
EBRACEs ,右花括号}
SEMICOLONs ,;
对应的Lex文件如下(Example 3):
%{
#include <stdio.h>
%}
%%
[a-zA-Z][a-zA-Z0-9]* printf("WORD ");
[a-zA-Z0-9\/.-]+ printf("FILENAME ");
\" printf("QUOTE ");
\{ printf("OBRACE ");
\} printf("EBRACE ");
; printf("SEMICOLON ");
\n printf("\n");
[ \t]+ /* ignore whitespace */;
%%
当我们将文件输入分词器时,得到:
WORD OBRACE
WORD FILENAME OBRACE WORD SEMICOLON EBRACE SEMICOLON
WORD WORD OBRACE WORD SEMICOLON EBRACE SEMICOLON
EBRACE SEMICOLON
WORD QUOTE FILENAME QUOTE OBRACE
WORD WORD SEMICOLON
WORD QUOTE FILENAME QUOTE SEMICOLON
EBRACE SEMICOLON
与之前提到的配置文件相比,很明显我们对其进行了符号化。配置文件的每个部分都被匹配了并且转化成指定的符号。
这正是我们要给YACC使用的。
YACC
YACC能够将输入的符号流解析成指定的值。这里清晰的描述了YACC与Lex之前的关系。YACC没有输入流的概念,它仅接受预处理过的符号集。你可以自己写符号生成器,不过本文全部将其交给Lex。
关于语法跟语法分析器的一点小注意:当YACC成熟时,它就被用作编译器的解析文析的工具。计算机语言不允许有二义性。因此,YACC在遇到有歧义时会抱怨移进/归约或者归约/归约冲突。更多关于YACC与歧义的问题参考冲突章节。
一个简单的温度调节控制器
我们想用一门简单的语言去控制一个温度调节器,例如:
heat on Heater on!
heat off Heater off!
target temperature 22 New temperature set!
我们需要辨别的符号有:heat,on/off(STATE),target,temperature,NUMBER。对应的Lex文件如下(Example 4):
%{
#include <stdio.h>
#include "y.tab.h"
%}
%%
[0-9]+ return NUMBER;
heat return TOKHEAT;
on|off return STATE;
target return TOKTARGET;
temperature return TOKTEMPERATURE;
\n /* ignore end of line */;
[ \t]+ /* ignore whitespace */;
%%
注意两个重要的变化。第一,引入了头文件y.tab.h。第二,我们不再使用print函数,而是直接返回符号的名字。这样做的目的是为了接下来将它嵌入到YACC中,而后者对打印到屏幕的内容根本不关心。Y.tab.h定义了这些符号。
但是y.tab.h是从哪得到的呢?它是由YACC从语法文件中生成的。 我们的语言非常简单,以下是它的语法:
%%
commands: /* empty */
| commands command
;
command:
heat_switch
|
target_set
;
heat_switch:
TOKHEAT STATE
{
printf("\tHeat turned on or off\n");
}
;
target_set:
TOKTARGET TOKTEMPERATURE NUMBER
{
printf("\tTemperature set\n");
}
;
第一个部分我称之为根,它告诉我们有命令集(commands),并且这些命令集由一些独立的命令(command)组成。如你所见,这些规则是递归的,因为他本身又包含了commands.这就意味着通过递归可以将这一系列的命令集进行归约。阅读Lex和YACC内部原理获取更多递归的详细内容。
第二个部分规则定义了command具体是什么。我们只支持两种命令:heat_switch和target_set。这个是|-符号的意思:一个命令(command)包含了heat_switch或target_set。
heat_switch包含了HEAT符号,即一个简单的单词heat以及后面跟一个状态(在Lex中定义的on或off)。
target_set稍微有些复杂,它由TARGET符号(单词target),TEMPERATURE符号(单词)以及一个数字组成。
完整的YACC文件
前面一节仅列出了YACC文件的部分,以下是我们省略的开头部分:
%{
#include <stdio.h>
#include <string.h>
void yyerror(const char *str)
{
fprintf(stderr,"error: %s\n",str);
}
int yywrap()
{
return 1;
}
main()
{
yyparse();
}
%}
%token NUMBER TOKHEAT STATE TOKTARGET TOKTEMPERATURE
函数yyerror在YACC发生错误时被调用 ,我们只是简单的将传入的信息打印了出来,实际有比这更巧妙的处理,参阅"深度阅读"一节。
函数yywrap能够用于是否继续读取其它的文件,当遇到EOF时,你可以打开其它文件并返回0。或者,返回1,意味着真正的结束。欲知更多,请参阅"Lex和YACC内部工作原理"章节。
函数main是程序的起点。
最后一行简单的定义了哪些符号将会被用到,如果调用YACC时启用了-d选项,会将这些符号会输出到y.tab.h文件。
编译、运行温度调节控制器
lex example4.l
yacc -d example4.y
cc lex.yy.c y.tab.c -o example4
有一点小变化。现在我们使用YACC编译我们的程序,它生成y.tab.c和y.tab.h文件.然后才是调用Lex。编译时,不再需要-ll,因为程序中我们定义了自己的main函数。
**注意:如果你得到一个编译器错误:not being able to find 'yylval',将下面的内容加入到文件example4.l中的#include 下面 **
extern YYSTYPE yylval;
Lex 和YACC工作内部原理有相关的解释。
运行示例:
$ ./example4
heat on Heat turned on or off heat off Heat turned on or off target temperature 10 Temperature set
target humidity 20 error: parse error
以上并不是我们要完成的真正目标,而是通过此例循序渐进,控制学习曲线,使读者继续保持兴趣。并非所有酷的特性都能一次被展示。
拓展温度调节器使其可处理参数
上面的示例可以正确的解析温度调节器的命令,但是它并不知道应该做什么,它并不能取到你输入的温度值。
接下来工作就是向其中加一点功能使之可以读取出具体的温度值。为此我们需要学习如何将Lex中的数字(NUMBER)匹配转化成一个整数,使其可以在YACC中被读取。
当Lex匹配到一个目标时,它就会将匹配到的文字放到yytext中。YACC从变量yylval中取值。在下面的Example5中,是一种直接的方法:
%{
#include <stdio.h>
#include "y.tab.h"
%}
%%
[0-9]+ yylval=atoi(yytext); return NUMBER;
heat return TOKHEAT;
on|off yylval=!strcmp(yytext,"on"); return STATE;
target return TOKTARGET;
temperature return TOKTEMPERATURE;
\n /* ignore end of line */;
[ \t]+ /* ignore whitespace */;
%%
如你所见,以yytext作为参数调用atoi函数,并将其返回值赋给yylval变量,这样YACC就可以使用它。我们对STATE采用类似的处理方式:如果为on,yylval为1。
请注意,在Lex中分别对on和offf进行匹配可以得到更快的处理代码,但是我想展示一点更复杂的规则。
接下来我们学习YACC如何处理这些。Lex中我们称为yylval,在YACC有另外一个名字。下面检查设置温度目标的规则:
target_set:
TOKTARGET TOKTEMPERATURE NUMBER
{
printf("\tTemperature set to %d\n",$3);
}
;
为了取到规则中的第三个部分的值,(例如,NUMBER),我们需要使用$3,只要yylex返回,yylval的值就会被显示在终端中,其值经由$取得。
为了阐述这个特性,让我们观查新的heat_switch规则:
heat_switch:
TOKHEAT STATE
{
if($2)
printf("\tHeat turned on\n");
else
printf("\tHeat turned off\n");
}
;
解析配置文件
让我们继续讨论前面提到的配置文件:
zone "." { type hint; file "/etc/bind/db.root";
}
之前我们已经为其写过一个分词器。现在需要为其写一个YACC语法文件并且修改那个分词器以适应YACC。
Example 6:
%{
#include <stdio.h>
#include "y.tab.h"
%}
%%
zone return ZONETOK;
file return FILETOK;
[a-zA-Z][a-zA-Z0-9]* yylval=strdup(yytext); return WORD;
[a-zA-Z0-9\/.-]+ yylval=strdup(yytext); return FILENAME;
\" return QUOTE;
\{ return OBRACE;
\} return EBRACE;
; return SEMICOLON;
\n /* ignore EOL */;
[ \t]+ /* ignore whitespace */;
%%
仔细看你会发现yylval有所不同!我们不再期望它是一个整数,而是假设它为一个字符串。为了使其保持简单,采用了strdup并且浪费了一些内存。
使用字符串是因为大多数时候我们处理的是名字:文件名和区域名。稍后我们会解释如何多类型数据。
为了告诉YACC中yylval的类型,将下面的一行添加到YACC语法中:
#define YYSTYPE char *
语法本身也变得更复杂了,为了使其更容易理解,我们将其分成几个部分来介绍。
commands:
|
commands command SEMICOLON
;
command:
zone_set
;
zone_set:
ZONETOK quotedname zonecontent
{
printf("Complete zone for '%s' found\n",$2);
}
;
上面是个引子,包含了前面提到的递归根,请注意我们指明了命令集以;结束。我们定义了一个叫zone_set的命令,它包含ZONE符号(单词zone),后面跟着一个带引号的名称和zonecontent。zonecontent很简单:
zonecontent: OBRACE zonestatements EBRACE
它以一个OBRACE({)为开始,然后跟着zonestatements,再跟着一个EBRACE(})。
qutedame: QUOTE FILENAME QUOTE { $$=$2 }
上面定义了quotedname:一个在引号中间的文件名。然后特别定义:quotedname符号的值是FILENAME,即quotedname的值是其本身文件名,但不包含包裹着它的引号。这就是命令$$=$2的含意。它指:我的值是我本身的第二个部分。当quotedname在其它规则中被引用时,可通过$取其值,实际得到的值是经由$$=$2指定的。
zonestatements:
|
zonestatements zonestatement SEMICOLON
;
zonestatement:
statements
|
FILETOK quotedname
{ printf("A zonefile name '%s' was encountered\n",$2);
}
;
以上是zone块里面所有申明的框架,我们又一次看到了递归。
block:
OBRACE zonestatements EBRACE SEMICOLON
;
statements:
| statements statement
;
statement: WORD | block | quotedname
上面定义了一个块,里面包含了申明语句。
执行它,得到如下结果:
$ ./example6
zone "." { type hint;
file "/etc/bind/db.root"; type hint;
};
A zonefile name '/etc/bind/db.root' was encountered
Complete zone for '.' found
用c++制作解析器
尽管Lex和YACC比C++要出现的早,但也可以生成一个c++版的解析器。虽然Flex包含一个可以生成c++的分词器的参数 ,但我们不会使用它,因为YACC不知道如何直接使用它们。
我比较喜欢通过Lex生成一个c语言文件,然后再用YACC生成c++代码。不过当你使用链接器生成你程序时,可能会遇到一些问题,因为c++代码默认不能找到C语言中的函数。除非你用extren申明这些函数。为了这样做,在YACC中放入如下的C代码:
extern "C" {
int yyparse(void);
int yylex(void);
int yywrap() {
return 1;
}
}
如果你想申明或者改变yydebug,你得这样做:
extern int yydebug;
main()
{
yydebug=1;
yyparse();
}
你也许已经发现需要将YYSTYPE的定义放到Lex文件中,因为C++是严格类型的检查。
用下以方式编译:
lex bindconfig2.l
yacc ??verbose ??debug ?d bindconfig2.y ?o bindconfig2.cc
cc ?c lex.yy.c ?o lex.yy.o
c++ lex.yy.o bindconfig2.cc ?o bindconfig2
因为YACC使用了-o选项,y.tab.h现在被称作bindconfig2.cc.h。
总结:不要将分词器编译成c++。用c++生成语法解析器时需要用exetern "C"语句告诉编译器C中的函数。
Lex和YACC内部工作原理
在YACC文件中,main函数调用了yyparse(),此函数由YACC替你生成的,在y.tab.c文件中。
函数yyparse从yylex中读取符号/值组成的流。你可以自己编码实现这点,或者让Lex帮你完成。在我们的示例中,我们选择将此任务交给Lex。
Lex中的yylex函数从一个称作yyin的文件指针所指的文件中读取字符。如果你没有设置yyin,默认是标准输入(stdin)。输出为yyout,默认为标准输出(stdout)。
你可以在yywrap函数中修改yyin,此函数在每一个输入文件被解析完毕时被调用,它允许你打开其它的文件继续解析,如果是这样,yywarp的返回值为0。如果想结束解析文件,返回1。
每次调用yylex函数用一个整数作为返回值,表示一种符号类型,告诉YACC当前读取到的符号类型,此符号是否有值是可选的,yylval即存放了其值。
默认yylval的类型是整型(int),但是可以通过重定义YYSTYPE以对其进行重写。分词器需要取得yylval,为此必须将其定义为一个外部变量。原始YACC不会帮你做这些,因此你得将下面的内容添加到你的分词器中,就在#include下即可:
extern YYSTYPE yylval;
Bison会自动帮你做这些。
符号值
前面提到过,函数yylex需要返回它遇到的符号类型,并将其值放到yylval中。这些符号经由命令%token定义,并对其赋值了数字类型的id号,以256开始。
基于此,所有ascii字符都可以作为一个符号。比方说你要写一个计算器,到目前为止,我们可以写一个如下的分词器:
[0-9]+ yylval=atoi(yytext);return NUMBER;
[ \n]+ /*eat whitespace */;
- return MINUS;
\* return MULT
\+ return PLUS;
...
语法可以是这样:
exp: NUMBER
| exp PLUS exp | exp MINUS exp | exp MULT exp
其实没必要这样复杂。通过使用ascii字符为符号的id,分词器可以写成这样:
[0-9]+ yylval=atoi(yytext);return NUMBER;
[ \n]+ /*eat whitespace */;
. return (int)yytext[0];
...
.匹配所有匹配的单字符。对应的语法为:
exp: NUMBER
| exp '+' exp | exp '-' exp | exp '*' exp
这样看起来更直接也更短了,你不需要在头部使用%定义那些字符。
这样做还有一个优点,即对于所有的输入,Lex都会匹配,避免了默认不匹配时将其输出到标准输出。比方说用户在计算器中使用^,会产生一个解析错误,而非将其输出到标准输出。
递归:'右即是错'
递归是YACC必不可少的。没有它,你就不能指定一个文件包含一系列的独立命令或语句。根据规定,YACC仅对第一条规则感兴趣,或者使用%start符号指定的起始规则。
YACC中的递归分为两类:左递归和右递归。大部分时候你应该使用左递归,就像这样:
commands: /*empty*/ |
commands command
它的意思是,一个命令集要么是空,要么它包含更多的命令集以及后面跟着一个命令。YACC的工作方式意味着它可以轻松的砍掉单独的命令块(从前面)并逐步归约它们。
与左递归相比,右递归迷惑了大部分人,觉得看起来更好:
commands: /*empty*/ |
command commands
但这样代价太高了。如果使用%start规则,需要YACC将所有的命令放在栈上,消耗很多的内存。因此尽可能使用左递归解析长语句,比如解析整个文件。
有时则无可避免的使用右递归,如果你的语句不是太长,你不需要想尽一切方法使用左递归。
如果命令有终结符,右递归看起来更自然一些,但是仍然代价昂贵:
commands: /*empty*/ |
command SEMICOLON commands
正确的代码是使用左递归:
commands: /* empty */ |
commands command SEMICOLON\
高级yylval:%union
现在,我们需要定义yylval的类型,虽然这并不总是合适的。有时我们需要处理多类型的数据。回到早前的温度调节器示例,假设我们想要能够选择一个加热器进行控制,像这样:
heater mainbuiling
Selected 'mainbuilding' heater
target temperature 23 'mainbuilding' heater target temperature now 23
我们称这这种yylval是个联合体,它即可以处理字符串,也可以是整数,但不是同时处理这两种。
之前说过,YACC的yylval类型是取决于YYSTYPE,可以想象,我们可以通过定义YYSTYPE为联合体。不过YACC有一个更简单的方法:使用%union语句。
基于例4,现在我们写出如下的YACC语法(Example 7),刚开始为:
%token TOKHEATER TOKHEAT TOKTARGET TOKTEMPERATURE %union { int number;
char *string;
} %token STATE %token NUMBER %token WORD
定义了我们的联合体,它仅包含数字和字体串,然后使用一个扩展的%token语法,告诉YACC应该取联合体的哪一个部分。
这个例子中,我们定义STATE 为一个整数,这点跟前面一样,NUMBER符号用于读取温度值。
不过新的WORD被定义为一个字符串。
分词器文件也有很多改变:
%{ #include #include #include "y.tab.h" %}
%%
[0?9]+ yylval.number=atoi(yytext); return NUMBER;
heater return TOKHEATER;
heat return TOKHEATER;
on|off yylval.number=!strcmp(yytext,"on"); return STATE;
target return TOKTARGET;
temperature return TOKTEMPERATURE;
[a?z0?9]+ yylval.string=strdup(yytext);return WORD;
\n /* ignore end of line */;
[ \t]+ /* ignore whitespace */;
%%
如你所见,我们不再直接获取yylval的值,而是添加一个后缀指示想取得哪个部分的值。不过在YACC语法中,我们无须这样做,因为YACC为我们做了神奇的这些:
heater_select:
TOKHEATER WORD
{ printf("\tSelected heater '%s'\n",$2);
heater=$2;
}
;
由于上面的%token定义,YACC自动从联合体中挑选string成员。同时也请注意,我们保存了一份$2的副本,它在后面被用于告诉用户是哪一个加热器发出的命令:
target_set:
TOKTARGET TOKTEMPERATURE NUMBER
{ printf("\tHeater '%s' temperature set to %d\n",heater,$3);
}
;
更多详情请参考example7.y。
调试
特别是刚学习时,调度工具非常重要。幸运的是,YACC能够给出许多反馈信息。这些反馈信息需要一定的开销,你需要一些开关参数来启用它们。
当你编译语法文件时,在YACC命令行中增加 --debug和--verbose。在语法的C语言的头部,添加如下:
int yydebug=1;
这样会生成文件y.output,里面解释了我们创建的状态机。
当你运行生成的二进制,它会输出很多信息,包含状态机目前的状态,以及哪些符号被读取了。
Peter Jinks 写了一篇关于调式方面的文章,包含一些常见的错误及其处理方法。
状态机
YACC解析器内部运行着一个叫状态机的东西。这个名字暗示着这个机器有多种状态。而规则控制着状态机从一个状态到另外一个状态的改变。所有的东西起始于之前我提到的根的规则。
引用示例7中y.output的输出内容:
state 0 ZONETOK , and go to state 1 $default reduce using rule 1 (commands)
commands go to state 29 command go to state 2 zone_set go to state 3
默认情况下,这个状态经由commands规则归约,这是前面提到的由多个单一命令语句建立起来的递归规则形成的命令集,后跟一个;,也许还有更多的命令集。
状态一直递减,直到遇到它能理解的东西,在这个例子里,比如一个ZONETOKE,单词zone。然后它转向状态1,它将处理一个zone 命令:
state 1 zone_set ?> ZONETOK . quotedname zonecontent (rule 4)
QUOTE , and go to state 4 quotedname go to state 5
上面的第一行有一个.在里面,它指示所处的位置:我们正好遇到一个ZONETOK,现在寻找quotedname。很明显,一个quotedname起始于一个QUTOTE,而它将我们转向状态4。
欲进一步了解,用调试一节提到的参数编译Example 7。
冲突:'移进/归约','归约/归约'
只要YACC发出关于冲突的警告,可能就有麻烦了。解决这些冲突似乎是门艺术,也许会让你对那门语言理解的更深刻,远比你想知道的多。
解决问题围绕着如何解释一系列的符号。假设我们定义了一门语言,它需要接收一系列的命令:
delete heater all delete heater number1
为此,我们这样定义语法:
delete_heaters:
TOKDELETE TOKHEATER mode
{
deleteheaters($3);
}
mode: WORD
delete_a_heater:
TOKDELETE TOKHEATER WORD
{ delete($3);
}
也许你已经感觉到了有问题。状态机开始读入单词'delete',然后需要由接下来的符号决定转向哪。这个接下来的符号即可以是一个mode,指明了如何删除加热器,或者一个待删除的加热器。
但问题出自于这两个命令的下一个符号是WORD。YACC不知道应该要怎样做,这导致了一个'归约/归约'警告,以及一个更具体的警告:'delete_a_heater'永远不能被访问。
这个示例的冲突很容易解决(例如,将第一个命令重命名为'delete heaters all',或者将'all'单独定义为一个符号),但是有时却非常困难。用--verbose标记生成的y.output文件能够起到很大的帮助。
网友评论