詞法&語法分析基礎
將文本轉化為可以執行的程序一般需要詞法分析、語法分析、語義分析和后端處理等步驟。如非學習從頭開始寫這些工具其實非常浪費時間,所以一般使用現成的工具生成語法解析代碼
本文所用的部分參考資料:
- flex & bison
- 自己動手寫編譯器,推薦閱讀
- 自制編程語言
- 兩周自制腳本語言
- Why you should not use (f)lex, yacc and bison
- ANTLR 4權威指南
開源工具:lex/yacc、flex/bison、python PLY、ANTLR4、Boost Spirit
基礎概念
- 語言: 一個語言就是一個句子集合( a set of sentences) , 用 L 表示, 任何由句子組成的集合都可以被稱為一個語言
- 編譯: 編譯就是給定兩個句子集合 Ls ( 源語言) 和 Lo ( 目標語言) 以及一個句子 ss , 判斷 ss 是否屬於 Ls , 以及在 Lo 中尋找出一個句子 so , 其意義和 ss 相同
詞法分析
處理語言的第一個組成部分是詞法分析器(lexical analyzer、lexer 或者 scanner),詞法分析將文本分割為單詞(token)序列
詞法分析可用的工具有 lex 和 flex ,后者是前者的開源增強版本。詞法分析器相對簡單,所以正式的編程語言一般都不會使用 lex 等工具
最簡單的詞法分析方式是直接掃描法,在掃描字符串的過程中判斷與解析 token,這類方法實現簡單但比較難擴展,而且效率也不高,部分 token 的判斷需要多次掃描
正則表達式
絕大部分語言的詞法分析工具是正則表達式,無論是 flex 等工具還是手寫詞法分析器,正則表達式一般都是基礎
正則表達式的實現使用的是 FA,即有限狀態自動機(Finate Automaton)
有限狀態自動機( Finate Automaton) 是用來判斷字符串( 句子) 是否和正則表達式匹配的假想機器, 它有一個字母表 Σ 、 一個狀態集合 S , 一個轉換函數 T , 當它處於某個狀態時,若它讀入了一個字符( 必須是字母表里的字符),則會根據當前狀態和讀入的字符自動轉換到另一個狀態,它有一個初始狀態,還有一些所謂的接受狀態
它的工作過程是:首先自動機處於初始狀態,之后它開始讀入字符串,每讀入一個字符,它都根據當前狀態和讀入字符轉換到下一狀態,直到字符串結束,若此時自動機處於其接受狀態,則表示該字符串被此自動機接受,即匹配成功
數學家們已經證明了:任何一個正則表達式都有一個等價的有限狀態自動機,任何一個有限狀態自動機也有一個等價的正則表達式
Flex 是一個快速詞法分析生成器, 它可以將用戶用正則表達式寫的分詞匹配模式構造成一個有限狀態自動機(一個 C 函數)
單詞類型
幾乎所有語言都支持下面三種單詞類型:
- 標識符:變量名、函數名或者類名,為了簡單,運算符在 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
為例,語法分析后生成的語法樹形如:
依次求解子樹的值可得整個表達式的值
上下文無關語法
一個程序就是一個句子( 字符串),語言就是一個句子集合。 那么如何准確的表示這個集合(語言)?—— 上下文無關語法( context-free grammar)
一個上下文無關語法 G 就是由一個終結符集合 T ,一個非終結符集合 N( N 和 T 不相交),一個產生式集合 P ,以及一個起始符號 S( S ∈ N) 組成。由語法 G 推導( 產生) 出來的所有的句子的集合稱為 G 語言。 因此一個語法可以代表一個句子集合, 也就是一個語言
上下文本無關語法 G ( context-free grammar, CFG) : 一個 4 元組: (T, N, P, S) , 其中 T 為終結符集合, N 為非終結符集合, P 為產生式集合, S 為起始符號。 一個句子如果能從語法 G 的 S 推導得到, 可以直接稱此句子由語法 G 推導得到, 也可稱此句子符合這個語法, 或者說此句子屬於 G 語言。 G語言( G language) 就是語法 G 推導出來的所有句子的集合, 有時也用 G 代表這個集合
產生式示例 1
注意遞歸的使用
S -> AB /* 起始符 */
A -> aA | ε
B -> b | bB
S 為起始符號,余下的兩條為生成式,可得當前語法能推導出的所有句子集合為:
A : { ε, a, aa, aaa, ... } /* ε 標識空語句 */
B : { b, bb, bbb, ... }
S : { b, bb, bbb, ..., ab, abb, ..., aab, aabb, ... }
產生式示例 2
Expr -> Expr op Expr | (Expr) | number
op -> + - * /
導出的表達式示例:
Expr : { 123, 25 + 24, 78 - 34, 12 * ( 23 + 9 ), ... }
自頂向下分析
自頂向下分析就是從起始符號開始,不斷的挑選出合適的產生式,將中間句子中的非終結符的展開,最終展開到給定的句子
比如使用上面產生式示例 1 ,結合自頂向下分析 aaab
,從 S 開始不斷展開 S/A/B
上面產生式 1 的自頂向下分析流程如下:
Working-string | Production |
---|---|
S | S –> AB |
AB | A –> aA |
aAB | A –> aA |
aaAB | A –> aA |
aaaAB | A -> ε |
aaaB | B -> b |
aaab | ACCEPT |
自底向上分析
自底向上分析的順序和自頂向下分析的順序剛好相反,從給定的句子開始,不斷的挑選出合適的產生式,將中間句子中的子串折疊為非終結符,最終折疊到起始符號
產生式:
S –> AB
A –> Aa | ε
B –> b | bB
自底向上分析 aaab
流程
Working-string | Production |
---|---|
aaab | insert(ε) |
εaaab | A -> ε |
Aaaab | A -> Aa |
Aaab | A -> Aa |
Aab | A -> Aa |
Ab | B -> b |
AB | S ->AB |
S | ACCEPT |
歧義消除
無論自底向上還是自頂向下,都會出現歧義:
- 所有產生式都不可應用
- 有多個產生式可以應用 ,此時需要使用回溯,試探性的選擇一個產生式,看是否可用。 回溯分析一般都非常慢, 因此一般通過精心構造語法來避免回溯
左右遞歸
左遞歸( left recursive) 是指形如A -> Au
這樣的規則, 右遞歸( left recursive) 則是指形如 A -> uA
這樣的規則。為避免回溯, 不宜將自頂向下分析法應用於含左遞歸的語法 , 這是由此方法的分析順序決定的
巴科斯范式(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 就默認了以下兩條規則
- 使用最大一口原則,一次匹配最長的串
- 優先匹配最先出現的模式
- flex 默認從標准輸入(stdin)獲取數據,不過可以在啟動程序是修改:
yyin=fopen("file_path", "r")
- flex 對運行時的正則表達式進行了優化,速度比普通的正則庫要快
- 默認 flex 生成的詞法分析函數不可重入,即在沒有同步的前提下不能用於多線程;可以在使用 Flex 生成 C 函數時使用
--rerntrant
命令,生成可重入的解析函數
Bison
- 移進/歸約,語法分析過程和正則類似,語法分析器從詞法分析器獲得一個 token 后會將 token 置入棧中,這個過程被稱為移進(shift);新 token 入棧后會觸發規則檢測,如果棧中 token 序列滿足我們定義的 BNF 規則,語法分析器會將符合規則的部分合並成對應的規則對象,比如 term 或者 expr,這個過程被稱為歸約(reduce)。歸約過程需要考慮優先級,例如
1+2*3
就不能簡單的將1+2
歸約為 expression。觸發歸約時 bison 會執行相應的動作- 歸約/歸約沖突,同時可以進行多個歸約,默認匹配前面的語法規則,要避免出現這種沖突
- 移進/歸約沖突,滿足移進規則,同時滿足歸約規則,避免出現這種沖突
Flex&Bison 應用示例
Flex 輸入文件格式為:
%{
Declarations /* 這部分內容是 C 代碼,會被原樣拷貝進lex.yy.c 文件中,當前內容可以沒有*/
%}
Definitions /* 定義用於下面 Rules 中的宏,當前內容可以沒有 */
%%
Rules /* 規則定義,規則必須有 */
%%
User subroutines /* 用戶 C 代碼 */
引入 Flex 文件的 Makefile 示例
wc: lex.yy.c
gcc -o $@ $<
lex.yy.c: word-spliter.l
flex $<
Flex 實現單詞計數命令 wc
下面是使用 flex 實現單詞計數程序的示例
flex 程序(將文件保存為 fl.l
):
/* flex 程序分為三個部分,用兩個百分號進行分割 */
/* 第一部分包含聲明與選項設置,%{ %} 之間的內容會被原樣拷貝到生成的 C 代碼中 */
%{
int chars = 0;
int words = 0;
int lines = 0;
int yywrap(void){ return 1; } // 加上這行可以避免鏈接 fl 庫
%}
/* 當前 flex 文件沒有使用 Definitions */
%%
[a-zA-Z]+ {words++; chars += strlen(yytext);} /* 左側模式右側行為,匹配到的字符串會保存在全局變量 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 實現簡單的詞法分析
%{
#include "token.h" // token.h 見后
int cur_line_num = 1;
void init_scanner();
void lex_error(char* msg, int line);
%}
/* Definitions, note: \042 is '"' */
INTEGER ([0-9]+)
UNTERM_STRING (\042[^\042\n]*)
STRING (\042[^\042\n]*\042)
IDENTIFIER ([_a-zA-Z][_a-zA-Z0-9]*)
OPERATOR ([+*-/%=,;!<>(){}])
SINGLE_COMMENT1 ("//"[^\n]*)
SINGLE_COMMENT2 ("#"[^\n]*)
%%
[\n] { cur_line_num++; }
[ \t\r\a]+ { /* ignore all spaces */ }
{SINGLE_COMMENT1} { /* skip for single line comment */ }
{SINGLE_COMMENT2} { /* skip for single line commnet */ }
{OPERATOR} { return yytext[0]; }
"<=" { return T_Le; }
">=" { return T_Ge; }
"==" { return T_Eq; }
"!=" { return T_Ne; }
"&&" { return T_And; }
"||" { return T_Or; }
"void" { return T_Void; }
"int" { return T_Int; }
"while" { return T_While; }
"if" { return T_If; }
"else" { return T_Else; }
"return" { return T_Return; }
"break" { return T_Break; }
"continue" { return T_Continue; }
"print" { return T_Print; }
"readint" { return T_ReadInt; }
{INTEGER} { return T_IntConstant; }
{STRING} { return T_StringConstant; }
{IDENTIFIER} { return T_Identifier; }
<<EOF>> { return 0; }
{UNTERM_STRING} { lex_error("Unterminated string constant", cur_line_num); }
. { lex_error("Unrecognized character", cur_line_num); }
%%
int main(int argc, char* argv[]) {
int token;
init_scanner();
while (token = yylex()) {
print_token(token);
puts(yytext);
}
return 0;
}
void init_scanner() {
printf("%-20s%s\n", "TOKEN-TYPE", "TOKEN-VALUE");
printf("-------------------------------------------------\n");
}
void lex_error(char* msg, int line) {
printf("\nError at line %-3d: %s\n\n", line, msg);
}
int yywrap(void) {
return 1;
}
token.h
#ifndef TOKEN_H
#define TOKEN_H
typedef enum {
T_Le = 256, /* Flex 默認使用 ASCII 碼標識 ASCII 字符,所以其他 token 的編碼從 256 開始 */
T_Ge, T_Eq, T_Ne, T_And, T_Or, T_IntConstant,
T_StringConstant, T_Identifier, T_Void, T_Int, T_While,
T_If, T_Else, T_Return, T_Break, T_Continue, T_Print,
T_ReadInt
} TokenType;
static void print_token(int token) {
static char* token_strs[] = {
"T_Le", "T_Ge", "T_Eq", "T_Ne", "T_And", "T_Or", "T_IntConstant",
"T_StringConstant", "T_Identifier", "T_Void", "T_Int", "T_While",
"T_If", "T_Else", "T_Return", "T_Break", "T_Continue", "T_Print",
"T_ReadInt"
};
if (token < 256) {
printf("%-20c", token);
} else {
printf("%-20s", token_strs[token-256]);
}
}
#endif
使用 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
-
安裝 Java 1.6 及以上版本
-
下載 antlr-4.7.1-complete.jar ,或者從這里下載
-
將 antlr-4.7.1-complete.jar 加入到環境變量中
-
長久有效的方法是直接將路徑寫入到 CLASSPATH 環境變量中
-
臨時有效:
SET CLASSPATH=.;./antlr-4.7.1-complete.jar;%CLASSPATH%
-
linux :
export CLASSPATH=".:./antlr-4.7.1-complete.jar:$CLASSPATH"
-
如果不想設置環境變量,那么在執行 java 命令時需要帶上 jar 包的目錄位置,例如
javac -cp "./antlr-4.7.1-complete.jar" Hello*.java
-
-
-
執行命令:
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
工具
- 生成 java 代碼:
java -Dfile.encoding=UTF8 -jar antlr-4.7.1-complete.jar Hello.g4
- 編譯生成 class 文件:
java Hello*.java
- 執行命令:
java -cp "./antlr-4.7.1-complete.jar" org.antlr.v4.gui.TestRig Hello r -tree
- 輸入 hello world,換行(點擊 Enter 鍵)
- windows 下需要輸入 CTRL+z,linux 平台下輸入 CTRL+D
- 再次點擊換行鍵
- 重復執行 3 ,不過將命令中的 -tree 改為 -gui,ANTLR 將以圖形界面的形式輸出語法分析樹;-tree 以 Lisp 文本的形式展示
- 其他常用參數解釋
-tokens
,打印詞法符號流;-ps file.ps
,以 PostScript 格式保存語法分析樹結果;-trace
打印規則名稱及進入和離開規則時的詞法符號-encoding encodingname
,指定輸入文件的編碼-diagnostics
,開啟解析過程中的調試信息輸出,比如定義的規則有歧義-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 編譯失敗,因為只是為了驗證代碼,所以並沒有解決編譯失敗的問題