语法分析
- 数学理论:上下文无关文法(CFG)
- 描述语言语法规则的数学工具
- 自顶向下分析
- 递归下降分析算法(预测分析算法)
- LL分析算法
- 自底向上分析
- LR分析算法
上下文无关文法
- 自然语言中的句子的典型结构:
- 主语 谓语 宾语
- 名字动词名词
- 例子:
- 名词:{羊、老虎、草、水}
- 动词:{吃、喝}
定义
- 上下文无关文法
G
是一个四元组:G = (T, N, P, S)
- 其中
T
是终结符集合 N
是非终结符集合P
是一组产生式规则- 每条规则的形式:
X -> β1 β2 ...βn
- 其中
X ∈ N
,βi ∈ (T ∪ N)
- 其中
- 每条规则的形式:
S
是唯一的开始符号(非终结符)S ∈ N
- 其中
推导
- 给定文法
G
,从G
的开始符号S
开始,用产生式的右部替换左侧的非终结符 - 此过程不断重复,直到不出现非终结符为止
- 最终的串称为句子
最左推导和最右推导
- 最左推导:每次总是选择最左侧的符号进行替换
- 最右推导:每次总是选择最右侧的符号进行替换
语法分析的任务
给定文法 G
和句子 s
,语法分析要回答的问题:是否存在对句子s的推导?
分析树与二义性
分析树
- 推导可以表达成树状结构
- 和推导所用的顺序无关(最左、最右、其他)
- 特点:
- 树中的每个内部节点代表非终结符
- 每个叶子节点代表终结符
- 每一步推导代表如何从双亲节点生成它的直接孩子节点
二义性文法
- 给定文法 G,如果存在句子 s,它有两棵不同的分析树,那么称G是二义性文法
- 从编译器角度,二义性文法存在问题:
- 同一个程序会有不同的含义
- 因此程序运行的结果不是唯一的
- 解决方案:文法的重写
左递归的文法实现的句子具有左结合性;右递归文法实现的句子具有右结合性
自顶向下分析
- 语法分析:给定文法 G 和句子 s,回答 s 是否能够从 G 推导出来?
- 基本算法思想:从 G 的开始符号出发,随意推导出某个句子 t,比较 t 和 s
- 若 t == s,则回答“是”
- 若 t != s,则?
- 因为这是从开始符号出发推出句子,因此称为自顶向下分析
- 对应于分析树自顶向下的构造顺序
代码
tokens[]; // all tokens
i = 0;
stack = [S]; // S 为开始符号
while (stack != [])
if (stack[top] is a terminal t)
if (t == tokens[i++])
pop();
else
backtrack();
else if (stack[top] is a nonterminal T)
pop(); push(the next right hand side of T);
算法的讨论
- 算法需要用到回溯
- 给分析效率带来问题
- 而就这部分而言(就所有部分),编译器必须高效
- 编译上千万行的内核等程序
- 因此,实际上我们需要线性时间的算法
- 避免回溯
- 引出递归下降分析算法和LL(1)分析算法
用前看符号避免回溯
递归下降分析
- 也称为预测分析
- 分析高效(线性时间)
- 容易实现(方便手工编码)
- 错误定位和诊断信息准确
- 被很多开源和商业的编译器所采用
- GCC 4.0,LLVM,...
- 算法基本思想:
- 每个非终结符构造一个分析函数
- 用前看符号指导产生式规则的选择
算法
parse_S() {
parse_N();
parse_V();
parse_N();
}
parse_N() {
token = tokens[i++];
if (token == s || token == t || token == g || token == w) {
return;
}
error("...");
}
parse_V() {
token = tokens[i++];
if (token == e || token == d) {
return;
}
error("...");
}
一般的代码框架
parse_X() {
token = nextToken();
switch (token) {
case ...: // β11 ... β1i
case ...: // β21 ... β2i
case ...: // β31 ... β3i
...
default: error("...");
}
}
对算术表达式的递归下降分析
a first try
parse_E() {
token = tokens[i++];
if (token == num) {
? // E + T or T
} else {
error("...");
}
}
选择不当将有可能再次引起回溯
a second try
parse_E() {
parse_T();
token = tokens[i++];
while (token == +) {
parse_T();
token = tokens[i++];
}
}
parse_T() {
parse_F();
token = tokens[i++];
while (token == *) {
parse_F();
token = tokens[i++];
}
}
LL(1) 分析算法
- 从左(L)向右读入程序,最左(L)推导,采用一个(1)前看符号
- 分析高效(线性时间)
- 错误定位和诊断信息准确
- 有很多开源或商业的生成工具
- ANTLR,...
- 算法基本思想:
- 表驱动的分析算法
FIRST 集合
不动点算法
foreach (nonterminal N) {
FIRST(N) = {};
}
while (sime set is changing) {
foreach (production p: N -> β1 ... βn) {
if (β1 == a...) {
FIRST(N) ∪= {a};
}
if (β1 == M...) {
FIRST(N) ∪= FIRST(M);
}
}
}
把 FIRST 集合推广到任意串上
构造 LL(1) 分析表
分析表中的冲突
分析表的构造
- 首先研究右侧的例子:
- FIRST_S(X Y Z)?
- 一般情况下需要知道某个非终结符是否可以推出空串
- NULLABLE
- 并且一般需要知道在某个非终结符后面跟着什么符号
- 跟随集FOLLOW
- FIRST_S(X Y Z)?
NULLABLE 集合
- 归纳定义:
- 非终结符 X 属于集合 NULLABLE,当且仅当:
- 基本情况:
- X ->
- 归纳情况:
- X -> Y1 ..., Yn
- Y1, ..., Yn 是n个非终结符,且都属于NULLABLE集
- X -> Y1 ..., Yn
- 基本情况:
- 非终结符 X 属于集合 NULLABLE,当且仅当:
NULLABLE = {};
while (NULLABLE is still changing) {
foreach (production p: x -> β) {
if (β == ε) {
NULLABLE ∪= {X};
}
if (β == Y1 ... Yn) {
if (Y1 ∈ NULLABLE && ... && Yn ∈ NULLABLE) {
NULLABLE ∪= {X};
}
}
}
}
FIRST 集合计算公式
- 基于归纳的计算规则:
- 基本情况:
- X -> a
- FIRST (X) ∪= {a}
- X -> a
- 归纳情况:
- X -> Y1 Y2 ... Yn
- FIRST (X) ∪= FIRST(Y1)
- if Y1 ∈ NULLABLE, FIRST (X) ∪= FIRST(Y2)
- if Y1,Y2 ∈ NULLABLE, FIRST(X) ∪= FIRST(Y3)
- ...
- X -> Y1 Y2 ... Yn
- 基本情况:
不动点算法
foreach (nonterminal N) {
FIRST(N) = {};
}
while (some set is changing) {
foreach (production p: N -> β1 ... βn) {
foreach (βi from β1 upto βn) {
if (βi == a...) {
FIRST(N) ∪= {a};
break;
}
if (βi == M...) {
FIRST(N) ∪= FIRST(M);
if (M is not in NULLABLE) {
break;
}
}
}
}
}
FOLLOW 集的不动点算法
foreach (nonterminal N) {
FOLLOW(N) = {};
}
while (some set is changing) {
foreach (production p: N -> β1 ... βn) {
temp = FOLLOW(N);
foreach (βi from βn downto β1) { // 逆序
if (βi == a...) {
temp = {a};
}
if (βi == M...) {
FOLLOW(M) ∪= temp;
if (M is not NULLABLE) {
temp = FIRST(M);
}
else {
temp ∪= FIRST(M);
}
}
}
}
}
计算 FIRST_S 集合
foreach (prodcution p) {
FIRST_S(p) = {};
}
calculate_FIRST_S (production p: N1 -> β1 ... βn) {
foreach (βi from β1 to βn) {
if (βi == a...) {
FIRST_S(p) ∪= {a};
return;
}
if (βi == M...) {
FIRST_S(p) ∪= FIRST(M);
if (M is not NULLABLE) {
return;
}
}
}
FIRST_S(p) ∪= FOLLOW(N);
}
LL(1) 分析表
LL(1) 分析器
tokens[]; // all tokens
i = 0;
stack = [S]; // S 开始符号
while (stack != []) {
if (stack[top] is a terminal t) {
if (t == tokens[i++]) {
pop();
} else {
error(...);
}
} else if (stack[top] is a nonterminal T) {
pop();
push(table[T, tokens[i]]);
}
}
LL(1) 分析冲突处理
消除左递归
提取公因子
LR(0) 分析算法
LL(1) 分析
- 从左(L)向右读入程序,最左(L)推导,采用一个(1)前看符号
- 优点:
- 算法运行高效
- 有现成的工具可用
- 缺点:
- 能分析的文法类型受限
- 往往需要文法的改写
- 优点:
自底向上分析算法
- 研究其中最重要也是最广泛应用的一类
- LR分析算法(移进-归约算法)
- 算法运行高效
- 有现成的工具可用
- 这也是目前应该广泛的一类语法分析器的自动生成器中采用的算法
- YACC, bison, CUP, C#yacc, 等
- LR分析算法(移进-归约算法)
基本思想
点记号
为了方便标记语法分析器已经读入了多少输入,我们可以引入一个点记号 •
自底向上分析
另外的写法
生成一个逆序的最右推导
- 需要两个步骤:
- 移进一个记号到栈顶上,或者
- 归约 栈顶上的n个符号(某产生式的右部)到左部的非终结符
- 对产生式 A -> β1 ... βn
- 如果 βn ... β1 在栈顶上,则弹出 βn... β1
- 压入A
- 对产生式 A -> β1 ... βn
- 核心的问题:如何确定移进和归约的时机?
算法思想
LR(0) 分析表
LR(0) 分析算法
stack = []
push($) // $: end of file
push(1) // 1: initial state
while (true) {
token t = nextToken();
state s = stack[top];
if (ACTION[s, t] == "si") {
push(t);
push(i);
} else if (ACTION[s, t] == "rj") {
pop(the right hand of production "j: X -> β");
state s = stack[top];
push(X);
push(GOTO[s, X]);
} else error(...);
}
LR(0) 分析表构造算法
C0 = closure(S' -> ·S$); // the init closure
SET = {C0};
Q = enQueue(C0);
while (Q is not empty) {
C = deQueue(Q);
foreach (x ∈ (N ∪ T)) {
D = goto(C, x);
if (x ∈ T) {
ACTION[C, x] = D;
} else {
GOTO[C, x] = D;
}
if (D not ∈ SET) {
SET ∪= {D};
enQueue(D);
}
}
}
goto(C, x) {
temp = {};
foreach (C's item i: A -> β · x γ) {
temp ∪= {A -> βx · γ};
}
return closure(temp);
}
closure(C) {
while (C is still changing) {
foreach (C's item i: A -> β · B γ) {
C ∪= {B -> ...};
}
}
}
SLR 分析算法
LR(0) 分析算法
- 从左(L)向右读入程序,最右(L)推导,不用前看符号来决定产生式的选择(0个前看符号)
- 优点:
- 容易实现
- 缺点:
- 能分析的文法有限
- 优点:
LR(0) 分析算法的缺点
- 对每一个形如 X -> α· 的项目
- 直接把 α 归约成 X, 紧跟一个“goto”
- 尽管不会漏掉错误,但会延迟错误发现时机
- LR(0)分析表中可能包含冲突
SLR 分析算法
- 和 LR(0) 分析算法基本步骤相同
- 仅区别于对归约的处理
- 对于状态 i 上的项目 X -> α·
- 仅对 y ∈ FOLLOW(X) 添加 ACTION[i, y]
- 对于状态 i 上的项目 X -> α·
LR(1) 分析算法
SLR 分析算法的思想
- 基于 LR(0),通过进一步判断一个前看符号,来决定是否执行归约动作
- X -> α· 归约,当且仅当 y ∈ FOLLOW(X)
- 优点:
- 有可能减少需要归约的情况
- 有可能去除需要移进-归约冲突
- 缺点:
- 仍然有冲突出现的可能
LR(1) 的项目
- [X -> α·β, a] 的含义是:
- α在栈顶上
- 剩余的输入能够匹配 βa
- 当归约 X -> αβ 时,a 是前看符号
- 把
reduce by X -> αβ
填入 ACTION[s, a]
- 把
LR(1) 项目的构造
- 其他和 LR(0) 相同,仅闭包的计算不同:
- 对项目 [X -> α·Yβ, a]
- 添加 [Y -> ·γ, b] 到项目集
- 其中 b ∈ FIRST_S(βa)
- 添加 [Y -> ·γ, b] 到项目集
- 对项目 [X -> α·Yβ, a]
LALR 分析算法
- 把类似的项目集进行合并
- 需要修改 ACTION 表和 GOTO 表,以反映合并的效果
对二义性文法的处理
- 二义性文法无法使用 LR 分析算法分析
- 不过,有几类二义性文法很容易理解,因此,在 LR 分析器的生成工具中,可以对它们特殊处理
- 优先级
- 结合性
- 悬空 else
LR(1) 分析工具
语法分析器的实现方法
- 手工方式
- 递归下降分析器
- 使用语法分析器的自动生成器
- LL(1), LR(1)
- 两种方式在实际的编译器中都有广泛的应用
- 自动的方式更适合快速对系统进行原型
历史发展
- YACC 是
Yet Another Compiler-Compiler
缩写 - 在1975年首先由Steve Johnson在Unix上实现
- 后来,很多工具在此基础上做了改进:
- 例如 GNU Bison
- 并且移植到了很多其他语言上
- YACC 现在是一个标准的工具(见IEEE Posix 标准P1003.2)
Yacc
用户代码和Yacc声明:可以在接来的部分使用
%%
语法规则:上下文无关文法的规则及相应语义动作
%%
用户代码:用户提供的代码
首先在 Linux/Ubuntu 上使用 sudo apt install bison -y
安装 bison
创建一个 .y
文件:
%{
#include <stdio.h>
#include <stdlib.h>
int yylex();
void yyerror();
%}
%left '+'
%%
lines: line
| line lines
;
line: exp'
';
exp: n
| exp '+' exp
;
n: '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9' | '0';
%%
int yylex()
{
return getchar();
}
void yyerror(char *s)
{
fprintf(stderr, "%s
", s);
return;
}
int main(int argc, char ** argv)
{
yyparse();
return 0;
}
使用 bison text.y
将其编译成 test.tab.c
文件:
然后使用 gcc test.tab.c
将生成分析后的文件编译,得到可执行文件:
执行 ./a.out
即可进行语法分析: