跳到内容

6.3 LR 移入归约与语法树:从栈中的前缀恢复结构

另一台解析器不从开始符号向下预测,而是一边读取 token,一边从栈顶识别已经完成的结构。

递归下降从开始符号预测下一步,LR 解析器则从输入出发:把 token 移入栈,直到栈顶出现某条产生式右侧,再归约成左侧非终结符。它看见的是一个“可行前缀”,目标是找到最右推导的逆过程。

本课目标

  • 解释 shift、reduce、accept 和 error 动作;
  • 理解 LR item、closure 与 goto 的用途;
  • 区分 shift/reduce 和 reduce/reduce 冲突;
  • 选择 CST、AST 或 lossless tree,并保留源码来源。

1. 移入与归约

文法:

text
E → E + T | T
T → id

输入 id + id 的概念过程:

text
栈             输入          动作
               id + id $     shift id
id             + id $        reduce T → id
T              + id $        reduce E → T
E              + id $        shift +
E +            id $          shift id
E + id         $             reduce T → id
E + T          $             reduce E → E + T
E              $             accept

真实 LR 栈还保存自动机状态,ACTION 表根据“栈顶状态 + lookahead”选择动作,GOTO 表在归约后根据“新栈顶状态 + 左侧非终结符”决定下一状态。

2. LR item 表示产生式进度

item 在产生式右侧放一个点:

text
E → E · + T

表示 E 已识别,下一步期待 + T。若点在非终结符前,closure 要加入该非终结符可能开始的产生式;读到一个文法符号后,goto 把点跨过该符号并再次求 closure。

item 集合构成一个 DFA:状态概括“目前识别了哪些文法前缀”。LR(1) item 还携带 lookahead,用于决定完成产生式后在哪些 token 上归约。

SLR、LALR 与 canonical LR(1) 使用不同方式传播或合并 lookahead,表大小和能处理的文法范围不同。不能把它们都笼统当作同一个“LR”。

3. 两类冲突

Shift/reduce

同一表项既可移入 lookahead,又可归约。经典例子是悬空 else:看到 else 时,可以先归约无 else 的 if,也可以移入并与最近 if 结合。

Reduce/reduce

同一表项可按两条不同产生式归约,表示当前栈内容有两个完成解释。它通常比 shift/reduce 更难通过简单优先级合理解决。

工具常允许声明运算符优先级与结合性来解决表达式冲突。必须确认冲突数量符合预期;用“总是 shift”静默掩盖新冲突,会让语言行为随文法修改意外变化。

4. Bison 风格语义动作

text
%left '+' '-'
%left '*' '/'

%%
expr:
      expr '+' expr { $$ = make_binary($1, PLUS, $3, @$); }
    | expr '*' expr { $$ = make_binary($1, STAR, $3, @$); }
    | '(' expr ')'  { $$ = with_span($2, @$); }
    | NUMBER        { $$ = make_number($1, @$); }
    ;

$1$3 是右侧符号语义值,$$ 是左侧结果,位置记号依工具配置而异。语义动作中的分配失败、异常和错误恢复必须遵守生成器运行时的栈清理约定,否则容易泄漏节点。

文法中的优先级声明是紧凑方案,但分层非终结符通常更容易被多种解析器和读者理解。选择应考虑语言规范可读性与工具诊断。

5. CST、AST 与 lossless syntax tree

结构通常保留主要用途
CST文法节点、标点、括号解释解析过程
AST语义相关表达式、声明、语句类型检查、IR 生成
lossless tree全部 token、空白、注释、错误节点格式化、IDE、重构

AST 可以删除括号节点,因为树结构已表达优先级;但若工具要保留用户写法,必须在其他结构中保留括号与 trivia。不存在对所有消费者都最好的唯一语法树。

6. 源位置不应只挂在叶子上

二元表达式节点的 span 通常覆盖左操作数起点到右操作数终点;运算符 token 另有 span。这样可以:

  • 报告整个错误表达式;
  • 精确强调操作符;
  • 在宏展开时追踪 spelling 与 expansion 位置;
  • 让优化后的节点仍能回到源结构。

合成节点与缺失 token 需要零长度或 synthetic span。诊断渲染必须识别它,避免产生倒置或越界范围。

7. LR 错误恢复不是天然更差

LR 解析器可使用特殊 error 符号、弹栈到可接受 error 的状态、丢弃输入直到同步 token。现代实现也能做局部修复和生成期望 token 集。

LL 与 LR 的诊断质量主要取决于文法、状态信息、恢复算法和人为设计,不能用“LL 好、LR 差”一概而论。LR 状态更难直接映射为用户概念,但也包含精确的可行前缀信息。

8. 如何选择解析路线

  • 文法适合预测、团队重视手写控制与定制诊断:递归下降或 Pratt;
  • 已有成熟 LR 文法、需要处理左递归和较宽文法类:LR 生成器;
  • 需要保留二义或处理自然语言式文法:GLR、Earley 等广义算法;
  • 需要无回溯歧义的 ordered choice:PEG,但要理解优先选择语义与传统 CFG 不同。

选择解析器之前,应固定语言语义、错误恢复要求、增量能力和工具链生态,不能只比较一张“支持文法更多”的表。

常见误区

  • LR 从左到右产生最左推导:它构造最右推导的逆过程。
  • 有冲突就说明语言二义:可能是所选 LR 变体状态合并造成,也可能通过更强 lookahead 解决。
  • 优先级声明会自动符合语言规范:声明顺序写错会稳定地产生错误语义。
  • AST 丢掉标点后所有工具都能复用:源码工具通常需要 lossless tree 或 token 映射。

练习

  1. 手工完成 id + id 的 shift/reduce 轨迹。
  2. +* 和右结合 ^ 写优先级与结合性声明。
  3. 给悬空 else 文法解释 shift/reduce 两种选择各产生什么树。
  4. 为编译、格式化和 IDE 三类消费者选择树结构并说明取舍。

小结

LR 解析器把文法前缀编码成状态,在状态栈上执行移入和归约。冲突必须被解释而不是消音,语义动作则把归约结果变成 AST 或其他树结构。无论选择 LL 还是 LR,源码位置和错误恢复都是核心功能,不是后加装饰。

下一章会在树上建立符号与类型关系,再把高层结构降到更适合分析和生成代码的中间表示。

Built with VitePress | Software Systems Atlas