2025年4月11日 星期五 乙巳(蛇)年 正月十二 夜 设为首页 加入收藏
rss
您当前的位置:首页 > 计算机 > 精彩资源

Lex与YACC详解

时间:09-06来源:作者:点击数:23
城东书院 www.cdsy.xyz

一、简介

只要你在Unix环境中写过程序,你必定会邂逅神秘的Lex&YACC,就如GNU/Linux用户所熟知的Flex&Bison,这里的Flex就是由Vern Paxon实现的一个Lex,Bison则是GNU版本的YACC。在此我们将统一称呼这些程序为Lex和YACC。新版本的程序是向上兼容的(译注:即兼容老版本),所以你可以用Flex和Bison来尝试下我们的实例。

这些程序实用性极广,但如同你的C编译器一样,在其主页上并没有描述它们,也没有关于怎样使用的信息。当和Lex结合使用时,YACC实在是棒极了,但是Bison的主页上并没有描述Bison如何跟Lex结合使用以生成代码的相应说明。

二、Lex & YACC能为你做什么?

如果使用得当,这些程序(指LEX&YACC)可以让你轻易的解析复杂的语言,当你需要读取一个配置文件时,或者你需要编写一个你自己使用的语言的编译器时,这对于你来说是莫大的裨益。

本文档能提供给你一些帮助,你将发现你再也不用手工写解析器了,Lex & YACC就是为你量身打造的利器。

三、Lex

Lex会生成一个叫做『词法分析器』的程序。这是一个函数,它带有一个字符流传入参数,词法分析器函数看到一组字符就会去匹配一个关键字(key),采取相应措施。一个非常简单的例子(example1)如下:

  • %{
  • #include <stdio.h>
  • %}
  • %%
  • stop printf("Stop command received\n");
  • start printf("Start command received\n");
  • %%

第一部分,位于%{和%}对之间直接包含了输出程序(stdio.h)。我们需要这个程序,因为使用了printf函数,它在stdio.h中定义。

第二部分用’%%’分割开来,所以第二行起始于’stop’,一旦在输入参数中遇到了’stop’,接下来的那一行(printf()调用)将被执行。

除此之外,还有’start’,其跟stop的行为差不多。

我们再次用’%%’结束代码段。

为了编译上面的例子,只需要执行以下命令:

  • lex example1.l
  • cc lex.yy –o example –ll

注意:如果你用flex,则就将lex命令用flex代替,还需要将’-ll’选项改成’-lfl’。在RedHat 6.x以及SuSe中需要这样做。

这样,Lex将生成’example1’这个文件。运行该文件,它将等待你输入一些数据。每次你输入一些不匹配的命令(非’stop’和’start’),它会将你输入的字符再次输出。你若输入’stop’,它将输出’Stop command received’。

用一个EOF(^D)来结束程序。

也许你想知道,它是怎么运行的,因为我们并没有定义main()函数。这个函数(指main())已经在lib1(liblex)中定义好了,在此我们选用了编译选项’-ll’

3.1 Lex的常规表达式

常规表达式是一种使用元语言的模式描述。表达式由符号组成。符号一般是字符和数字,还有一些具有特殊含义的其他标记,如下表:

3.2 匹配中的正则表达式示例

这个实例(example2)本身并没什么用处,下一个实例也不会提及正则表达式。但这里它展示了如何在Lex中使用正则表达式,这在后面将非常有用。

  • %{
  • #include <stdio.h>
  • %}
  • %%
  • [0123456789]+ printf("NUMBER\n");
  • [a-zA-Z][a-zA-Z0-9]* printf("WORD\n");

该Lex文件描述了两种token匹配:WORDs和NUMBERs。正则表达式非常恐怖,但是只需要稍花力气便可以加以理解。

其中NUMBER匹配“[0123456789]+”可以写成“[0-9]+”。

WORD匹配就有点复杂:[a-zA-Z][a-zA-Z0-9]*

第一部分仅仅匹配一个’a’到’z’或’A’到’Z’之间的字符,也即一个字母。接着该字母后面需要连上0个或多个字符,这些字符可以是字母,也可以是数字。这里为何用’*’? ’+’表示至少1次的匹配。一个WORD只有一个字符也可以很好的匹配,在第一部分我们已经匹配到了一个字符,所以第二部分可以是0个匹配,所以用’*’。

用这种方式,我们就模仿了很多编程语言中对于一个变量名的要求,即要求变量名『必须』以字母开头,但是可以在后续字符中用数字。也就是说’temperature1’是一个正确的命名,但是’1temperature’就不是。

像example1一样编译example2,并输入一些文本,如下:

  • $ ./example2
  • foo
  • WORD
  • bar
  • WORD
  • 123
  • NUMBER
  • bar123
  • WORD
  • 123bar
  • NUMBER
  • WORD

你也许会疑惑,所有的输出中的空格是从哪来的?理由很简单:从输入而来,我们不在空格上匹配任何内容,所以它们又输出来了。

Flex主页上有正则表达式的详细文档。很多人觉得perl正则表达式主页的说明非常有用,但是Flex并不实现perl所实现的所有东西。

你只需要确保不写一些形如’[0-9]*’的空匹配即可,你的词法分析器(由Flex生成)将不明就里的开始不断的匹配空字符。

3.3 复杂一点的类C语法示例

假定我们需要解析一个形如下面的文件:

  • logging{
  • category lame-servers { null; };
  • category cname { null; };
  • };
  • zone "." {
  • type hint;
  • file "/etc/bind/db.root";
  • }

我们在此见到了很多token:

  • WORD: 如’zone’和’type’
  • FILENAME:如“/etc/bind/db.root”
  • QUOTE: 如包含文件名的引号
  • OBRACE:{
  • EBRACE: }
  • SEMICOLON: ;

example3相应的Lex文件如下:

  • %{
  • #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 */;
  • %%

当输入我们的文件到Lex生成的example3中,我们得到:

  • 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可以解析输入流中的标识符(token),这就清楚的描述了YACC和LEX的关系,YACC并不知道『输入流』为何物,它需要事先就将输入流预加工成标识符,虽然你可以自己手工写一个Tokenizer,但我们将这些工作留给LEX来做。

YACC用来为编译器解析输入数据,即程序代码。这些用编程语言写成的程序代码一点也不模棱两可——它们只有一个意思。正因为如此,YACC才不会去对付那些有歧义的语法,并且会抱怨shift/reduce或者reduce/reduce冲突。更多的关于模糊性和YACC『问题』可以在『冲突』一章中找到。

4.1 一个简单的温度控制器

假定我们有一个温度计,我们要用一种简单的语言来控制它。关于此的一个会话、如下:

  • heat on
  • Heater on!
  • heat off
  • Header off!
  • target temperature set!

我们需要识别的标识符为heat, on/off(STATE), target, temperature, NUMBER。

LEX的tokenizer(example4)为:

  • %{
  • #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』;

第二,我们不再打印输出了,我们返回标识符的名字。之所这样做是因为我们将这些返回传送给了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");
  • };

第一部分,我们称之为根(root)。它告诉我们有一个『commands』,并且这些『commands』由单个的『command』组成。正如你所见到的那样,这是一个标准的递归结构,因为它又再次包含了『commands』。这意味着该程序可以一个个的递减一系列的命令。参见『LEX和YACC内部工作原理』一章,阅读更多的递归细节。

第二个规则定义了『command』的内容。我们只假定两种命令。

一个heat_switch由HEAT标识符组成,它后面跟着一个状态,该状态在LEX中定义,为『on』或『off』。

target_set稍微有点复杂,它由TARGET标识符、TEMPERATURE以及一个数字组成。

前面的那个例子只有YACC文件的语法部分,起始在YACC文件中还有其它内容,完整的YACC文件如下:

  • %{
  • #include <stdio.h>
  • #include <string.h>
  • //在lex.yy.c里定义,会被yyparse()调用。在此声明消除编译和链接错误。
  • extern int yylex(void);
  • // 在此声明,消除yacc生成代码时的告警
  • extern int yyparse(void);
  • int yywrap()
  • {
  • return 1;
  • }
  • // 该函数在y.tab.c里会被调用,需要在此定义
  • void yyerror(const char *s)
  • {
  • printf("[error] %s\n", s);
  • }
  • int main()
  • {
  • yyparse();
  • return 0;
  • }
  • %}
  • %token NUMBER TOKHEAT STATE TOKTARGET TOKTEMPERATURE
  • %%
  • 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");
  • };
  • %%

函数yyerror()在YACC发现一个错误的时候被调用,我们只是简单的输出错误信息,但其实还可以做一些更漂亮的事情,参见文档尾的『进阶阅读』部分。

yywrap()函数用于不断的从一个文件中读取数据,当遇到EOF时,你可以再输入一个文件,然后返回0,你也可以使得其返回1,暗示着输入结束。更多细节,参见『YACC和LEX内部如何工作的?』一章。

接着,这里有一个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()函数,并不需要libl来提供。

注意:如果在编译过程中报错说找不到『yylval』,那么在example4.l的#include <y.tab.h>下面加上:

  • 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
  • $

4.2 扩展恒温器,使得其可以接受参数

我们已经可以正确的解析温度计命令了,并且能对一些错误做标记。但也许一些狡猾的人会猜疑说,该解析器并不知道你应该做什么,也没有处理一些你输入的数值。

让我们来添加能读取新的温度参数的功能。为达到此目的,我们得知道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。

接下来,我们就得考察YACC如何来应对。我们来看看新的temperature target规则设置:

  • 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");
  • }

如果现在运行example5,它将输出你所输入的数据。

4.3 解析一个配置文件

让我们再次回顾先前提到的那个配置文件:

  • zone "./www" {
  • type hint;
  • name foo;
  • hello "/hello/world";
  • file "/etc/bind/db.root";
  • };

之前我们已经将LEX文件写好了,接下来只需要编写YACC语法文件,并且对词法分析器做一些修改,使得其可以返回一些值给YACC。

example6中的词法分析器如下:

  • %{
  • #include <stdio.h>
  • #define YYSTYPE char *
  • extern YYSTYPE yylval; // 必须放在#include "y.tab.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;
  • [ \t]+ /* ignore whitespace */;
  • \n /* ignore EOL */;
  • %%

细心的话,你会发现yylval已经改变了!我们不再认为它是一个整数,而是假定为一个char*类型数据。为保持简单性,我们调用strdup并因此耗费了很多内存。但这并不影响你解析这个文件。

我们需要保存字符串的值,在此我们处理的都是一些命名,文件名以及区域命。在下一章,我们将解说如何对付一些复杂类型的数据。

为通知YACC关于yylval的新类型,我们在YACC的语法文件中添加一行:

  • #define YYSTYPE char *

下面是完整的YACC文件,语法比较复杂,建议结合代码画AST树来帮助理解(原文这里的代码有不少问题,下面是修正后的代码,对语法也简化了一点点):

  • %{
  • #include <stdio.h>
  • #include <string.h>
  • #define YYSTYPE char *
  • extern YYSTYPE yylval;
  • //int yydebug = 1; // debug
  • extern int yylex(void);
  • extern int yyparse(void);
  • int yywrap()
  • {
  • return 1;
  • }
  • void yyerror(const char *s)
  • {
  • printf("[error] %s\n", s);
  • }
  • int main()
  • {
  • yyparse();
  • return 0;
  • }
  • %}
  • %token SEMICOLON ZONETOK OBRACE EBRACE QUOTE FILENAME WORD FILETOK
  • %%
  • commands:
  • | commands command SEMICOLON
  • ;
  • command:
  • zone_set
  • ;
  • zone_set:
  • ZONETOK quotedname zonecontent
  • {
  • printf("Complete zone for '%s' found\n", $2);
  • };
  • zonecontent:
  • OBRACE zonestatements EBRACE
  • ;
  • quotedname:
  • QUOTE FILENAME QUOTE
  • {
  • $$ = $2;
  • };
  • zonestatements:
  • | zonestatements zonestatement SEMICOLON
  • ;
  • zonestatement:
  • statements | FILETOK quotedname
  • {
  • printf("A zonefile name '%s' was encountered\n", $2);
  • };
  • statements:
  • | WORD statement
  • {
  • $$ = $2; // seems unnecessary
  • printf("statements, key: %s, value: %s\n", $1, $2);
  • };
  • statement:
  • WORD | quotedname
  • ;
  • %%

我们执行example6的输出文件:

  • # 将配置文件的内容保存到sample.conf文件中,方便测试
  • $ cat sample.conf | ./example6

输出为:

  • statements, key: type, value: hint
  • statements, key: name, value: foo
  • statements, key: hello, value: /hello/world
  • A zonefile name '/etc/bind/db.root' was encountered
  • Complete zone for './www' found

五、生成C++代码的解析器

虽然LEX和YACC的历史要早于C++,但是还是可以用它们来生成一个C++解析器。但我们用LEX来生成C++的词法分析器,YACC并不知道如何直接来处理这些,所以我们不打算这么做。

我认为比较好的做法是,要做一个C++解析器,就需要LEX生成一个C文件,并且让YACC来生成C++代码。但当你这么做的时候,在这个过程中你将会遇到问题,因为C++代码默认情况下并不能找到C的函数,除非你将那些函数定义为extern “C”。

为达此目的,我们在YACC代码中编写一个C开头:

  • extern “C”
  • {
  • int yyparse(void);
  • int yylex(void);
  • int yywrap()
  • {
  • return 1;
  • }
  • }

如果你想声明并改变yydebug函数,你得这样做:

  • extern int yydebug;
  • int main()
  • {
  • yydebug = 1;
  • yyparse();
  • }

这是因为C++中的一个关于定义的规则,即不允许yydebug的多处定义。

你还可能发现,你需要在你的LEX文件中重复#define YYSTYPE,这是由于C++中严格的类型检查(机制)造成的。

按照如下方式来编译:

  • # Makefile
  • example_cpp:
  • lex -o $@.yy.c $@.lex
  • cc -c $@.yy.c -o $@.yy.o
  • yacc -dtv $@.yy -o $@.cpp
  • g++ $@.yy.o $@.cpp -o $@.out

由于-o选项的存在,y.tab.h现在变成example_cpp.hpp,记住这点。

总结: 不要自寻烦恼的在C++中编译你的词法分析器,让它呆在C的领地里。在C++中编写解析器时,(也得)确保向编译器解释清楚,即你的C函数都有一个extern “C”声明。

六、Lex和YACC内部工作原理

在YACC文件中,你定义了你自己的main()函数,它在某个点上调用了yyparse()。YACC会创建你的yyparse()函数,并在y.tab.c中结束该函数。

yyparse()函数读取一个『标识符/值对』(token/value pairs)流,这些流需要事先就提供,这些流可以是你自己手写的代码提供的,也可以是LEX生成的。在我们的示例中,我们把这个工作丢给了LEX。

LEX生成的yylex()函数从文件参数FILE *file中读取字符(文件名为yyin)。如果不设置成yyin,则默认为标准输入,它会输出到yyout中,如果不加设置,就是stdout。你可以在yywrap()函数中修改位于文件尾的yyin.yywrap()函数。这些修改使得你可以打开另一些文件,并继续解析。

如果是这种情况,那么就让yywrap()返回0,如果你想在该文件上结束解析,就让它返回1。

每次yylex()调用都会返回一个整数值,该值代表了一个标识符类型(token type)。它告诉YACC,已经读取了这种标识符。该标识符可以有一个值,它应该存放在yylval变量中。

yylval的默认类型为int,但是你可以修改其类型,通过在YACC文件中#define YYSTYPE。

词法分析器需要能够访问yylval,为达到此效果,(yylval)必须在词法分析器(lexer)中被声明为一个外部变量(extern variable)。原来的YACC忽略了这点,并没有为你干这项工作,所以,你必须添加以下代码到你的词法分析器中,就在#include <y.tab.h>下面:

  • extern YYSTYPE yylval;

当今多数人使用的Bison已经为你把这事自动做好了。

6.1 标识符的值(token values)

在前面我已经说过,yylex()需要返回它遇到了一个什么标识符类型,并将其值存储在yylval中。当这些标识符在%token命令中定义时,它们就被赋予了一些数字ID,从256开始。

由于这个事实,(我们)可以将所有的ascii字符当作标识符。假定你要写一个计算器,到现在为止,我们可能已经这样写了其词法分析器:

  • [0-9]+ yylval = atoi(yytext); return NUMBER;
  • [ \n]+ /* eat whitespace */
  • - return MINUS;
  • \* return MULT;
  • \+ return PLUS;

YACC文件可能是这样:

  • exp : NUMBER
  • | exp PLUS exp
  • | exp MINUS exp
  • | exp MULT exp

没有必要弄这么复杂。用字符作为速记法来作为标识符的数字ID,我们可以这样来重写我们的词法分析器:

  • [0-9]+ yylval = atoi(yytext); return NUMBER;
  • [ \n]+ /* eat whitespace */
  • . return (int) yytext[0];

最后一行匹配任何的单个字符,否则就是不匹配字符。

而YACC的语法文件则是这样:

  • exp : NUMBER
  • | exp '+' exp
  • | exp '-' exp
  • | exp '*' exp

这样更加简短而直观,你就不必在文件头用%token来定义那些ascii字符了。

另一方面,这样构造还有一些好处,它可以匹配所有丢给它的东西,而避免了将那些不匹配的输入输出到标准输出的默认行为。如果用户在当前计算器上输入一个’^’字符,将会导致一个解析错误,而不是将其输出到标准输出中。

6.2 递归:’right is wrong’

递归是YACC一个极其重要的特性。没有递归的话,你就确定一个文件是由一系列独立的命令组成还是由语句组成。由于YACC自身的特性,它只对第一个规则或那个你将其设计为『起始规则』的规则感兴趣。起始规则用’%start’符号标记。

YACC中的递归以两种形式出现,左递归和右递归。左递归是你应该经常使用的,它们看起来如下:

  • commands : /* empty */
  • | commands command

这是在说,一个command要么为空,要么它包含了更多的commands,后面再跟一个command。YACC的这种工作方式意味着它现在可以轻易的剔除单个的command群并一步步简化(处理)。

拿上面的例子和下面的右递归做比较:

  • commands : /* empty */
  • | command commands

但是这样做开销有点大,如果(commands)是%start规则(即起始规则),那么YACC会将所有的commands保存在你的栈数据中(file on the stack),这将耗费大量内存。所以,在解析长的语句时,务必使用左递归,例如整个文件。但有时难以避免右递归,不过,如果你的语句并不太长,你就没有必要越轨使用左递归。

如果你有一些东西来终结(因此而分割)你的commands,右递归就非常适合了,但开销还是有点大:

  • commands : /* empty */
  • | command SEMICOLON commands

正确的做法是使用左递归(这也不是我发明的):

  • commands : /* empty */
  • | commands command SEMICOLON

本文档的早期版本错误的使用了右递归。Markus Triska友好的提示我们这点(错误)。

6.3 高级yylval: %union

现在,我们需要定义yylval的类型。但是这并不一直恰如其当。我们可能会多次这样做,因为需要处理多种数据类型。回到我们假定的那个恒温器,可能你想选择控制一个加热器,例如:

  • heater mainbuilding
  • Selected ‘mainbuilding’ heater
  • Target temperature 23
  • ‘mainbuilding’ heater target temperature now 23

这样的话,就要求yylval是一个union,它可以存储字符串,也可以存储整数,但并不是同时存储。

回忆之前我们讲过,我们提前通知YACC哪种yylval类型会要处理是通过定义YYSTYPE来实现。同样,我们可以定义YYSTYPE为一个union,YACC中有一种简便的方法来实现,即%union语句。

在example4基础上,我编写example7的YACC语法:

  • %token TOKHEATER TOKHEAT TOKTARGET TOKTEMERATURE
  • %union
  • {
  • int number;
  • char *string;
  • }
  • %token <number> STATE
  • %token <number> NUMBER
  • %token <string> WORD

我们定义了union,它只包含一个整数和一个字符串。接着使用了一个扩展的%token语法,我们向YACC解释了应该获取union哪个部分的标识符。

在本例中,我们让STATE标识符用一个整数(来表示),这跟之前一样。NUMBER同理,我们之前用来读取温度。

但是WORD有所改变,它声明为需要一个字符串。

词法解析器文件有所改变:

  • %{
  • #include <stdio.h>
  • #include "y.tab.h"
  • %}
  • %%
  • [0-9]+ yylval.number=atoi(yytext); return NUMBER;
  • heater return TOKHEATER;
  • heat return TOKHEAT;
  • on|off yylval.number =! strcmp(yytext,"on"); returnSTATE;
  • 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自动选择了union中的’string’成员。注意这里$2中存储的一份拷贝,在后面它会告诉用户发送命令到哪个heater(需要在C文件头定义char *heater):

  • target_set:
  • TOKTARGET TOKTEMPERATURE NUMBER
  • {
  • printf("\tHeater '%s' temperature set to %d\n",
  • heater, $3);
  • };

6.4 自定义YY_INPUT指向字符串而非标准输入

很多情况下,我们不希望从标准输入解析,而希望解析给定的字符串。实现方法是自定义实现YY_INPUT,具体做法如下:

  • /* LEX 文件 */
  • extern int lex_custom_input(char* buf, unsigned long *num_read,int max_read);
  • #undef YY_INPUT
  • #define YY_INPUT(b,r,s) lex_custom_input(b,&r,s)
  • /* C 文件 */
  • static int g_read_offset;
  • #define MAX_TEXT_LEN 1024
  • static char s_text_buf[MAX_TEXT_LEN];
  • int lex_custom_input(char* buf, unsigned long *num_read,int max_read)
  • {
  • int num_to_read = max_read;
  • int remain = strlen(s_text_buf) - g_read_offset;
  • num_to_read = num_to_read>remain ? reamin:num_to_read;
  • memcpy(buf, s_text_buf + g_read_offset, num_to_read);
  • *num_read = num_to_read;
  • g_read_offset += num_to_read;
  • return 0;
  • }

七、调试

YACC中有许多调试反馈信息。这些调试信息的代价有点高,所以你需要提供一些开关来打开它。

当调试你的语法时,在YACC命令行中添加—debug和—verbose选项,在你的C文件头中添加以下语句:

  • int yydebug = 1;

这将生成一个y.output文件,其中说明了所创建的那个状态机。

当你运行那个生成的二进制文件,它将输出很多运行时信息。里面包含当前所运行的状态机以及读取到的一些标识符。

Peter Jinks写了一篇关于调试的文章,他在其中讲述了一些常见得错误以及如何修正这些错误。

7.1 状态机

YACC解析器在内部运行的是一个『状态机』,该状态机可以有多种转台。接着有多个规则来管制状态间的相互转化。任何内容都是从『root』规则开始。

在example7的y.output文件中:

  • state 0
  • ZPONETOK , 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 satte 3

默认情形下,这个状态机从『commands』规则开始递减演化,这也是我们之前的那个递归规则,它定义了『commands』并从单个的『command』进行构造,后面跟着一个分号,然后可能再跟着更多的『commands』。

这个状态机不断递减演化,直到它遇到某些它能理解的东西,在本例中,为一个ZONETOK,也即单词『zone』。然后转化到状态1,在此,进一步处理一个zone command:

  • state 1
  • zone_set -> ZONETOK .quotedname zonecontent (rule 4)
  • QUOTE , and go to state 4
  • Quotedname go to state 5

第一行中有一个『.』,它用来说明我们所处的位置:即刚刚识别到了一个ZONETOK,目前正在寻找一个『quotedname』。显然,一个『quotedname』会以QUOTE开始,它将我们转化到状态4。

为进一步跟踪,用在『调试』章节中提到的标志来编译example7。

7.2 冲突:『shift/reduce』以及『reduce/reduce』

一旦YACC警告你出现了冲突,那么你的麻烦来了。要解决这些问题显得是一种技巧形式,它会教会很多关于你的语言的东西。比你想知道的要多的多的内容。

问题萦绕于如何来翻译一系列标识符。假定我们定义了一种语言,它需要接受一下两种命令:

  • delete heater all
  • delete heater number1

为达到此目的,我们的语法为:

  • delete_heaters :
  • TOKDELETE TOKHEATER mode
  • {
  • Deleteheaters($3);
  • }
  • mode : WORD
  • delete_a_heater:
  • TOKDELETE TOKHEATER WORD
  • {
  • delete($3);
  • }

也许你已经嗅到了麻烦的味道。状态机从读取单词『word』开始,接着它根据下一个标识符觉得转换到何种状态。接下来的标识符可以是『mode』,它指定了如何来删除heater,或者是将要删除的heater。

然而这里的麻烦是,对于这两个命令,下一个标识符都将是一个WORD。YACC因此也无法决定下一步该干嘛。这回导致一个『reduce/reduce』警告,而下一个警告就是『delete_a_heater』节点在状态转化图中永远也不能达到。

这种情况的冲突容易解决(例如,重命名第一个命令为『delete all heater』,或者将『all』作为一个分开的标识符),但有时,(要解决冲突)却非常困难。 通过『--verbose』参数生成的y.output文件可以提供给你极大的帮助。

八、进一步阅读

GNU YACC(Bison)有一个非常棒的.info文件,在其中很好的记录了YACC的语法。其中只提到了一次LEX,然而它还是很棒的。你可以用Emacs中那个非常好的『pinfo』工具阅读.info文件。同时,在Bison的主页上可以获得它:Bison手册

FLEX有一个不错的主页。如果你粗略了解了FLEX所作所为,那将是非常有益的。FLEX的手册也可以联机获取。

在这些关于YACC和LEX的介绍之后,你可能觉得你想需要更多的信息。下面的书籍我们都还没有阅读,但他们听起来不错:

  • 《Bison—The Yacc-Compitible Parser Generator》——Charles Donnelly && Richard Stallman.
  • 《Lex & Yacc》——John R. Levine, Tony Mason ,Doug Brown.
  • 《Compilers: Principles, Techniques, and Tools》——By Alfred V.Aho,Ravi
城东书院 www.cdsy.xyz
方便获取更多学习、工作、生活信息请关注本站微信公众号城东书院 微信服务号城东书院 微信订阅号
推荐内容
相关内容
栏目更新
栏目热门
本栏推荐