复习编译原理的时候观看了 b 站视频,大概会区分各种文法(LR(0)、SLR(1)、LR(1)、LALR(1))但是不理解为什么需要这么多文法,以及有什么用?所以总结一下
问题
文法=规定语言长什么样
比如表达式可以写成:
这告诉编译器:a+b*c怎么被拆分成语法数
实际编译时,语法分析器做的就是:读token,然后决定是 移进 还是 归约。
eg:
输入id+id*id
词法分析器首先把源码变成token
id + id * id
语法分析器一边读,一边问自己:
现在这个 id 是不是已经可以归约成 T / E?
还是应该先继续读后面的 +、*?
1."移近"和"归约"是什么
这是理解冲突的核心。
假设有文法:
E -> E + E
E -> E * E
E -> id
输入:
id + id * id
当分析到:
id + id · * id
也就是已经看到 id + id,下一个符号是 *。
这事parser(解析器)有两个选择:
选择一:归约
id + id
可以先归约成:
E + E
再归约成:
E
这相当于理解成:
(id + id) * id
选择二:移进
先不要归约,继续读 * id,最后理解成:
id + (id * id)
这才符合通常的乘法优先级。
所以这里 parser 不知道该怎么办:
到底归约,还是继续读?
这就叫:移进-归约冲突。
2. 什么是"冲突"?
冲突就是:在同一个状态、面对同一个输入符号时,分析表里出现两个动作。
LR 分析表里每个格子本来应该只有一个动作,比如:
ACTION[状态 i, 符号 a] = shift
或者:
ACTION[状态 i, 符号 a] = reduce
但如果同一个格子里同时出现:
shift(移进) 和 reduce(归约)
就叫 移进-归约冲突。
如果同时出现两个 reduce:
reduce A -> α
reduce B -> β
就叫 归约-归约冲突。
冲突 = 编译器在某一步无法唯一决定下一步怎么做。
3. 为什么会冲突?
通常有两个原因。
第一种:文法本身有歧义
比如:
E -> E + E
E -> E * E
E -> id
这个文法没有说明 + 和 * 谁优先。
所以:
id + id * id
可以有两种语法树:
(id + id) * id
或者:
id + (id * id)
编译器当然会犹豫。
解决办法是改文法,例如写成:
E -> E + T | T
T -> T * F | F
F -> id
这样 * 在更底层,优先级更高。
第二种:文法不歧义,但 parser 看得不够远
这就是 LR(0)、SLR(1)、LR(1)、LALR(1) 的区别。
它们的区别本质是:
parser 在做决定时,能利用多少上下文信息。
4. LR(0)、SLR(1)、LR(1)、LALR(1) 怎么理解?
我们可以把它们理解成四种"眼神不同"的 parser。
LR(0):最莽
LR(0) 不看下一个输入符号。
只要看到某个项目变成:
A -> α ·
它就想归约。
所以 LR(0) 很容易冲突。
比如状态里同时有:
A -> α ·
B -> α · b
LR(0) 会想:
A -> α · 可以归约
B -> α · b 看到 b 又可以移进
但 LR(0) 不看后面到底是不是 b,所以容易冲突。
SLR(1):稍微聪明一点
SLR(1) 会看 FOLLOW 集。
如果有:
A -> α ·
它不是在所有符号上都归约,而是只在:
FOLLOW(A)
里的符号上归约。
也就是说:
只有当下一个 token 可能跟在 A 后面时,才归约 A -> α
所以 SLR(1) 比 LR(0) 少很多冲突。
但是 SLR(1) 的问题是:FOLLOW(A) 是全局的,太粗糙。
它不管你当前在哪个上下文里,只要某个符号在 FOLLOW(A) 里,就认为可以归约。
所以有些情况下它还是会误判。
LR(1):最精细
LR(1) 的项目长这样:
A -> α · β, a
后面的 a 叫 向前看符号 lookahead。
意思是:
只有当后面真的看到 a 时,才允许这个归约。
所以 LR(1) 不是用全局 FOLLOW 集,而是给每个项目单独带一个 lookahead。
因此 LR(1) 判断最准确,冲突最少。
缺点是:状态数很多,分析表很大。
LALR(1):实际编译器常用的折中
LALR(1) 的思想是:
LR(1) 太大了,那我把"核心相同"的状态合并一下。
比如两个 LR(1) 状态:
A -> α ·, a
和:
A -> α ·, b
核心都是:
A -> α ·
只是 lookahead 不同。
LALR(1) 会把它们合并成:
A -> α ·, a/b
这样表变小很多。
所以
LR(1) ⊂ LALR(1)
就是在讲:LALR(1) 是在 LR(1) 基础上合并同心项目集。
实际中的 yacc / bison 这类工具就常用 LALR(1) 或类似技术。
5. 理论和实际编译怎么结合?
实际编译器大概是这样:
源代码
↓
词法分析 lexer
↓
token 流
↓
语法分析 parser
↓
语法树 AST
↓
语义分析 / 类型检查 / 中间代码 / 优化 / 目标代码
我们现在学的 LR、SLR、LALR 主要在这一步:
token 流 → 语法树 AST
也就是 parser 的理论基础。
比如写:
int x = 1 + 2 * 3;
词法分析得到:
int id = num + num * num ;
语法分析器要根据文法判断:
1 + 2 * 3
这里的文法可能是:
E -> E + T | T
T -> T * F | F
F -> num | id | ( E )
E 表示表达式 expression
T 表示项 term
F 表示因子 factor
应该被理解为:
1 + (2 * 3)

而不是:
(1 + 2) * 3
所以,文法不是为了考试凭空造出来的,它决定了编译器怎么理解代码结构。
为什么不会推成 (1 + 2) * 3?
因为如果想得到:
(1 + 2) * 3
那最外层应该是:
T -> T * F
也就是说整个 1 + 2 必须先成为一个 T。
但是在这个文法里,T 只能生成:
F
T * F
T / F
它不能直接生成:
1 + 2
因为 + 只在 E 这一层出现。
所以:
1 + 2
只能是 E,不能是 T。
而 T * F 的左边必须是 T,不是 E。
因此不加括号时,文法不允许把它理解成:
(1 + 2) * 3
如果真的想这样写,就必须输入:
(1 + 2) * 3
因为括号规则:
F -> ( E )
允许一个完整的 E 被包起来,变成一个 F,再进一步变成 T。
这和实际编译器是一回事吗?
是的,思想是一回事。
实际编译器里一般会有类似的表达式规则,只是更复杂,比如还要处理:
赋值 =
逻辑或 ||
逻辑与 &&
比较 < > ==
加减 + -
乘除 * /
一元运算 ! -
函数调用 f()
数组访问 a[i]
成员访问 obj.x
括号
它们通常会按优先级分很多层,例如:
assignment
logical_or
logical_and
equality
relational
additive
multiplicative
unary
primary
比如可以写成:
Expr -> Assignment
Assignment -> id = Assignment | LogicalOr
LogicalOr -> LogicalOr || LogicalAnd | LogicalAnd
LogicalAnd -> LogicalAnd && Equality | Equality
Equality -> Equality == Relational | Relational
Relational -> Relational < Additive | Additive
Additive -> Additive + Multiplicative | Multiplicative
Multiplicative -> Multiplicative * Unary | Unary
Unary -> - Unary | Primary
Primary -> num | id | ( Expr )
这跟我们学的:
E -> E + T | T
T -> T * F | F
F -> id | num | (E)
本质完全一样,只是实际语言的层级更多。
所以
语法分析器就是根据文法来理解表达式的。
1 + 2 * 3 能被正确理解,是因为文法把 + 放在 E 层,把 * 放在 T 层,从结构上规定了:
* 的优先级高于 +
不是语法分析器自己猜出来的。
6. 一句话记住这些文法的关系
从弱到强大概是:
LR(0) < SLR(1) < LALR(1) < LR(1)
意思是:
能处理的文法范围越来越大,冲突越来越少,但代价也越来越高。
更直观一点:
LR(0):不看后面,直接判断
SLR(1):看 FOLLOW 集,粗略判断
LR(1):每个项目都带 lookahead,精确判断
LALR(1):接近 LR(1),但合并状态,适合实际使用
核心:
冲突就是 parser 在某个状态不知道该 shift 还是 reduce。
LR 系列算法的目的,就是构造分析表,让 parser 每一步都有唯一动作。