C# 语法分析器(五)错误恢复
系列导航
语法分析中的错误恢复是一个很复杂的问题,有多种可能的错误恢复策略。
不恢复
顾名思义,遇到错误后直接给出错误提示信息然后退出,属于简单粗暴的做法。
恐慌模式的错误恢复
在遇到错误时不断丢弃输入中的符号,直到找到同步词法单元集合中的某个元素为止。同步词法单元可以由开发者手动指定(通常是界限符,例如分号或结束括号)或通过一些方法自动选择。恐慌模式的错误恢复可能会跳过大量输入,但实现比较简单,适合作为下述错误恢复策略失败后的兜底。
短语层次的错误恢复
当发现错误时,语法分析器可以根据余下的输入进行局部错误纠正,例如插入一个缺少的符号,或者删掉一个多余的符号,使得语法分析可以继续进行下去。
这里的逻辑需要小心处理,否则容易导致进入无限循环。其主要不足在于难以处理实际错误发生在被检测位置之前的情况。
错误产生式
通过预测可能遇到的常见错误,可以在文法中加入特殊的产生式(例如 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$,那就只能从其中选择一个,我优先选择移入动作,如果时多个归约动作,那么选择其中出现较为靠前的那个。实现可以参见这里。
恐慌模式的错误恢复
如果短语层次的错误恢复失败了,就选择恐慌模式作为兜底,丢弃部分输入确保可以进入到一个可以继续分析的状态,甚至直接丢弃所有输入。
这里有两个问题:
- 应该丢弃多少输入。
- 应该采用状态栈中的哪个状态来识别后续输入。
显然我们不能只考虑栈顶状态,说不定输入中缺少了必要的符号,使得栈顶状态无法被正常归约。我们应当在丢弃输入和弹出状态之间选择一个合适的平衡,例如——丢弃最少的输入,使得状态栈最接近栈顶的状态能够识别余下的输入。
假设状态栈中包含状态 $[i_k, i_{k-1}, \cdots, i_2, i_1]$,其中靠右(标号较小)的状态更加接近栈顶,每个状态可能接受的终结符号集合为 $E_k$,$E_k$ 包含的就是存在 $\text{ACTION}[i_k, X]$ 时所有可能的 $X$ 的集合。
接下来要找到状态栈中所有可达状态的 $E_k$ 的集合 $S$,这个集合就是当前状态栈中所有可能接受的符号集合。
- 设栈顶状态为 $i$。
- $S += E_i$。
- 将 $i$ 对应产生式已读入的部分抛弃,包括其状态栈和符号栈。若此时栈顶状态为 $j$,$i$ 对应的产生式头为 $A$,那么将状态 $\text{GOTO}(j, A)$ 压栈。
- 将 $E_j$ 加入 $S$ 中。
- 令 $i = j$,重复步骤 2,直到状态栈全部扫描完毕。
在步骤 3,是要强制将状态 $i$ 归约掉,因此这里会有一个出栈再计算 $\text{GOTO}$ 的过程,需要用到 $i$ 的某个项。如果 $i$ 只有一个项,那自然没得选;如果有多个项,其实可以任选一个,我选择的是所有项中定点右边内容最少的。
找到了可能接受的符号集合 $S$,就可以不断丢弃输入,直到遇到 $S$ 中的终结符,此时就是可以允许继续进行语法分析的最近输入。接下来就是强制归约符号栈中的符号,直到找到能够接受输入的状态。相关实现可以参见这里。
到此为止,基本上可以实现一个完整的语法分析器了,后面就来介绍如何实际构造语法分析器。
本系列相关代码都可以在这里找到。
作者:CYJB
出处:http://www.cnblogs.com/cyjb/
GitHub:https://github.com/CYJB/
本文版权归作者和博客园共有,欢迎转载,但未经作者同意必须保留此段声明,且在文章页面明显位置给出原文连接,否则保留追究法律责任的权利。