编译器前端简介 Flex&Bison/ANTLR4/Boost.Spirit

词法&语法分析基础

将文本转化为可以执行的程序一般需要词法分析、语法分析、语义分析和后端处理等步骤。如非学习从头开始写这些工具其实非常浪费时间,所以一般使用现成的工具生成语法解析代码

本文所有的部分参考资料:

开源工具:lex/yacc、flex/bison、python PLYANTLR4Boost Spirit

词法分析

处理语言的第一个组成部分是词法分析器(lexical analyzer、lexer 或者 scanner),词法分析将文本分割为单词(token)序列

词法分析可用的工具有 lex 和 flex ,后者是前者的开源增强版本。词法分析器相对简单,完全可以自己编写,所以正式的编程语言一般都不会使用 lex 等工具

词法分析最基础的工具是正则表达式,无论是 flex 等工具还是手写词法分析器,正则表达式一般都是基础

单词类型

几乎所有语言都支持下面三种单词类型:

  • 标识符:变量名、函数名或者类名,为了简单,运算符在 Stone 语言中中也被看作标识符
    • [A-Z_a-z][A-Z_a-z0-9]*,普通的标识符
    • [A-Z_a-z][A-Z_a-z0-9]*|==|<=|>=|&&|\|\||\p{Punct} ,把符号也看作标识符。 \p{Punct} 匹配标点符号
  • 整型字面量:([0-9]+)
  • 字符串字面量:"(\\"|\\\\|\\n|[^"])*"

简单的词法分析过程

每次读取一行文本,通过正则表达式匹配并分类各单词从而形成单词( token) 数组。下面是截取 Stone Java 实现中的 Lexer 类部分代码,C++ 和 Java 中正则表达式的语法标准与实现不同,使用 C++ 实现时可以考虑分别匹配不同类型单词然后整合

理解下面代码需要先了解 java regex 工具中的 group 概念

public static String regexPat
    = "\\s*((//.*)|([0-9]+)|(\"(\\\\\"|\\\\\\\\|\\\\n|[^\"])*\")"
    + "|[A-Z_a-z][A-Z_a-z0-9]*|==|<=|>=|&&|\\|\\||\\p{Punct})?";

// 处理一行文本并根据匹配结果为不同的单词生成不同的 token 对象
protected void addToken(int lineNo, Matcher matcher) {
    String m = matcher.group(1);
    if (m != null) // if not a space
        if (matcher.group(2) == null) { // if not a comment
            Token token;
            if (matcher.group(3) != null)// if number
                token = new NumToken(lineNo, Integer.parseInt(m));
            else if (matcher.group(4) != null) // if string literal
                token = new StrToken(lineNo, toStringLiteral(m));
            else // if identifier
                token = new IdToken(lineNo, m);
            queue.add(token);
        }
}

语法分析

词法分析后需要判断这些单词的组合方式是否满足我们当初设定的语法要求,比如 54 ! b 这样的的组合是违反语法规则的

语法分析常用的工具有 yacc 和 bison,后者是前者的开源版本

  • 终结符/非终结符,词法分割中的最小单元,也是语法分析中的最小单元,例如一个整数,一个运算符等。表达式由终结符组成,其是可以分割的,故称为非终结符

抽象语法树(AST)

抽象语法树(Abstract Syntax Tree,AST)是表示程序结构的数据结构,构造语法树的过程称为语法分析

1+3*(4-1)+2 为例,语法分析后生成的语法树形如:

graph LR A[+] C[+] B[2] E[*] D[1] F[3] G[-] H[4] I[1] A --> B A --> C C --> D C --> E E --> F E --> G G --> H G --> I

依次求解子树的值可得整个表达式的值

所有语言都有自己的语法,编译领域常用巴科斯范式描述语言的语法规则

巴科斯范式(BNF)

编译领域常用巴科斯范式(Backus-Naur Form,BNF)来描述语言的语法规则,BNF 与正则表达式很类似,但 BNF 对递归的支持更加丰富且 BNF 以单词为最小匹配单元(正则表达式则是字符)

BNF 示例(不同实现语法不同):

factor:    NUMBER | "(" expresion ")"
term:      factor { ("*" | "/") factor }
expresion: term { ("+" | "-") term }

BNF 的实现各有不同,下面对上面的 BNF 进行简单的解释

元符号 解释
{ pat } 表示模式 pat 至少重复 0 次
[ pat ] 与重复出现 0 次或 1 次的模式 pat 匹配
pat1 │ pat2 与 pat1 或者 pat2 匹配
() 将括号内视为一个完整的模式

如果定义顺序即优先级(优先级最高的在上面),那么 factor 的优先级是最高的,这其实很明显,数学表达式中,单一的数字的值即为其本身,括号括起来的内容要预先求值

term 可以认为是短语,是一个完整的组成,和括号包含的内容类似要优先求值

expression 表达式是 term 的组合

从上到下,下面是上面定义的超集,上面是下面定义的子集

Flex&Bison 简介

如上文介绍,Flex 是词法分析工具,Bison 是语法分析工具。Flex 将输入的文本流转化为 token 序列,Bison 分析这些 token 并基于逻辑(语法)进行组合。Flex 和 Bison 的详细用法可以参考书籍 《flex与bison(中文版)》 ,如果是工作中使用且没有历史包袱,推荐使用 ANTLR4

Flex&Bison 的特点

Flex

  • flex 使用正则表达式匹配 token,而部分字符串可以被多个正则匹配到,那么 Flex 就默认了以下两条规则
    1. 使用最大一口原则,一次匹配最长的串
    2. 优先匹配最先出现的模式
  • flex 默认从标准输入(stdin)获取数据,不过可以在启动程序是修改:yyin=fopen("file_path", "r")
  • flex 对运行时的正则表达式进行了优化,速度比普通的正则库要快

Bison

  • 移进/归约,语法分析过程和正则类似,语法分析器从词法分析器获得一个 token 后会将 token 置入栈中,这个过程被称为移进(shift);新 token 入栈后会触发规则检测,如果栈中 token 序列满足我们定义的 BNF 规则,语法分析器会将符合规则的部分合并成对应的规则对象,比如 term 或者 expr,这个过程被称为归约(reduce)归约过程需要考虑优先级,例如 1+2*3 就不能简单的将 1+2 归约为 expression。触发归约时 bison 会执行相应的动作
    • 归约/归约冲突,同时可以进行多个归约,默认匹配前面的语法规则,要避免出现这种冲突
    • 移进/归约冲突,满足移进规则,同时满足归约规则,避免出现这种冲突

Flex&Bison 应用示例

Flex 实现单词计数命令 wc

下面是使用 flex 实现单词计数程序的示例

flex 程序(将文件保存为 fl.l):

/* flex 程序分为三个部分,用两个百分号进行分割 */
/* 第一部分包含声明与选项设置,%{ %} 之间的内容会被原样拷贝到生成的 C 代码中 */
%{
int chars = 0;
int words = 0;
int lines = 0; 
    
int yywrap(void){ return 1; } // 加上这行可以避免链接 fl 库
%}

/* 第二部分是一系列的模式和动作 */
%%
[a-zA-Z]+ {words++; chars += strlen(yytext);} /* 左侧为模式,右侧为行为 */
\n        {chars++; lines++;} /* 如果输入的字符串匹配正则表达式, 则执行右侧的 C 代码 */
.         {chars++;} 

%%
/* 第三部分是 C 代码,这部分不写也是可以的,-lfl 时会自动从 fl 中引入一个主函数 */
main(int argc, char **argv){
    yylex();
    printf("%d,%d,%d\n", lines, words, chars);
}

将 flex 程序转化为 c 代码:

flex fl.l         # 自动生成 lex.yy.c 文件
gcc lex.yy.c      # 生成可执行文件,这里不要用 g++,因为c/c++符号命名问题,使用 g++ 编译会失败
# gcc lex.yy.c -lfl # 如果flex 文件中没有定义 yywrap 函数,需要链接 fl 库
./a.out < fl.l    # 对 fl.l 文件中的单词进行计数,flex 对正则进行了 DFA 优化,速度非常快

使用 Flex&Bison 实现计算器

Flex 词法解析

%{
#include <stdio.h>
#include "y.tab.h"
int yywrap(void) { return 1; }
%}

%%
"+"             return ADD;
"-"             return SUB;
"*"             return MUL;
"/"             return DIV;
"\n"            return CR;
([1-9][0-9]*)|0|([0-9]+\.[0-9]*) { 	
    double temp;
    sscanf(yytext, "%lf", &temp); /* 匹配到的原始字符串保存在全局变量 yytext 中 */
    yylval.double_value = temp; /* 解析出的值会存放在名为 yylval 的全局变量中,yylval 是 union */
    return DOUBLE_LITERAL;
}
[ \t] ;
. { fprintf(stderr, "lexical error.\n");  exit(1); }
%%
// c codes...

Bison 语法定义

%{
#include <stdio.h>
#include <stdlib.h>
#define YYDEBUG 1
%}
%union {
    int          int_value;
    double       double_value;
}
%token <double_value>      DOUBLE_LITERAL
%token ADD SUB MUL DIV CR
%type <double_value> expression term primary_expression

%%
line_list : line | line_list line ;
line : expression CR { printf(">>%lf\n", $1); }
expression : term | expression ADD term { $$ = $1 + $3; } | expression SUB term { $$ = $1 - $3; };
term : primary_expression | term MUL primary_expression { $$ = $1 * $3;}
       | term DIV primary_expression { $$ = $1 / $3; }; 
primary_expression : DOUBLE_LITERAL ; /* 一元表达式的形式,自动补全 { $$ = $1 } */                
%%

int yyerror(char const *str){
    extern char *yytext;
    fprintf(stderr, "parser error near %s\n", yytext);
    return 0;
}

int main(void){
    extern int yyparse(void);
    extern FILE *yyin;

    yyin = stdin;
    if (yyparse()) {
        fprintf(stderr, "Error ! Error ! Error !\n");
        exit(1);
    }
}

ANTLR 4 简介

很多语言使用 antlr 来生成编译器前端代码,antlr 相对于 flex&Bison 这类工具而言使用了更新的技术

antlr 是使用 Java 开发的,所以执行 antlr4 工具需要 java 环境,可以从这里下载 ANTLR 4.8 tool itself

比较好的入门资料是官方文档 Getting Started wit ANTLR v4

简单示例 C++

这里摘取官方文档中 windows 下的配置方式,我使用的 cmd 终端是 cmder,使用的命令行工具源自 git/bin,编译环境为 WSL

  1. 安装 Java 1.6 及以上版本

  2. 下载 antlr-4.7.1-complete.jar ,或者从这里下载

  3. 将 antlr-4.7.1-complete.jar 加入到环境变量中

    1. 长久有效的方法是直接将路径写入到 CLASSPATH 环境变量中

    2. 临时有效:SET CLASSPATH=.;./antlr-4.7.1-complete.jar;%CLASSPATH%

      1. linux :export CLASSPATH=".:./antlr-4.7.1-complete.jar:$CLASSPATH"

      2. 如果不想设置环境变量,那么在执行 java 命令时需要带上 jar 包的目录位置,例如

        javac -cp "./antlr-4.7.1-complete.jar" Hello*.java

  4. 执行命令:java org.antlr.v4.Tool %*,如果配置生效会输出下面内容,可以参考官方文档为命令设置别名

    ANTLR Parser Generator  Version 4.7.1
     -o ___              specify output directory where all output is generated
     -lib ___            specify location of grammars, tokens files
     -atn                generat......
    

hello world

定义语法规则,保存为 Hello.g4

// Define a grammar called Hello
grammar Hello;
r  : 'hello' ID ;         // match keyword hello followed by an identifier
ID : [a-z]+ ;             // match lower-case identifiers
WS : [ \t\r\n]+ -> skip ; // skip spaces, tabs, newlines

生成对应语言的解析代码

java  -Dfile.encoding=UTF8 -jar antlr-4.7.1-complete.jar -Dlanguage=Cpp Hello.g4

为了更方便的使用 ANTLR,可以先生成 Java 代码,然后做一些简单的测试,下面以上面的 Hello.g4 为例介绍下 ANTLR4 的 org.antlr.v4.gui.TestRig 工具

  1. 生成 java 代码:java -Dfile.encoding=UTF8 -jar antlr-4.7.1-complete.jar Hello.g4
  2. 编译生成 class 文件:java Hello*.java
  3. 执行命令:java -cp "./antlr-4.7.1-complete.jar" org.antlr.v4.gui.TestRig Hello r -tree
    1. 输入 hello world,换行(点击 Enter 键)
    2. windows 下需要输入 CTRL+z,linux 平台下输入 CTRL+D
    3. 再次点击换行键
  4. 重复执行 3 ,不过将命令中的 -tree 改为 -gui,ANTLR 将以图形界面的形式输出语法分析树;-tree 以 Lisp 文本的形式展示
    1. 其他常用参数解释
      1. -tokens,打印词法符号流;-ps file.ps ,以 PostScript 格式保存语法分析树结果;
      2. -trace 打印规则名称及进入和离开规则时的词法符号
      3. -encoding encodingname,指定输入文件的编码
      4. -diagnostics,开启解析过程中的调试信息输出,比如定义的规则有歧义
      5. -SLL,使用另外一种更快但功能相对较弱的解析策略
ANTLR4 语法简介
  • 语法规则以小写字母开始
  • 词法规则以大写字母开始
  • 使用 | 分割一个规则的若干备选分支,例如:stat: expr NEWLINE|NEWLINE;

使用 ANTLR 实现计算器

首先安装 antlr cpp 运行时环境,可以从这里下载

sudo apt-get install pkg-config
sudo apt-get install uuid-dev
mkdir build && mkdir run && cd build
# cmake 的最低版本为 2.8
cmake .. -DANTLR_JAR_LOCATION=./antlr-4.7.1-complete.jar -DWITH_DEMO=True
make
DESTDIR=/runtime/Cpp/run  # 设置安装目录
make install

可以从这里下载完整的 c++ 代码,我在 linux 下编译并执行成功,windows 编译失败,因为只是为了验证代码,所以并没有解决编译失败的问题

Boost.Spirit (待续)

猜你喜欢

转载自www.cnblogs.com/jiahu-Blog/p/12908448.html