下一页 上一页 目录

4. YACC

YACC 可以解析由带有特定值的标记组成的输入流。这清楚地描述了 YACC 与 Lex 的关系,YACC 不知道“输入流”是什么,它需要预处理过的标记。虽然你可以编写自己的分词器,但我们将完全交给 Lex 来处理。

关于语法和解析器的说明。当 YACC 问世时,该工具被用来解析编译器的输入文件:程序。用计算机编程语言编写的程序通常 *不是* 模棱两可的 - 它们只有一个含义。因此,YACC 不处理歧义,并且会抱怨移位/归约或归约/归约冲突。关于歧义和 YACC “问题”的更多信息可以在“冲突”章节中找到。

4.1 一个简单的恒温器控制器

假设我们有一个恒温器,我们想使用一种简单的语言来控制它。与恒温器的会话可能如下所示

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 文件

上一节仅显示了 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
$

这与我们最初设定的目标不太一致,但为了保持学习曲线的可管理性,并非所有酷炫的东西都可以一次呈现出来。

4.2 扩展恒温器以处理参数

正如我们所见,我们现在可以正确解析恒温器命令,甚至可以正确标记错误。但是,正如你可能从委婉的措辞中猜到的那样,程序不知道它应该做什么,它没有传递你输入的任何值。

让我们首先添加读取新目标温度的能力。为此,我们需要学习 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,它会正确输出你输入的内容。

4.3 解析配置文件

让我们回顾一下我们之前提到的配置文件的一部分

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


下一页 上一页 目录