算符优先分析法是我当年学编译原理时觉得最“像一个正常人”的语法分析方法。因为你看表达式i + i * i,不需要先画什么推导树、算一堆 FIRST、FOLLOW,脑子里天然就知道先算乘法再算加法——算符优先分析法干的事,就是把这套“人脑直觉”形式化,变成一张表和一套移进-归约规则,喂给程序去执行。
如果你正在被自下而上的语法分析搞到头大,或者做实验课“算符优先分析表生成”做到怀疑人生,又或者准备面试时被问“算符优先分析和 LR 有什么区别”,那这篇内容应该能帮你把第 5 章这块硬骨头啃下来。我会把优先关系、FIRSTVT/LASTVT、素短语这些概念,用完整的手工推导和代码思路串起来,最后再聊聊我实际踩过的坑。
1. 自下而上分析的全局视图:优先分析法处在哪个位置
1.1 从“移进-归约”到“最右推导的逆过程”
自下而上分析的核心思想是:从输入串出发,不断把右部匹配的符号串“归约”成非终结符,最终归约到文法的开始符号。这个过程在形式上是最右推导的逆过程——也就是说,如果语法树存在,那么自下而上分析就是沿着最右推导的路径倒着走回起点。
为了描述每一步该归约哪个符号串,教材里引入了“句柄”这个概念。句柄本质上是某个产生式的右部,同时它出现在当前句型的最左位置,归约它就能和最右推导一一对应。你可以把句柄想象成“拆积木时最上面那块松动的积木”,每次只拆最可能拆的那一块。LR 分析器就是沿着这条严格路径走的,每次归约的必然是真句柄,属于规范归约。
但这里有一个很实际的问题:对于表达式文法,我们真的需要每次都精确定位句柄吗?其实不需要。人用手算表达式时,不会每一步都去检查“这是不是最右推导的逆过程”,而是只看运算符之间的优先级:*比+高,所以先算i * i;括号优先级最高,所以先算括号内的内容。算符优先分析法就是抓住了这种“运算符优先级”的本质,把分析过程从“识别句柄”简化为“比较相邻终结符的优先关系”,实现起来比 LR 简单一个量级。
1.2 为什么单独给“算符优先”开一条路线
在自下而上分析的众多方法里,算符优先分析(Operator Precedence Parsing)是一个很特别的存在。它不像 LR 那样对文法有非常一般性的要求,而是专门面向“运算符语言”设计的,最典型的就是算术表达式、布尔表达式、赋值语句这类文法。
它最大的优点是:分析表中不出现非终结符,终结符之间的优先关系才是主角。这意味着分析时不用关心栈里的非终结符具体是什么名字,只需要盯着终结符比较优先级,然后决定是移进还是归约。对于表达式文法来说,这种简化非常自然,而且实现起来的空间开销和时间开销都远小于同等级的 LR 分析表。
代价是什么呢?代价就是它牺牲了一部分能力。算符优先分析归约的未必是句柄,而是“最左素短语”,因此它不属于规范归约。如果文法比较复杂,或者两个终结符之间的优先关系出现冲突,这个方法就会失效。所以在学习时,我建议大家把算符优先分析看成“LR 的简化特例”,而不是独立的万能方法,这样理解它的能力和边界都会清晰很多。
2. 三种优先关系与 FIRSTVT、LASTVT 的核心定义
2.1 “小于、等于、大于”到底在比什么
算符优先分析定义了三对终结符之间的二元关系,符号分别是<、=(教材里常用 ≐ 表示)、>。注意,这里的“小于、等于、大于”不是数值比较,而是“优先级关系”的抽象:如果a < b,意思是终结符a的优先级低于终结符b,那么在实际分析中,当栈顶出现a而当前输入符是b时,应该优先处理b代表的运算,也就是先把b移进栈。
举个例子,在经典表达式文法里,+ < *表示加号的优先级低于乘号。处理i + i * i时,栈顶终结符是+,当前输入是*,因为+ < *,所以把*移进栈,而不是急着把+左边的部分归约。这直观对应了“先乘除后加减”。
a ≐ b表示两者优先级相等,通常出现在“括号配对”或者“同一个运算符连续出现但结合性需要特殊处理”的场景。比如(和)之间有( ≐ ),表示遇到右括号时可以把括号内的内容整体归约。a > b则表示栈顶终结符优先级更高,该做归约了。三种关系合在一起,构成了分析程序的决策依据:比较栈顶终结符和当前输入符,若<或≐则移进,若>则归约。
2.2 FIRSTVT 和 LASTVT 的递归计算规则
构造优先关系表之前,必须先算出每个非终结符的 FIRSTVT 集合和 LASTVT 集合。FIRSTVT(A) 的定义是:从 A 出发经过一步或多步推导,得到的句型中“最左边可能出现的终结符集合”。它和 FIRST 集合的区别在于,FIRSTVT 只关心第一个符号是终结符的情况,如果第一个符号是非终结符,则继续追踪这个非终结符的 FIRSTVT,直到遇到终结符为止。
计算规则可以归纳为两条基础规则和一条迭代规则:
- 如果产生式形如
A → a...或A → B a...,那么终结符a属于 FIRSTVT(A)。 - 如果产生式形如
A → B...,且b ∈ FIRSTVT(B),那么b也属于 FIRSTVT(A)。
用大白话说,第一条规则找“直接以终结符开头”的候选式,第二条规则负责把左部非终结符打头的候选式继续传递下去。
LASTVT(A) 的定义完全对称:从 A 出发推导出的句型中“最右边可能出现的终结符集合”。计算规则也对称:
- 如果产生式形如
A → ...a或A → ...aB,那么终结符a属于 LASTVT(A)。 - 如果产生式形如
A → ...B,且b ∈ LASTVT(B),那么b也属于 LASTVT(A)。
实际手工推导时,建议大家从最底层的非终结符往上推。因为底层非终结符的候选式最简单,FIRSTVT/LASTVT 一眼就能看出来,往上层层传递时才不容易漏项。
2.3 优先关系表的三条构造规则
有了 FIRSTVT 和 LASTVT,就可以构造优先关系表了。对文法中每条产生式逐一检查,应用下面三条规则:
若产生式右部出现
...a b...或...a B b...(a、b为终结符,B为非终结符),则a ≐ b。这条规则本质是:终结符紧挨着终结符,或者被一个非终结符隔开的两个终结符,它们在语法结构上处于同一层,所以优先级相等。若产生式右部出现
...a B...(a是终结符,B是非终结符),则对 FIRSTVT(B) 中的每个终结符b,都有a < b。意思是:终结符a后面跟了一个非终结符B,那么B能推导出的所有“开头终结符”优先级都比a高,处理时要先处理b。若产生式右部出现
...B a...(a是终结符,B是非终结符),则对 LASTVT(B) 中的每个终结符b,都有b > a。意思是:非终结符B后面直接跟终结符a,那么B能推导出的所有“结尾终结符”优先级都比a高,处理时先归约B的部分。
这三条规则一定要对照着具体产生式去理解,光背很容易弄混方向。我见过很多同学把第 2 条和第 3 条搞反,最后表里全是错的。有个笨但有效的记忆法:看产生式右部,终结符在前、非终结符在后,那就是“前面低、后面高”,记<;非终结符在前、终结符在后,那就是“前面高、后面低”,记>;两个终结符之间要么直接挨着,要么隔了一个非终结符,那就是“相等”,记≐。
3. 完整实例:经典表达式文法全流程演算
3.1 选用文法与目标说明
为了把上面的规则落到实地,我们用一个几乎所有教材都会用的经典表达式文法:
E → E + T | T T → T * F | F F → ( E ) | i这里E表示表达式,T表示项,F表示因子,终结符集合是{+, *, (, ), i},非终结符集合是{E, T, F},开始符号是E。注意这个文法没有把减法和除法放进来,是为了让推导过程更清爽,实际扩展时思路完全一样。
我们的目标是通过手工推导,得到完整的算符优先关系表,然后用这个表去分析输入串i + i * i,看它如何一步步完成归约。整个流程走完,你对优先分析法的理解会从“背规则”变成“会推规则”。
3.2 FIRSTVT 和 LASTVT 完整推导
先从最底层的F开始。F的产生式是F → ( E ) | i。第一条基础规则:遇到F → i,右部直接以终结符i开头,所以i ∈ FIRSTVT(F);遇到F → ( E ),右部也直接以终结符(开头,所以(也属于FIRSTVT(F)。因此:
FIRSTVT(F) = { (, i }再看 LASTVT(F)。产生式F → i右部以终结符i结尾,所以i ∈ LASTVT(F);产生式F → ( E )右部以终结符)结尾,所以) ∈ LASTVT(F)。因此:
LASTVT(F) = { ), i }接着推T。T的产生式是T → T * F | F。先看 FIRSTVT:产生式T → F右部以非终结符开头,所以FIRSTVT(F)中的所有元素都要传给T,即(, i进入FIRSTVT(T)。产生式T → T * F右部以非终结符T开头,没有直接以终结符开头的候选式,但注意右部第二个符号是终结符*,虽然它不是右部开头,但在 FIRSTVT 的定义里,A → B a...中的a也是 FIRSTVT(A) 的成员。这里的T → T * F正好就是A → B a...的形式:A是T,B是T,a是*。所以* ∈ FIRSTVT(T)。综合起来:
FIRSTVT(T) = { *, (, i }LASTVT(T):产生式T → F右部以非终结符结尾,LASTVT(F)中的), i都进入LASTVT(T)。产生式T → T * F右部以非终结符F结尾,所以LASTVT(F)继续传给T;同时它符合A → ...aB的形式,a是*,所以*也属于LASTVT(T)。综合:
LASTVT(T) = { *, ), i }最后推E。E → E + T | T。FIRSTVT(E):E → T把 FIRSTVT(T) 全部传过来,即*, (, i;E → E + T符合A → B a...,a是+,所以+ ∈ FIRSTVT(E)。因此:
FIRSTVT(E) = { +, *, (, i }LASTVT(E):E → T把 LASTVT(T) 全部传过来,即*, ), i;E → E + T符合A → ...aB,a是+,所以+ ∈ LASTVT(E)。因此:
LASTVT(E) = { +, *, ), i }这就是整个推导过程。检查一下有没有漏项:E的 FIRSTVT 里面没有),E的 LASTVT 里面没有(,都是符合预期的。
3.3 优先关系表的生成过程
有了 FIRSTVT/LASTVT,逐条产生式套三条规则。
先看F → ( E )。右部是( E ),符合...aB b...的模式,其中a是(,B是E,b是),所以规则 1 给出( ≐ )。同时这个产生式还包含...aB...的模式((后跟E),所以规则 2 给出:对 FIRSTVT(E) 中的每个终结符b,有( < b。FIRSTVT(E) 是{+, *, (, i},也就是说:
( < + , ( < * , ( < ( , ( < i再看F → i,没有成对的终结符,没有产生任何优先关系,这符合直觉:单个因子本身就是最基础的操作数,不需要额外定义优先级。
看T → T * F。右部是T * F,符合...B a...和...aB...同时出现的模式。先处理aB部分:*后跟非终结符F,规则 2 给出* < FIRSTVT(F),而 FIRSTVT(F) 是{(, i},所以:
* < ( , * < i再处理Ba部分:非终结符T后跟*,规则 3 给出 LASTVT(T) 中的每个终结符b,有b > *。LASTVT(T) 是{*, ), i},所以:
* > * , ) > * , i > *看T → F,右部只有单个非终结符,不产生任何优先关系。
看E → E + T。右部是E + T。规则 2 处理+后跟T:+ < FIRSTVT(T),而 FIRSTVT(T) 是{*, (, i},所以:
+ < * , + < ( , + < i规则 3 处理E后跟+:LASTVT(E) 中的每个终结符b,有b > +。LASTVT(E) 是{+, *, ), i},所以:
+ > + , * > + , ) > + , i > +看E → T,右部单个非终结符,不产生优先关系。
最后处理边界符号#。分析开始前栈底和输入串末尾都有#,它不属于文法终结符,但参与分析。习惯上定义:
# < FIRSTVT(E) , LASTVT(E) > # , # ≐ #也就是# < +、# < *、# < (、# < i,以及+ > #、* > #、) > #、i > #,还有# ≐ #。
把这些关系汇总到一张 6×6 的表格里,行表示栈顶终结符,列表示当前输入终结符,结果如下:
| 优先关系 | + | * | ( | ) | i | # |
|---|---|---|---|---|---|---|
+ | > | < | < | 空 | < | > |
* | > | > | < | > | < | > |
( | < | < | < | ≐ | < | 空 |
) | > | > | 空 | > | 空 | > |
i | > | > | 空 | > | 空 | > |
# | < | < | < | 空 | < | ≐ |
表中的“空”表示这一对终结符之间没有定义优先关系,一旦分析过程中需要比较它们,就说明输入串有语法错误。比如i i在正常表达式中是非法的,表中i行i列没有关系,程序会在这里报错,这是合理行为。
3.4 用优先关系表分析输入串i + i * i
有了表,分析过程就变成机械操作了。用一个栈存放文法符号,开始时栈底放#,输入串末尾也放#。每次比较栈顶终结符a和当前输入符b:
- 若
a < b或a ≐ b,把b移进栈; - 若
a > b,则找栈顶最左素短语进行归约; - 若没有定义关系,报错。
下面分析i + i * i,我列出关键步骤。为了可读性,非终结符统一归约为N,因为算符优先分析在归约时并不关心非终结符的具体名字,只知道“这里归约出了一个非终结符”就够了。
第一步,栈是#,输入是i + i * i #。查表#和i:# < i,移进i,栈变成# i,输入变成+ i * i #。现在栈顶终结符是i,当前输入符是+,查表i > +,所以归约。把栈顶的i归约为N,栈变成# N。
第二步,栈顶终结符是#,当前输入符是+,查表# < +,移进+,栈变成# N +,输入变成i * i #。接着比较栈顶终结符+和输入符i:+ < i,移进i,栈变成# N + i,输入变成* i #。
第三步,比较栈顶终结符i和当前输入符*:查表i > *,所以归约i为N,栈变成# N + N。继续比较栈顶终结符+和输入符*:+ < *,移进*,栈变成# N + N *,输入变成i #。再比较*和i:* < i,移进i,栈变成# N + N * i,输入变成#。
第四步,比较栈顶终结符i和输入符#:i > #,归约i为N,栈变成# N + N * N。此时栈顶终结符是*,输入符是#,查表* > #,继续归约。把N * N归约为N,栈变成# N + N。再比较栈顶终结符+和输入符#:+ > #,继续归约,把N + N归约为N,栈变成# N。
最后比较栈顶终结符#和输入符#:# ≐ #,分析成功结束。
观察整个流程可以发现,算符优先分析在第二步到第三步之间,实际上先归约了右边i * i中的因子,再归约整个乘积,最后才处理加法,完全符合“先乘除后加减”的运算顺序。这就是这个方法名字里“优先”两个字的真正含义——它不需要像 LR 那样严格寻找句柄,只需要按照优先关系决定“什么时候算、什么时候往后看”。
4. 素短语、最左素短语与文法合法性判定
4.1 素短语到底是个什么东西
既然算符优先分析归约的不是句柄,那它每次归约的到底是什么?答案是“最左素短语”。素短语的定义是:至少包含一个终结符,并且除自身之外不再包含任何更小的素短语的短语。
字面描述比较绕,我换个说法。在一个句型中,有些子串是一个产生式的右部,这些子串叫“短语”。句柄是其中最左边那个。素短语则额外要求:这个子串内部不能套着另一个可以归约的短语,而且它必须包含至少一个终结符(否则就退化成了只有非终结符的归约,无法用优先关系驱动)。
还是看i + i * i的句型N + N * i。在这个句型里,N * i是一个素短语,因为它包含终结符*和i,并且内部不再有可以归约的短语。N + N * i整体虽然也能匹配某个产生式的右部,但它内部包含N * i这个更小的可归约子串,所以它不是素短语。在每一步归约中,算符优先分析选择的是“最左边”的那个素短语,这就是“最左素短语”。
这个定义直接关系到归约动作的正确性。分析程序在决定归约时,不能随便归约一个能匹配产生式的子串,而必须找最左素短语。实现时通常采用“从栈顶向下扫描,找到第一个终结符优先级低于前一个终结符的位置”,这个位置到栈顶之间的子串就是要归约的素短语。你如果自己写分析器,这部分的代码逻辑值得单独测。
4.2 算符优先文法的三个判定条件
并不是任何文法都能用算符优先分析法,用之前需要先判断文法是不是“算符优先文法”。教材里的判定条件主要有三条:
- 文法中不能有形如
A → ...BC...的产生式右部,也就是说两个非终结符不能相邻出现。理由很明显:算符优先关系只定义在终结符之间,如果两个非终结符贴在一起,中间没有终结符作为“锚点”,就无法用优先关系驱动分析。 - 对任意两个终结符
a、b,它们之间至多只有一种优先关系成立。如果同时出现了a < b和a > b,或者既≐又<,就说明文法有歧义,优先关系表不唯一,分析程序不知道该移进还是归约。 - 没有二义性的基本要求:文法本身必须是二义性文法之外的非二义性文法。
这三条条件里,第二条在实际做题时最容易踩坑。比如有的同学推导+和+的优先关系时,发现从E → E + T得到+ > +,但内心又觉得“同一个运算符应该相等”,于是擅自写成+ ≐ +,结果构造出的表和标准答案对不上。其实在左结合文法里,同级运算符之间是>而不是≐,因为左结合意味着右边的运算符要“等左边的算完再算”,所以栈顶的运算符优先级更高。判断的时候一定要跟着规则走,不要凭直觉改结果。
4.3 优先函数:节省一张表的工程化手段
优先关系表虽然直观,但存储空间是终结符个数的平方。对嵌入式环境这种资源敏感的场景,可以把表压缩成两个一维数组:栈内优先函数f和输入优先函数g,让优先关系的比较变成整数比较。定义是:a < b当且仅当f(a) < g(b);a ≐ b当且仅当f(a) = g(b);a > b当且仅当f(a) > g(b)。
构造优先函数的方法有迭代法和关系图法。关系图法的思路是把每个终结符a拆成两个节点,一个代表f(a),一个代表g(a),根据优先关系连边:a < b从f(a)连向g(b),a > b从g(b)连向f(a),a ≐ b则同时连两条边。然后对每个节点求最长路径长度,作为其优先函数值。有环说明不存在优先函数。
但优先函数有个致命弱点:它把“无优先关系”和“优先级相等”混为一谈。原来的表中i和i之间无定义,转为优先函数后可能被解释为相等,导致错误输入无法及时报错。所以实际工程里如果要用优先函数,必须额外在文法层面保证“凡是表中无定义的位置,运行时一定不会出现”。这一点在做实验时容易被忽略,面试被问到优先函数时也常常作为加分点。
5. 程序化实现:把分析表生成和分析过程写成代码
5.1 数据结构怎么设计最顺手
如果你正在写“算符优先分析表生成”实验,第一步不是急着写算法,而是把数据结构设计好。我自己的习惯是:
- 终结符集合用一维数组存,
#放在最后一位; - FIRSTVT 和 LASTVT 用二维布尔矩阵表示,行是非终结符,列是终结符,值为
true表示该终结符属于对应非终结符的集合; - 优先关系表用二维字符数组存储,取值
' '、'<'、'>'、'='四种。
用布尔矩阵而不是set的好处是,后续迭代计算非常直观:直接遍历矩阵找true,而不需要做集合求并、判断元素是否存在等操作。对于学习级代码,这种“空间换逻辑”的思路能少掉很多头发。
5.2 FIRSTVT/LASTVT 迭代求解的核心代码逻辑
FIRSTVT 和 LASTVT 的计算可以统一用“迭代到不再变化”的方式实现。以 FIRSTVT 为例,初始时遍历所有产生式,找到满足A → a...或A → B a...的候选式,把终结符a标记进 FIRSTVT(A)。然后不断遍历产生式,只要遇到A → B...且 FIRSTVT(B) 中有新的终结符,就把这些终结符也加入 FIRSTVT(A),直到所有集合都不再增长。
这里给一段思路性的 Python 伪代码:
def compute_firstvt(grammar, non_terminals, terminals): firstvt = {nt: set() for nt in non_terminals} changed = True while changed: changed = False for lhs, rhs_list in grammar.items(): for rhs in rhs_list: # 规则: A -> a... 或 A -> B a... if rhs[0] in terminals: if rhs[0] not in firstvt[lhs]: firstvt[lhs].add(rhs[0]) changed = True elif len(rhs) >= 2 and rhs[0] in non_terminals and rhs[1] in terminals: if rhs[1] not in firstvt[lhs]: firstvt[lhs].add(rhs[1]) changed = True # 规则: A -> B... if rhs[0] in non_terminals: for b in firstvt[rhs[0]]: if b not in firstvt[lhs]: firstvt[lhs].add(b) changed = True return firstvtLASTVT 的代码完全对称,只是把“开头”换成“结尾”,把...aB和...B a的判断条件换一下。写代码时最容易出 bug 的地方是产生式右部的边界条件:len(rhs) >= 2判断一定要写,否则当右部只有一个非终结符时会越界。这个坑我在第一次写的时候踩过,报错还不是报在明显的地方,是算出来的集合莫名其妙少符号,排查了很久才发现是越界访问。
5.3 分析驱动主程序的实现要点
分析驱动主程序是另一个重要模块。核心逻辑就是维护一个栈,不断比较栈顶终结符和当前输入符,执行移进或归约。栈里面会混合存放非终结符和终结符,但比较优先级时,要从栈顶往下找“最近的那个终结符”,跳过非终结符。
伪代码大概是这样的:
def analyze(tokens, precedence_table, terminals): stack = ['#'] tokens.append('#') ip = 0 while True: # 找到栈顶最近的终结符 top_terminal = next(t for t in reversed(stack) if t in terminals) cur = tokens[ip] rel = precedence_table[top_terminal][cur] if rel == ' ': raise SyntaxError("非法终结符组合") if rel == '<' or rel == '=': stack.append(cur) ip += 1 elif rel == '>': # 找最左素短语并归约 # 从栈顶往下扫描, 找到第一个 '<' 的位置 # 中间的子串整体归约为非终结符 do_reduce(stack) if stack == ['#'] and cur == '#': break归约时不需要真的查产生式决定用哪个非终结符替换,因为算符优先分析不关心非终结符名字,直接把素短语替换成统一的N就行。这也是写实验代码时最省事的地方。如果后续要做语法树,那在归约时需要记录“这个素短语对应文法的哪个产生式”,再在栈里压入对应的左部非终结符。
实际上,如果实验要求是“生成优先关系表并分析句子”,我建议把表生成和分析驱动分成两个独立文件来写。表生成部分专心处理 FIRSTVT、LASTVT 和优先关系,分析驱动部分只依赖二维表结果,这样两边独立测试,出错了也容易定位。
6. 手工推导与实验中出现的高频问题
6.1 FIRSTVT/LASTVT 计算时的常见漏项
我批过不少同学的作业,FIRSTVT 和 LASTVT 计算中最常见的错误就三种。
第一种是漏掉形如A → B a...这种产生式右部“第二个位置”的终结符。很多人记得A → a...规则,却忘了B a...这种非终结符后面紧跟终结符的情况也要加入集合。比如E → E + T里,+属于 FIRSTVT(E) 不是因为它出现在右部开头,而是因为它出现在第一个非终结符E后面。
第二种是迭代传递时只传了一层。比如文法有三层产生式E → T、T → F、F → i,如果遍历一遍就结束,很可能只算出 FIRSTVT(T) 包含i,忘了继续传给 FIRSTVT(E)。解决办法就是前面代码里的while changed循环,一直循环到没有任何变化为止。手工算的时候,可以用“标记已传递”的方法,每轮只检查新增元素,不然很容易漏。
第三种是把#当作普通终结符参与 FIRSTVT/LASTVT 计算。#只是分析时的界符,不属于文法符号,计算 FIRSTVT/LASTVT 时不应该包含它。有同学在初始时把#放进去,结果优先关系表里多出一堆莫名其妙的行和列,分析时又对不上号。
6.2 优先关系表出现冲突怎么处理
如果算出的优先关系表中,同一对终结符既出现了<又出现了>,或者既≐又<,那基本可以断定文法不是算符优先文法。遇到这种情况,不要试着“手工改表”,而要回到文法层面解决。
最常见的处理手段是改写文法。比如原本有产生式E → E + E | E * E | (E) | i,这个文法有二义性,i + i * i可以有两种不同的语法树,导致+和*的优先关系无法唯一确定。解决办法就是把它分层,拆成我们前面用的E → E + T | T、T → T * F | F这种形式,让优先级通过文法层级天然体现。拆完之后再算优先关系表,冲突就消失了。
还有一种情况是左结合和右结合混用导致的问题。比如要支持赋值运算=的右结合,直接写E → id = E | ...会引入= > =还是= < =的疑问。多数教材的处理方式是把右结合的运算单独用一层产生式表示,或者在算优先函数时特殊处理。考试和面试里,最常见的考法就是给你一个二义性文法,让你判断它“是否是算符优先文法”,并说明理由,答案的核心就落在“两个终结符之间存在不止一种优先关系”这一点上。
6.3 面试常问的几个“为什么”
面试官问到优先分析法时,通常不是考你会不会构造表,而是考你对概念边界的理解。以下几个问题是我多次遇到的,也是我觉得最值得提前想清楚答案的。
问:算符优先分析和 LR 分析的核心区别是什么?答:LR 分析是规范归约,每次归约的必然是最右推导的逆过程对应的句柄;算符优先分析只比较终结符之间的优先关系,归约的是最左素短语,不是规范归约。LR 分析能力强、适用范围广,但分析表和状态机构建复杂;算符优先分析表小、实现简单,适合表达式类文法,但无法处理非算符优先文法。
问:为什么算符优先分析归约时不关心非终结符的具体名字?答:因为优先关系只定义在终结符之间,非终结符既不参与比较,也不影响移进归约决策。归约时只需要把素短语替换成一个非终结符并继续分析,语法树的构建是后续语义动作的事。
问:句柄和素短语有什么区别?答:句柄是规范归约中每一步要归约的短语,它一定是短语且是最左的;素短语是至少含一个终结符且内部不再含更小短语的短语。对算符优先分析来说,最左素短语是归约对象,但它不一定是句柄。一个经典反例是简单优先分析法中句柄和素短语可能不一致的情况,不过在算符优先分析的适用范围里,最左素短语已经足够完成语法识别。
6.4 实验课测试用例选择经验
最后分享一点实验课的实际经验。写完算符优先分析器之后,不要只测i + i * i这种教科书用例,一定要准备几组边界测试。
我建议至少要测四类:一是正常优先级测试,比如i + i * i、(i + i) * i,验证乘除优先和括号处理;二是连续运算测试,比如i + i + i,验证同级左结合的处理;三是嵌套括号测试,比如i * (i + (i + i)),验证多层括号场景;四是错误输入测试,比如i +、()、i i、i * * i,预期行为是程序能够报告语法错误,而不是陷入死循环或者数组越界。
尤其要注意i i这种错误输入。由于表中i和i没有优先关系,分析器比较栈顶符号和当前输入符时应该立刻报错。如果程序在这种输入下发散,大概率是“无优先关系”被当成了<或>处理,检查一下比较逻辑里是否严格判断了空格字符。
在实际写代码过程中,我的体会是算符优先分析适合用“先把表打出来、再手工验证几条路径”的方式调试。建一个很小的文法,打印 FIRSTVT、LASTVT 和优先关系表,对照教材答案核对,确认无误后再去跑分析程序。比起直接调一个庞大的分析器,这样分阶段验证能省下大量排查时间。优先分析法虽然不如 LR 强大,但它的简洁性至今仍是处理表达式文法的有效工具,也是理解更复杂自下而上分析算法的重要一步。