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 分词器(示例 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”,其次,我们不再打印东西,而是返回标记的名称。这种改变是因为我们现在将所有内容都提供给 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 在内部工作”章节中介绍了关于递归的重要细节。
第二条规则定义了什么是命令。我们只支持两种命令,“heat_switch”(加热开关)和“target_set”(目标设置)。这就是 |-符号所代表的含义 - “一个命令由 heat_switch 或 target_set 组成”。
heat_switch 由 HEAT 标记组成,它只是单词“heat”(加热),后跟一个 state(状态)(我们在 Lex 文件中将其定义为“on”(开)或“off”(关))。
更复杂一些的是 target_set,它由 TARGET 标记(单词“target”(目标))、TEMPERATURE 标记(单词“temperature”(温度))和一个数字组成。
上一节仅显示了 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() 函数,它除了启动一切之外什么也不做。
最后一行简单地定义了我们将要使用的标记。如果使用“-d”选项调用 YACC,则会使用 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
$
这与我们最初设定的目标不太一致,但为了保持学习曲线的可管理性,并非所有酷炫的东西都可以一次呈现出来。
正如我们所见,我们现在可以正确解析恒温器命令,甚至可以正确标记错误。但是,正如你可能从委婉的措辞中猜到的那样,程序不知道它应该做什么,它没有传递你输入的任何值。
让我们首先添加读取新目标温度的能力。为此,我们需要学习 Lexer 中的 NUMBER 匹配,将其转换为整数值,然后可以在 YACC 中读取该值。
每当 Lex 匹配到目标时,它都会将匹配的文本放入字符串“yytext”中。YACC 反过来期望在变量“yylval”中找到一个值。在示例 5 中,我们看到了显而易见的解决方案
%{
#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”和“off”匹配会产生更快的代码,但我想要展示一个更复杂的规则和操作以进行更改。
现在我们需要学习 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");
}
;
如果你现在运行 example5,它会正确输出你输入的内容。
让我们回顾一下我们之前提到的配置文件的一部分
zone "." {
type hint;
file "/etc/bind/db.root";
};
请记住,我们已经为此文件编写了一个 Lexer。现在我们需要做的就是编写 YACC 语法,并修改 Lexer,使其以 YACC 可以理解的格式返回值。
在示例 6 的 lexer 中,我们看到
%{
#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 已经改变了!我们不再期望它是一个整数,但实际上假设它是一个 char *。为了保持简单,我们调用 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,}。
quotedname:
QUOTE FILENAME QUOTE
{
$$=$2;
}
本节定义了什么是“quotedname”(带引号的名称):QUOTEs 之间的 FILENAME。然后它说了一些特别的东西:带引号的名称标记的值是 FILENAME 的值。这意味着带引号的名称的值是不带引号的文件名。
这就是神奇的“$$=$2;”命令的作用。它说:我的值是我的第二部分的值。当带引号的名称现在在其他规则中被引用,并且你使用 $-构造访问其值时,你会看到我们在此处使用 $$=$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
这定义了一个块,以及可能在其中找到的“statements”(语句)。
执行时,输出如下所示
$ ./example6
zone "." {
type hint;
file "/etc/bind/db.root";
type hint;
};
A zonefile name '/etc/bind/db.root' was encountered
Complete zone for '.' found