C# 语法分析器(五)错误恢复

系列导航

  1. (一)语法分析介绍
  2. (二)LR(0) 语法分析
  3. (三)LALR 语法分析
  4. (四)二义性文法
  5. (五)错误恢复
  6. (六)构造语法分析器

语法分析中的错误恢复是一个很复杂的问题,有多种可能的错误恢复策略。

不恢复

顾名思义,遇到错误后直接给出错误提示信息然后退出,属于简单粗暴的做法。

恐慌模式的错误恢复

在遇到错误时不断丢弃输入中的符号,直到找到同步词法单元集合中的某个元素为止。同步词法单元可以由开发者手动指定(通常是界限符,例如分号或结束括号)或通过一些方法自动选择。恐慌模式的错误恢复可能会跳过大量输入,但实现比较简单,适合作为下述错误恢复策略失败后的兜底。

短语层次的错误恢复

当发现错误时,语法分析器可以根据余下的输入进行局部错误纠正,例如插入一个缺少的符号,或者删掉一个多余的符号,使得语法分析可以继续进行下去。

这里的逻辑需要小心处理,否则容易导致进入无限循环。其主要不足在于难以处理实际错误发生在被检测位置之前的情况。

错误产生式

通过预测可能遇到的常见错误,可以在文法中加入特殊的产生式(例如 Bison 的 error 产生式)。如果在语法分析中识别到了错误产生式,就意味着识别到了一个预期的错误,可以给出最合适的提示信息。但其主要不足在于需要较多人工设计,工作量较大。

全局纠正

在理想情况下,语法分析器可以选择一个最小的改动序列,使错误输入串能够以最低的开销转化为语法正确的串。但其成本过高,不太具有实用价值。

这里我提供了短语层次和恐慌模式两种错误恢复策略,期望在较低的人工成本下达到可用的错误恢复效果。

短语层次的错误恢复

我们可以直接在 LR 语法分析表的空白处(表示错误)插入错误恢复动作来实现短语层次的错误恢复,这里以上一章的二义性算式文法为例。

首先,如果某个状态具有唯一的归约状态(例如状态 3 只有“使用 $E \to id$ 归约”这一种归约状态),那我们可以在其空白(表示错误)处填入该归约动作。这种修改能够简化语法分析表,不过可能会使报错延后到一次或多次归约动作,但错误仍可以在任何移入动作发生之前被发现。

然后,在其它空白位置填入错误恢复动作,注意这里的动作是手工填入的。

$$\begin{array}
{|c|cccccc|ccc|}
状态 & id & + & * & ( & ) & \$ & E \\
0 & s3 & e1 & e1 & s2 & e2 & e1 & 1 \\
1 & e3 & s4 & s5 & e3 & e2 & acc & \\
2 & s3 & e1 & e1 & s2 & e2 & e1 & 6 \\
3 & r3 & r3 & r3 & r3 & r3 & r3 & \\
4 & s3 & e1 & e1 & s2 & e2 & e1 & 7 \\
5 & s3 & e1 & e1 & s2 & e2 & e1 & 8 \\
6 & e3 & s4 & s5 & e3 & s9 & e4 & \\
7 & r1 & r1 & s5 & r1 & r1 & r1 & \\
8 & r2 & r2 & r2 & r2 & r2 & r2 & \\
9 & r4 & r4 & r4 & r4 & r4 & r4 & \\
\end{array}$$

图 1 二义性算式文法的 LALR 语法分析表(包含错误恢复动作)

其中:

  • $e1$: 在状态 0、2、4、5 上使用,期望读入 $id$ 或 $($。
    动作是将状态 3(状态 0、2、4、5 在输入 $id$ 上的 $\text{GOTO}$ 目标)压入栈中,并发出诊断信息“缺少运算分量”。
  • $e2$: 在状态 0、1、2、4、5 上使用,发现输入是 $)$。
    动作是从输入中删除右括号,并发出诊断信息“不匹配的右括号”。
  • $e3$: 在状态 1、6 上使用,期望读入运算符。
    动作是将状态 4(状态 1、6 在输入 $+$ 上的 $\text{GOTO}$ 目标)压入栈中,并发出诊断信息“缺少运算符”。
  • $e4$: 在状态 6 上使用,期望读入输入结束标记。
    动作是将状态 9(状态 6 在输入 $)$ 上的 $\text{GOTO}$ 目标)压入栈中,并发出诊断信息“缺少右括号”。

执行这样带有错误回复动作的 LR 语法分析表,处理 $id+)$ 时的步骤如下所示:

状态栈 符号栈 输入 动作
0 $id+)\$$ 移入到 3
0 3 $id$ $+)\$$ 按照 3 $E \to id$ 归约
0 1 $E$ $+)\$$ 移入到 4
0 1 4 $E+$ $)\$$ 按照 e2 恢复,删除右括号,输出“不匹配的右括号”
0 1 4 $E+$ $\$$ 按照 e1 恢复,进入状态 3,输出“缺少运算分量”
0 1 4 3 $E+id$ $\$$ 按照 3 $E \to id$ 归约
0 1 4 7 $E+E$ $\$$ 按照 1 $E \to E+E$ 归约
0 1 $E$ $\$$ 接受

以上的错误恢复全部依赖手工填入,工作量非常大,而且需要理解 LR 语法分析表中的每一项,在稍大型的文法中几乎是不可行的。

而且这样的错误恢复动作几乎不可能在构造语法分析表时自动生成,因为其中包含了很多人工选择。

  • $e1$ 中,选择状态 3(对应输入 $id$)而非状态 2(对应输入 $($)。这里倒是可以通过对比 $\text{FOLLOW}(id)$、$\text{FOLLOW}(()$ 与错误输入的关系来自动决策,但这也仅限于移入动作的场景,如果遇到归约动作,由于在静态分析阶段无法确认归约后的栈顶状态,自然也无法确认 $\text{FOLLOW}$ 的内容。
  • $e3$ 中,选择状态 4(对应输入 $+$)可以说完全是人工选择的结果了,因为语法分析层面 $+$ 和 $*$ 是完全等价的,几乎没有区分的可能。

如果将这样的错误恢复动作后移到语法分析时,就可以利用分析时的上下文信息实现自动的错误恢复了。

下面均假设当前状态为 $i$,当前输入为 $a$,下一输入为 $b$。

尝试移除一个多余的符号

这个错误恢复逻辑非常简单,只要 $\text{ACTION}[i, b]$ 存在即可把 $a$ 删掉。实现可以参见这里

尝试插入一个符号

这个错误恢复逻辑略微复杂,首先需要检查每个存在 $\text{ACTION}[i, X]$ 的非终结符 $X$,并模拟语法分析(不能真的去修改状态栈)直到 $X$ 被移入,并转移到状态 $j$。

然后就可以检查是否存在 $\text{ACTION}[j, a]$,如果存在的话就将找到的 $X$ 添加作为候选,这样找到的所有候选非终结符,就是在插入到输入流后,能够恢复到可以正常接受 $a$ 的状态。

如果这时存在多个 $X$,那就只能从其中选择一个,我优先选择移入动作,如果时多个归约动作,那么选择其中出现较为靠前的那个。实现可以参见这里

恐慌模式的错误恢复

如果短语层次的错误恢复失败了,就选择恐慌模式作为兜底,丢弃部分输入确保可以进入到一个可以继续分析的状态,甚至直接丢弃所有输入。

这里有两个问题:

  1. 应该丢弃多少输入。
  2. 应该采用状态栈中的哪个状态来识别后续输入。

显然我们不能只考虑栈顶状态,说不定输入中缺少了必要的符号,使得栈顶状态无法被正常归约。我们应当在丢弃输入和弹出状态之间选择一个合适的平衡,例如——丢弃最少的输入,使得状态栈最接近栈顶的状态能够识别余下的输入。

假设状态栈中包含状态 $[i_k, i_{k-1}, \cdots, i_2, i_1]$,其中靠右(标号较小)的状态更加接近栈顶,每个状态可能接受的终结符号集合为 $E_k$,$E_k$ 包含的就是存在 $\text{ACTION}[i_k, X]$ 时所有可能的 $X$ 的集合。

接下来要找到状态栈中所有可达状态的 $E_k$ 的集合 $S$,这个集合就是当前状态栈中所有可能接受的符号集合。

  1. 设栈顶状态为 $i$。
  2. $S += E_i$。
  3. 将 $i$ 对应产生式已读入的部分抛弃,包括其状态栈和符号栈。若此时栈顶状态为 $j$,$i$ 对应的产生式头为 $A$,那么将状态 $\text{GOTO}(j, A)$ 压栈。
  4. 将 $E_j$ 加入 $S$ 中。
  5. 令 $i = j$,重复步骤 2,直到状态栈全部扫描完毕。

在步骤 3,是要强制将状态 $i$ 归约掉,因此这里会有一个出栈再计算 $\text{GOTO}$ 的过程,需要用到 $i$ 的某个项。如果 $i$ 只有一个项,那自然没得选;如果有多个项,其实可以任选一个,我选择的是所有项中定点右边内容最少的。

找到了可能接受的符号集合 $S$,就可以不断丢弃输入,直到遇到 $S$ 中的终结符,此时就是可以允许继续进行语法分析的最近输入。接下来就是强制归约符号栈中的符号,直到找到能够接受输入的状态。相关实现可以参见这里

到此为止,基本上可以实现一个完整的语法分析器了,后面就来介绍如何实际构造语法分析器。

本系列相关代码都可以在这里找到。

posted @ 2022-11-02 10:26  CYJB  阅读(267)  评论(0编辑  收藏  举报
Fork me on GitHub