在MiniOB源码中学习使用Flex与Bison解析SQL语句-第二节
上一节我们学习了MiniOB解析Create Table语句的方式。
这一节的目的是实现一个能够解析Create Table语句的SQL解析器,在这个过程中学习Bison和Flex的使用方式。
在本节中,使用Flex进行词法分析将放在语法分析之后,而语义分析和语法分析都是在Bison文件中编写。
语法分析先明确需要哪些单词,然后词法分析再定义解析出这些单词的方法。
定义数据结构存放Create Table语句的解析结果
// Sql_Node_Defs.hpp
#pragma once
#include <string>
#include <vector>enum class Attribute_Type {INT,CHAR
};enum class Sql_Command_Type {CREATE_TABLE
};struct Attribute_Info {std::string attr_name;Attribute_Type attr_type;int length;
};struct Sql_Node {Sql_Command_Type command_type;Sql_Node(Sql_Command_Type type) : command_type(type) {}virtual ~Sql_Node() = default;
};struct Create_Table_Sql_Node : Sql_Node {std::string relation_name;std::vector<Attribute_Info> attr_infos;std::vector<std::string> primary_keys;Create_Table_Sql_Node(Sql_Command_Type type) : Sql_Node(type) {}
};
bison文件的构成
bison文件一般以.y
为后缀。bison文件内部通常被分为三部分:
/*定义部分,定义单词、产生式的语义值类型等,或者引入头文件*/
%%
/*规则部分,用于定义产生式*/
%%
/*用户子例程部分*/
这三个部分之间用%%
来进行分割。
产生式是在规则部分定义的,可以按照第一节所描述的那样,定义产生式和语义计算代码对Create Table语句进行解析:
command: command_wrapper opt_semicolon{$$ = $1;};command_wrapper: create_table_stmt {$$ = $1;};create_table_stmt:CREATE TABLE ID LBRACE attr_def_list primary_key RBRACE{$$ = new Create_Table_Sql_Node(Sql_Command_Type::CREATE_TABLE);$$->relation_name = $3;$$->attr_infos.swap(*$5);delete $5;if ($6 != nullptr){$$->primary_keys.swap(*$6);delete $6;}};attr_def_list:attr_def {$$ = new std::vector<Attribute_Info>;$$->emplace_back(*$1);delete $1;}| attr_def_list COMMA attr_def{$$ = $1;$$->emplace_back(*$3);delete $3;};attr_def:ID type {$$ = new Attribute_Info;$$->attr_name = $1;$$->attr_type = $2;$$->length = 4;}|ID type LBRACE NUMBER RBRACE{$$ = new Attribute_Info;$$->attr_name = $1;$$->attr_type = $2;$$->length = $4;};type:INT { $$ = Attribute_Type::INT; }| CHAR { $$ = Attribute_Type::CHAR; };primary_key:/* empty */ {}| COMMA PRIMARY KEY LBRACE attr_list RBRACE{$$ = $5;};attr_list:ID {$$ = new std::vector<std::string>;$$->emplace_back($1);}| attr_list COMMA ID {$$ = $1;$$->emplace_back($3);};opt_semicolon: /* empty */| SEMICOLON;
这里模仿MiniOB的产生式定义来编写Create Table的语义计算。
只编写了规则部分后,我直接使用bison对其进行操作,让bison给我生成相应的语法分析代码,但是结果并不如我所愿:
➜ sql_parser bison --language=c++ -o sql_parser.cpp --defines=sql_parser.hpp sql.y
sql.y:18.5-10: error: symbol 'CREATE' is used, but is not defined as a token and has no rules18 | CREATE TABLE ID LBRACE attr_def_list primary_key RBRACE| ^~~~~~
sql.y:18.12-16: error: symbol 'TABLE' is used, but is not defined as a token and has no rules18 | CREATE TABLE ID LBRACE attr_def_list primary_key RBRACE| ^~~~~
sql.y:18.18-19: error: symbol 'ID' is used, but is not defined as a token and has no rules18 | CREATE TABLE ID LBRACE attr_def_list primary_key RBRACE| ^~
sql.y:18.21-26: error: symbol 'LBRACE' is used, but is not defined as a token and has no rules18 | CREATE TABLE ID LBRACE attr_def_list primary_key RBRACE| ^~~~~~
sql.y:18.54-59: error: symbol 'RBRACE' is used, but is not defined as a token and has no rules18 | CREATE TABLE ID LBRACE attr_def_list primary_key RBRACE| ^~~~~~
sql.y:40.21-25: error: symbol 'COMMA' is used, but is not defined as a token and has no rules40 | | attr_def_list COMMA attr_def| ^~~~~
sql.y:67.5-7: error: symbol 'INT' is used, but is not defined as a token and has no rules67 | INT { $$ = Attribute_Type::INT; }| ^~~
sql.y:68.7-10: error: symbol 'CHAR' is used, but is not defined as a token and has no rules68 | | CHAR { $$ = Attribute_Type::CHAR; }| ^~~~
sql.y:72.5-10: error: symbol 'NUMBER' is used, but is not defined as a token and has no rules72 | NUMBER { $$ = $1; }| ^~~~~~
sql.y:77.13-19: error: symbol 'PRIMARY' is used, but is not defined as a token and has no rules77 | | COMMA PRIMARY KEY LBRACE attr_list RBRACE| ^~~~~~~
sql.y:77.21-23: error: symbol 'KEY' is used, but is not defined as a token and has no rules77 | | COMMA PRIMARY KEY LBRACE attr_list RBRACE| ^~~
sql.y:97.7-15: error: symbol 'SEMICOLON' is used, but is not defined as a token and has no rules97 | | SEMICOLON| ^~~~~~~~~
这里说CREATE
,TABLE
正在被使用,但它既没有被定义成token,也不是一个产生式左部的非终结符。
bison所说的token是什么呢?其实就是终结符。也就是说,bison中token是需要定义的。现在的问题是如何定义一个token呢?
定义token
我们可以在bison文件的定义部分,使用如下的代码定义token
%token CREATE
%token TABLE
%token ID
%token LBRACE
%token RBRACE
%token COMMA
%token INT
%token CHAR
%token NUMBER
%token PRIMARY
%token KEY
%token SEMICOLON
添加完成之后,再次运行使用bison生成文件,能够得到sql_parser.hpp
和sql_parser.cpp
两个文件。
虽然,bison生成文件没有报错,但是生成出来的文件却是存在问题。经过我的一番研究,发现问题出现在语义值上。
bison中默认的语义值是int
。
// create_table_stmt: CREATE TABLE ID LBRACE attr_def_list primary_key RBRACE{yylhs.value = new Create_Table_Sql_Node(Sql_Command_Type::CREATE_TABLE);yylhs.value->relation_name = yystack_[4].value;yylhs.value->attr_infos.swap(*yystack_[2].value);delete yystack_[2].value;if (yystack_[1].value != nullptr){yylhs.value->primary_keys.swap(*yystack_[1].value);delete yystack_[1].value;}}
这是create_table_stmt
产生式的语义计算部分,文章里面看不到错误。我现在告诉你yylhs.value
的类型是int
,而现在我尝试将一个指针赋值给int
,从这一步开始其实我就错了。
现在的问题如何为不同的非终结符或者非终结符定义不同的语义值类型,比如create_table_stmt
它的语义值类型就应该是Create_Table_Sql_Node
,而number
这个产生式的语义值类型才应该是int
。
bison中的语义值似乎只能够存储在一个变量中,因此这个变量必须能够表示所有产生式所需要的类型,而这些类型之间不一定存在关系,最好的办法就是使用union
,让这些类型共用空间。
因此,我们需要在定义部分添加上语义值union的定义:
%{
/* %{ c/cpp代码 %} 中间的代码将会放到.cpp文件的最开始处 */
#include "Sql_Node_Defs.hpp"
#include "lex_sql.hpp"
#include <iostream>int yyerror(const char* msg)
{std::cerr << msg << std::endl;return 0;
}%}%token CREATE
%token TABLE
%token LBRACE
%token RBRACE
%token COMMA
%token INT
%token CHAR
%token PRIMARY
%token KEY
%token SEMICOLON%union {Sql_Node* sql_node; Create_Table_Sql_Node* create_table_sql_node;std::vector<Attribute_Info>* attr_infos;Attribute_Info* attr_info;Attribute_Type attr_type;int number;std::vector<std::string>* key_list;char* cstring;
}/* 有语义值的终结符需这样定义 */
%token <number> NUMBER
%token <cstring> ID/* 定义非终结符的语义值类型 */
/* <> 里面放的是union中的某个类型的名字 */
%type <sql_node> command
%type <sql_node> command_wrapper
%type <create_table_sql_node> create_table_stmt
%type <attr_infos> attr_def_list
%type <attr_info> attr_def
%type <attr_type> type
%type <key_list> primary_key
%type <key_list> attr_list
再次使用bison生成文件,观察生成的.hpp和.cpp文件,旧的问题解决了,但新的问题出现了。
sql_parser.hpp
文件中没有包含Sql_Node_Defs.hpp
文件,导致声明语义值union时报红。sql_parser.cpp
中说yylex()
方法未定义,这个方法将由flex生成,这里先不管。sql_parser.cpp
中说yyerror()
方法未定义,此方法需要我们自行定义,用于处理bison发生的错误。
实际上MiniOB也存在问题1,但是编译却不会失败,因为编译是针对.cpp
文件而言的,我们看看预处理后sql_parser.cpp
中Sql_Node_Defs.hpp
的内容会不会在sql_parser.hpp
之前就可以了。
注意这里我没有对生成的文件做任何修改,以下是sql_parser.cpp
开头的内容:
// First part of user prologue.
#line 1 "sql.y"#include "Sql_Node_Defs.hpp"#line 45 "sql_parser.cpp"#include "sql_parser.hpp"
可以发现Sql_Node_Defs.hpp
先于sql_parser.hpp
被包含进来了,这就说明Sql_Node_Defs.hpp
的类型定义是能够覆盖sql_parser.hpp
,因此不会出现编译错误。
那么接下来的问题就是如何让flex提供一个符合语法分析需求的yylex
方法了。
前面使用bison生成代码的命令是:
bison --language=c++ -o sql_parser.cpp --defines=sql_parser.hpp sql.y
我发现sql_parser.hpp
中的类型定义都是一些较为复杂的类,这样可能不好和flex结合起来使用,因此这里去除掉--language=c++
让其生成纯C语言的代码。
定义yyerror方法
最基本的yyerror()
能够定义为:
int yyerror(const char* msg);
如果我们还需要报错时的位置信息,则可以声明为:
int yyerror(YYLTYPE* loc, const char* msg);
这里我在sql.y
这个bison文件的定义部分将yyerror()
定义为:
int yyerror(const char* msg)
{std::cerr << msg << std::endl;return 0;
}
定义完成后就消除了sql_parser.cpp
中与yyerror
相关的报错。
flex与bison结合使用
现在我们的解析器还缺少一个yylex()
进行词法分析,通过观察代码:
/* YYCHAR is either empty, or end-of-input, or a valid lookahead. */if (yychar == YYEMPTY){YYDPRINTF ((stderr, "Reading a token\n"));yychar = yylex ();}if (yychar <= YYEOF)
我发现yylex()
方法不接收参数,并且它需要返回一个表示bison中token的枚举。因此,我猜测yylex()
需要在识别到某个/类单词时,将我们在bison文件中定义表示token的枚举值返回出来。
于是,我们就可以得出yylex()
与bison结合使用的关键是:在识别出bison中所需要的token是返回对应的枚举值。
flex识别单词
flex文件以.l
后缀结尾并且结构和bison文件类似,有定义、规则和用户函数部分。
定义部分和用户函数部分和bison文件的很类似,这里主要介绍flex文件的规则部分。
其规则部分主要由多个正则表达式 { 动作代码(C语言) }
的形式组成,意思就是当匹配当某个正则表达式时,执行yylex()
函数就会执行动作代码。
识别的过程中会出现一个单词匹配多个正则表达式的情况,会依次使用最长匹配原则和规则定义顺序来确定执行哪个动作。若一个单词不匹配任何正则表达式,其动作默认为将单词打印到标准输出中。
我初步编写了一个lex_sql.l
文件
%{
/* 先包含Sql_Node_Defs.hpp是因为sql_parser.hpp里面的类型未定义,而先包含可以让其编译时不出错 */
#include "Sql_Node_Defs.hpp"
/* 动作部分的代码会放在yylex()中因此动作部分将会返回bison所生成的枚举值,必要时会设置上语义值 */
#include "sql_parser.hpp" /* cstdlib和cstring中的一些函数会用在动作代码中 */
#include <cstdlib>
#include <cstring>
%}/* 不区分大小写 */
%option case-insensitive/* 给常用的正则表达式起别名,这些别名在规则部分可以使用{}来引用 */
WHITE_SPACE [\ \t\b\f\n]
DIGIT [0-9]+
ID [A-Za-z_]+[A-Za-z0-9_]*
DOT \.
QUOTE [\'\"]/* yylval可以认为是sql_parser.hpp中定义的union结构的全局变量 */
%%
{WHITE_SPACE} {
}
CREATE {return CREATE;
}
TABLE {return TABLE;
}
"(" {return LBRACE;
}
")" {return RBRACE;
}
"," {return COMMA;
}
INT {return INT;
}
CHAR {return CHAR;
}
PRIMARY {return PRIMARY;
}
KEY {return KEY;
}
";" {return SEMICOLON;
}
{DIGIT}+ { yylval.number=atoi(yytext); return NUMBER;
}
{ID} {yylval.cstring=strdup(yytext);return ID;
}
%%
使用以下命令生成相应的.hpp
和.cpp
文件:
flex -o lex_sql.cpp --header-file=lex_sql.hpp lex_sql.l
我们现在已经有了词法分析、语法分析、语义分析的代码了,接下来的任务就是将其链接起来形成一个程序:
$ g++ -c lex_sql.cpp -o lex_sql.o
$ g++ -c sql_parser.cpp -o sql_parser.o
$ g++ lex_sql.o sql_parser.o -o sql_parser
/usr/bin/ld: /usr/lib/gcc/x86_64-linux-gnu/13/../../../x86_64-linux-gnu/Scrt1.o: in function `_start':
(.text+0x1b): undefined reference to `main'
/usr/bin/ld: lex_sql.o: in function `yylex()':
lex_sql.cpp:(.text+0x52e): undefined reference to `yywrap'
/usr/bin/ld: lex_sql.o: in function `yyinput()':
lex_sql.cpp:(.text+0x117e): undefined reference to `yywrap'
collect2: error: ld returned 1 exit status
链接失败了,首要问题就是不论是sql_parser.cpp
还是lex_sql.cpp
都没有定义main
函数;其次就是lex_sql.cpp
中yywrap()
未定义。
yywrap()
用于指示词法分析器(lexer)是否已经到达了输入的末尾,并决定是否继续处理更多的输入。比如,一个文件词法分析到末尾时,后面还有另外一个文件需要进行词法分析,此时就需要使用yywrap()
。
这里我在定义部分使用%option noyywrap
明确输入到达末尾时不会有更多输入。
再次执行编译链接操作,就剩下一个缺少main
函数的错误了。
在sql.y
文件的用户例程部分添加上main
函数:
int main(int argc, char** argv)
{// yyparse的定义可以在sql_parser.cpp中查看return yyparse();
}
之后再进行编译和链接,发现没有出现问题sql_parser
可执行程序成功编译出来。
运行sql_parser
,你会发现它等待你输入,这是因为flex生成的词法分析器,默认你是从标准输入中输入数据的,因此它会等待你输入一行文本数据,之后bison的语法分析和语义计算才能进行。
我们可以往其中输入一条create table语句:
create table student(s_id int, s_name char(10), primary key(s_id));
你会发现程序未报错,但也没有输出任何东西,只是等待你的输入。如果你输入了不符合语法的语句那么它就会报错,你可以尝试把student
改成数字,它就会出现语法错误。
虽然现在我们的Create Table语句的解析器编写成功了,但是我们可以发现这个解析器的输入只能够是标准输入,但如果我们想要让它接收别的输入,比如:位于内存中的字符串或者是一个文件,我们该怎么做?
并且yyparse()
语法解析出来的数据结构,yyparse()
之外似乎是获得不了的,这该怎么解决?
总的来说存在两个问题:
yyparse()
的输入(更准确来说是yylex()
的输入)如何重定向。- 针对此问题,MiniOB的解决方法是:使用flex中提供的一些函数完成输入的重定向,接下来我会详细说明该怎么做。
- 如何获取
yyparse()
语义计算出来数据结构,在本例中是Sql_Node*
。- 针对此问题,MiniOB的解决方法是:为
yyparse()
定义输入参数,在规则部分设置这个输入参数,从而达成返回数据结构的目的。
- 针对此问题,MiniOB的解决方法是:为
重定向yylex的输入
我们可以使用yy_scan_string()
这个flex生成的函数来将yylex()
的输入变成一个字符串。
根据这个函数的功能,我将位于sql.y
这个bison文件的main
修改为了:
int main(int argc, char** argv)
{if (argc < 2){std::cerr << "Usage: sql_parser <sql>\n";return 1;}int ret = 0;YY_BUFFER_STATE buffer = yy_scan_string(argv[1]);ret = yyparse();// 释放已分析完的缓冲区yy_delete_buffer(buffer);return ret;
}
接着就可以使用以下命令来解析create table语句了:
./sql_parser "create table student(s_id int, s_name char(10), primary key(s_id));"
由于我们没有获取到语义分析到的结果,因此这里不会输出任何信息。但不输出任何信息本身就告诉我们词法分析和语法分析都没有问题。
接下来就是想办法获取到语义计算的结果,来看看我们的语义计算部分是正确还是错误的。
获取语义计算的结果
在bison文件的定义部分添加这条语句:
%parse-param { Sql_Node*& sql_node }
这样的话yyparse()
方法就需要传入Sql_Node**
才能够调用:
int yyparse (Sql_Node*& sql_node);
参数是不会影响yyparse
原本的执行逻辑,赋予yyparse
参数的原因是我们的语义计算部分需要。
因此,我们这里可以将command
产生式的代码修改成:
command: command_wrapper opt_semicolon{$$ = $1;sql_node = dynamic_cast<Sql_Node*>($$);};
并且main
函数也要做出改变,因为不能再空参调用了:
int main(int argc, char** argv)
{if (argc < 2){std::cerr << "Usage: sql_parser <sql>\n";return 1;}int ret = 0;Sql_Node* sql_node = nullptr;YY_BUFFER_STATE buffer = yy_scan_string(argv[1]);ret = yyparse(sql_node);yy_delete_buffer(buffer);if (sql_node != nullptr && sql_node->command_type == Sql_Command_Type::CREATE_TABLE){auto create_table_node = dynamic_cast<Create_Table_Sql_Node*>(sql_node);std::cout << "Create Table Command:" << std::endl;std::cout << "table name: " << create_table_node->relation_name << std::endl;for (int i = 0; i < create_table_node->attr_infos.size(); ++i){auto& attr_info = create_table_node->attr_infos[i];const char* type_str = nullptr;if (attr_info.attr_type == Attribute_Type::INT)type_str = "INT";else if (attr_info.attr_type == Attribute_Type::CHAR)type_str = "CHAR";elsetype_str = "UNKNOWN";std::cout << "attribute: name=" << attr_info.attr_name<< ",type=" << type_str << ",length=" << attr_info.length << std::endl;}std::cout << "primary keys: ";for (auto& attr_name : create_table_node->primary_keys){std::cout << attr_name << " ";}std::cout << std::endl;}return ret;
}
bison生成文件,然后编译链接,发现yyparse()
调用yyerror()
的方式变了。
原本空参的时候yyparse()
调用的yyerror()
只接受一个const char*
参数,但是添加了Sql_Node*&
之后就变了:
int yyerror(Sql_Node*& sql_node, const char* msg);
也就是说添加到yyparse()
的参数也会同步添加到yyerror()
中,修正的方法很简单,修改yyerror
的声明和定义(添加上Sql_Node*&
)。
运行:
$ ./sql_parser "create table student(s_id int, s_name char(10), primary key(s_id));"
Create Table Command:
table name: student
attribute: name=s_id,type=INT,length=4
attribute: name=s_name,type=CHAR,length=10
primary keys: s_id
这里输出的内容可以证明语义分析方面应该是没有问题的。
如果我们想要将这个词法分析、语法分析和语义分析的过程封装成一个函数,可以对main
函数稍加改造封装成这样:
int my_parse(const char* sql_str, Sql_Node* sql_node)
{int ret = 0;YY_BUFFER_STATE buffer = yy_scan_string(sql_str);ret = yyparse(sql_node);yy_delete_buffer(buffer);return ret;
}
这样的话我们使用my_parse()
函数就能够将一个SQL语句解析成Sql_Node
(在这个例子中支持Create Table)。
源代码链接
下一节将讲述如何MiniOB中的表达式是如何解析的。