简介:这份编译原理实验报告以算符优先分析法为主题,面向高校计算机相关专业正在学习编译原理课程的学生,尤其适合需要完成语法分析实验报告或理解算符优先分析原理的读者。报告来自华北水利水电学院的实验课程,围绕语法分析中的算符优先分析程序展开,包括根据给定文法构造FirstVt和LastVt集合、构建算符优先关系表,以及对(i+i)*i和i+i)*i等表达式进行正确性分析与归约过程演示等完整内容。资源为doc格式文档,共1个文件,压缩包约540KB,目前已获得132人学习浏览。文档包含完整的程序源代码,涉及priority数组、input数组、remain数组、AnalyseStack数组等核心数据结构,以及init、analyse、testchar、remainString等关键函数,读者可参照代码与说明深入理解算符优先分析法的实现流程与实验结论。
1. 算符优先语法分析:这门课里少数能让你「看到」分析过程的实验
编译原理课的实验二安排成算符优先分析,很多学校都这么做。比起实验一的词法分析,语法分析开始涉及完整的推导树、移进归约这些抽象概念,而算符优先正好是其中最容易落到代码里的:它不需要求 FIRST/FOLLOW 集,不用构造预测分析表,只要按几条规则生成终结符之间的优先关系,就能用一张二维表和一台栈机器把表达式串规约成开始符号。这篇笔记就围绕这个实验,把 FIRSTVT/LASTVT 怎么算、优先关系表怎么填、分析器怎么写拆开讲,再把实验报告里最容易翻车的几个细节提前指出来。适合正在写实验二的在校生,也适合想用最短路径理解自底向上分析原理的入门读者。
2. 算符优先分析为什么不需要 FIRST/FOLLOW 集:先立住理论再动手
2.1 算符优先分析定位:自底向上但只看终结符
语法分析分两大流派:自顶向下(递归下降、LL(1))和自底向上(LR、算符优先)。自顶向下从开始符号出发尝试匹配输入串,自底向上反着来,从输入串开始反复规约,直到归约成开始符号。LL(1) 需要求 FIRST 和 FOLLOW 集合,因为预测分析器要根据这两个集合决定「当前非终结符该展开哪条产生式」。算符优先法把问题大幅简化:它根本不管非终结符之间的推导关系,只定义终结符之间的优先级。分析过程中,每当栈顶终结符的优先级高于输入符号,就触发规约,把栈顶的一串符号归约成某个非终结符。这个「简单粗暴靠优先级驱动」的思路,使得它特别适合表达式文法,以及一切运算符层次分明的文法。代价是它的处理能力比 LR 弱,只能覆盖算符优先文法,而且不处理非终结符的归约选择——一个符号串能匹配多条产生式时,它不挑,这是它后来被 LR 系方法取代的核心原因。
2.2 FIRSTVT 与 LASTVT 的构造规则:两张集合表是全部基础
算符优先关系表既不是靠观察文法经验凑出来的,也不是从 FIRST/FOLLOW 推出来的。它依赖两组集合:
- FIRSTVT(P):非终结符 P 能推导出的所有串中,第一个终结符;或者第一个非终结符后面紧跟着的终结符。
- LASTVT(P):非终结符 P 能推导出的所有串中,最后一个终结符;或者最后一个非终结符前面紧跟着的终结符。
构造规则每条对应一种产生式形态。以 FIRSTVT 为例,对每个非终结符 A 的每条产生式:
- 形如 A→a… 或 A→Ba…,把终结符 a 加入 FIRSTVT(A)。这里 B 是任意非终结符,a 是终结符;
- 形如 A→B…,把 FIRSTVT(B) 全部并入 FIRSTVT(A);
- 重复迭代,直到所有集合不再变化。
LASTVT 对称:A→…a 或 A→…aB 加入 a,A→…B 则并入 LASTVT(B)。
构造过程必须用迭代直到固定点,因为规则 2 会产生传递闭包。比如文法 E→E+T | T,T 的 FIRSTVT 会传递进 E,而 E 自身的 FIRSTVT 又影响 T 的传递结果。手动算的时候经常漏掉这种传递,程序实现反而不会错,这也是为什么我建议无论如何都要写代码生成这两张表,不要手填。
2.3 三种优先关系由产生式形态决定:等于、小于、大于
得到 FIRSTVT 和 LASTVT 后,优先关系分三条规则填入:
- 对产生式 A→…aB…,终结符 a 与 FIRSTVT(B) 中每个元素构成 a <· b(a 优先级低于 b);
- 对产生式 A→…Bb…,LASTVT(B) 中每个元素与 b 构成 a ·> b(a 优先级高于 b);
- 对产生式 A→…ab… 或 A→…aBb…,a =· b(a 与 b 优先级相等)。
注意一个容易忽略的情况:规则 1 要求终结符后面紧跟非终结符,如果终结符后面直接是终结符,走的是规则 3 而不是规则 1。比如 F→(E),( 后面跟非终结符 E,于是 ( <· FIRSTVT(E);而 E→E+T 里的 + 后面跟终结符 T,但 T 是非终结符,所以 + <· FIRSTVT(T)。如果你把 ( 与 ( 的关系填成 =,那就错了——(E) 内部括号配对的是 ( =· ),而 ( <· ( 来自 F→(E) 和 (E) 内部嵌套的情形。
表格填完之后要检查:同一对终结符之间不能同时出现两种关系,否则不是算符优先文法。经典表达式文法 E→E+T | T, T→T*F | F, F→(E) | i 是算符优先文法,但反过来 E→E+E 这种就会出现 + 与 + 同时存在 <· 和 ·> 的冲突,直接判定非法。
2.4 用经典表达式文法完整走一遍集合计算
以一个四则运算文法为例,非终结符 E、T、F,终结符 + * ( ) i:
E → E+T | T T → T*F | F F → (E) | iFIRSTVT 的计算分三步走:
初始加入直接可见的终结符: F → (E):FIRSTVT(F) 加入 ( F → i:FIRSTVT(F) 加入 i T → T*F:FIRSTVT(T) 加入 * E → E+T:FIRSTVT(E) 加入 + 传递一轮: T → F:FIRSTVT(F) 并入 FIRSTVT(T) E → T:FIRSTVT(T) 并入 FIRSTVT(E) 结果: FIRSTVT(F) = { (, i } FIRSTVT(T) = { *, (, i } FIRSTVT(E) = { +, *, (, i }LASTVT 对称计算:
直接加入: F → (E):LASTVT(F) 加入 ) F → i:LASTVT(F) 加入 i T → T*F:LASTVT(T) 加入 * E → E+T:LASTVT(E) 加入 + 传递: T → F:LASTVT(F) 并入 LASTVT(T) E → T:LASTVT(T) 并入 LASTVT(E) 结果: LASTVT(F) = { ), i } LASTVT(T) = { *, ), i } LASTVT(E) = { +, *, ), i }优先关系分三条规则填:
| 产生式形态 | 填入的关系 |
|---|---|
| E→E+T(+后面跟非终结符 T) | + <· FIRSTVT(T),即 + <· {*, (, i} |
| T→T*F(*后面跟非终结符 F) | * <· FIRSTVT(F),即 * <· {(, i} |
| F→(E)((后面跟非终结符 E) | ( <· FIRSTVT(E),即 ( <· {+, *, (, i} |
| E→E+T(E后面跟+) | LASTVT(E) ·> +,即 {+, *, ), i} ·> + |
| T→TF(T后面跟) | LASTVT(T) ·>,即 {, ), i} ·> * |
| F→(E)(E后面跟)) | LASTVT(E) ·> ),即 {+, *, ), i} ·> ) |
| F→(E)(( E )中(与)相邻隔着E) | ( =· ) |
| F→i、E→E+T 等直接相邻的终结符 | 无(i 和 + 不直接相邻) |
检查冲突:以 + 和 + 为例,<· 关系里没有 + <· +,·> 关系里有 + ·> +,只此一种,合法。这就是这张表为什么能成为后面分析器的唯一依据。把这些集合和关系的计算写成函数,比手填可靠得多,下一章直接给出完整实现。
3. 用 Python 实现算符优先分析器:数据表构造、分析主循环与输出设计
3.1 文法数据结构和集合计算函数的实现
先定义产生式和终结符集合。实验报告里的文法通常固定,但代码最好能让文法作为输入参数,这样换文法不用改逻辑。
# 产生式:左部 -> 右部列表 grammar = { 'E': ['E+T', 'T'], 'T': ['T*F', 'F'], 'F': ['(E)', 'i'], } # 非终结符集合 non_terminals = set(grammar.keys()) # 终结符集合:从产生式右部收集所有不是非终结符的字符,外加 # terminals = set() for rhs_list in grammar.values(): for rhs in rhs_list: for ch in rhs: if ch not in non_terminals: terminals.add(ch) terminals.add('#')代码逻辑说明:文法用字典存,键是非终结符,值是产生式右部列表。终结符的收集方式充分利用了「右部里不属于非终结符集合的符号就是终结符」这一定义,但要注意i这种单字符终结符和( )这种单字符符号都能正确处理。若终结符是多字符的(比如if),这个简单实现就不行了,需要换成先声明终结符集合的方式,实验范围内单字符符号足够。
参数说明:E+T中E和T都是非终结符,+不是任何产生式左部,自动归入终结符集。#强制加入,用于标记输入串结束和分析栈的栈底。
FIRSTVT 和 LASTVT 的计算用迭代到固定点的方式:
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: # 规则1:A -> a... 或 A -> Ba...,把 a 加入 FIRSTVT(A) idx = 0 if rhs[0] in terminals: if rhs[0] not in firstvt[lhs]: firstvt[lhs].add(rhs[0]) changed = True # 规则1变体:A -> B a ...,把 a 加入 FIRSTVT(A) if 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 # 规则2:A -> B...,把 FIRSTVT(B) 并入 FIRSTVT(A) if rhs[0] in non_terminals: for item in firstvt[rhs[0]]: if item not in firstvt[lhs]: firstvt[lhs].add(item) changed = True return firstvt def compute_lastvt(grammar, non_terminals, terminals): lastvt = {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: # 规则1:A -> ...a 或 A -> ...aB,把 a 加入 LASTVT(A) if rhs[-1] in terminals: if rhs[-1] not in lastvt[lhs]: lastvt[lhs].add(rhs[-1]) changed = True if len(rhs) >= 2 and rhs[-1] in non_terminals and rhs[-2] in terminals: if rhs[-2] not in lastvt[lhs]: lastvt[lhs].add(rhs[-2]) changed = True # 规则2:A -> ...B,把 LASTVT(B) 并入 LASTVT(A) if rhs[-1] in non_terminals: for item in lastvt[rhs[-1]]: if item not in lastvt[lhs]: lastvt[lhs].add(item) changed = True return lastvt逻辑说明:每次遍历所有产生式时,只要任一集合发生变化就再跑一轮,直到没有变化。因为规则 2 的传递会带来连锁反应——FIRSTVT(T) 变化后 FIRSTVT(E) 也要跟着变,所以必须以固定点作为终止条件。手动计算时也是这个思路,只是人容易在第二轮传递时漏算。
3.2 优先关系表的自动填充与合法性校验
拿到两组集合后,填表是一个三层循环:遍历产生式、遍历右部、逐个字符判断形态。判断的是「终结符-非终结符」「非终结符-终结符」「终结符-终结符」「终结符-非终结符-终结符」四种形态。
def build_precedence_table(grammar, non_terminals, terminals, firstvt, lastvt): # 关系矩阵用字典嵌套字典表示,missing 表示未定义 matrix = {} for t1 in terminals: matrix[t1] = {} for t2 in terminals: matrix[t1][t2] = '' def add_relation(a, rel, b): if matrix[a][b] != '' and matrix[a][b] != rel: raise ValueError(f"冲突: {a} {matrix[a][b]} {b} 已存在," f"又想定义为 {a} {rel} {b},不是算符优先文法") matrix[a][b] = rel for lhs, rhs_list in grammar.items(): for rhs in rhs_list: n = len(rhs) for i, ch in enumerate(rhs): if ch not in terminals: continue # 情形 A -> ... a B ... if i + 1 < n and rhs[i+1] in non_terminals: for b in firstvt[rhs[i+1]]: add_relation(ch, '<', b) # 情形 A -> ... B b ... if i - 1 >= 0 and rhs[i-1] in non_terminals: for a in lastvt[rhs[i-1]]: add_relation(a, '>', ch) # 情形 A -> ... a b ... if i + 1 < n and rhs[i+1] in terminals: add_relation(ch, '=', rhs[i+1]) # 情形 A -> ... a B b ... if (i + 2 < n and rhs[i+1] in non_terminals and rhs[i+2] in terminals): add_relation(ch, '=', rhs[i+2]) # 补 # 的边界关系: # < FIRSTVT(开始符号),LASTVT(开始符号) > # start_symbol = list(grammar.keys())[0] for a in firstvt[start_symbol]: add_relation('#', '<', a) for a in lastvt[start_symbol]: add_relation(a, '>', '#') # # 与 # 相等(用于接受状态判断) add_relation('#', '=', '#') return matrix逻辑说明:每个终结符 ch 在右部出现时一次性检查四种形态,比先遍历产生式再遍历右部更紧凑。注意<和>方向别写反——a <· FIRSTVT(B)意味着 a 的优先级低于 B 推导出的所有首个终结符,分析时遇到这种状态要移进,不是要规约。#的边界关系是后来单独补的,因为#不在文法产生式里,循环不会自动覆盖它。
参数说明:add_relation里的冲突检测是实验报告里「证明该文法是算符优先文法」的代码化表达。文法如果不是算符优先文法,构造时就抛异常,比分析到一半再报错直观得多。
3.3 最左素短语规约与分析主循环
分析器持有一个栈和一个输入指针。每次取栈顶终结符和当前输入符号比较,小于或等于就移进,大于就规约。规约的关键不是随便弹几个符号,而是要找到最左素短语的边界——从栈顶往下弹出,直到遇到一个终结符,它和再往前一个终结符的关系是<,这个<就是素短语的左边界。
def parse(input_str, grammar, non_terminals, terminals, matrix): start_symbol = list(grammar.keys())[0] stack = ['#'] # 分析栈 ip = 0 # 输入指针 tokens = list(input_str) + ['#'] steps = [] # 记录分析步骤,用于输出 while True: # 找栈顶终结符 top_idx = len(stack) - 1 while stack[top_idx] in non_terminals: top_idx -= 1 top_terminal = stack[top_idx] cur = tokens[ip] if top_terminal == '#' and cur == '#': steps.append("接受") return True, steps rel = matrix[top_terminal].get(cur, '') if rel == '<' or rel == '=': # 移进 stack.append(cur) ip += 1 steps.append( f"移进 {cur}: 栈={' '.join(stack)}, 输入剩余={' '.join(tokens[ip:])}" ) elif rel == '>': # 规约:弹出最左素短语 phrase = [] # 弹出栈中符号直到找到素短语左边界 while True: sym = stack.pop() phrase.insert(0, sym) if sym in terminals: # 找到栈中下一个终结符 top_idx = len(stack) - 1 while top_idx >= 0 and stack[top_idx] in non_terminals: top_idx -= 1 if top_idx < 0: break prev_term = stack[top_idx] # 素短语左边界:prev_term < 当前终结符 if matrix[prev_term].get(sym, '') == '<': break # 匹配产生式右部 phrase_str = ''.join(phrase) matched = None for lhs, rhs_list in grammar.items(): if phrase_str in rhs_list: matched = lhs break if matched is None: raise RuntimeError(f"无法匹配产生式: {phrase_str} 栈={' '.join(stack)}") stack.append(matched) steps.append( f"规约 {phrase_str} -> {matched}: 栈={' '.join(stack)}, 输入剩余={' '.join(tokens[ip:])}" ) else: raise RuntimeError( f"语法错误: 栈顶终结符 {top_terminal} 与输入符号 {cur} 之间无优先关系," f"当前栈={' '.join(stack)},剩余输入={' '.join(tokens[ip:])}" )逻辑说明:移进时输入符号直接入栈,出栈的边界由优先关系自动决定。规约时先弹符号构成候选短语,短语的右边界就是触发>的那个栈顶终结符,左边界靠「遇到<就停」这条规则确定。弹出时处理了栈顶可能是非终结符的情况——算符优先分析的栈里非终结符不参与优先级比较,所以找终结符时要用循环跳过。匹配产生式时用「右部在 rhs_list 里」判断,这是因为文法可能出现在栈短语是某个右部的后缀的情况,但算符优先分析的素短语应该恰好等于某个右部,这里直接匹配是稳妥的。
参数说明:top_idx从栈顶向下找终结符,phrase用insert(0, ...)倒序恢复弹出顺序。rel可能为空字符串,此时说明该对终结符之间没有定义关系,属于文法不覆盖的输入,直接报语法错误。这样设计比给一个统一的错误处理更符合实验要求——实验报告通常要求指出非法输入在哪一步被拒绝。
3.4 输出设计:满足实验报告的步骤表格
实验报告最常要求的输出是「移进/规约过程表」,格式为步骤、符号栈、输入串、动作、产生式。在 parse 函数里已把每一步的文本记录在steps中,最后加一段格式化的打印即可:
def print_steps(steps): for i, step in enumerate(steps, 1): print(f"{i:3d}. {step}") if __name__ == '__main__': firstvt = compute_firstvt(grammar, non_terminals, terminals) lastvt = compute_lastvt(grammar, non_terminals, terminals) matrix = build_precedence_table(grammar, non_terminals, terminals, firstvt, lastvt) print("FIRSTVT:", firstvt) print("LASTVT:", lastvt) print("优先关系表:") terms = sorted(terminals) print(" " + " ".join(f"{t:>4}" for t in terms)) for t1 in terms: row = [f"{t1:>3}"] for t2 in terms: rel = matrix[t1].get(t2, '') row.append(f"{rel:>4}") print(" ".join(row)) test_cases = ["i+i*i", "(i+i)*i", "i+i-i", "i++i", "(i+i"] for case in test_cases: print(f"\n=== 分析: {case} ===") try: ok, steps = parse(case, grammar, non_terminals, terminals, matrix) print_steps(steps) except RuntimeError as e: print(f"错误: {e}")逻辑说明:先把两张集合和优先关系表打印出来,这是报告的「数据准备」部分。然后对每个测试用例跑分析器,合法输入列出完整步骤,非法输入输出异常信息,异常信息里带当前栈和剩余输入,方便调试时一眼看出问题在哪。
4. 算符优先实验避坑:5 个最容易翻车的地方
4.1 规约时选错产生式:E+T 被规约成了 T
现象:分析i+i*i,步骤走到最后把 E+T 直接规约成了 T,导致栈顶出现 T+T 这种无法继续的状态,或者最终归约不到 E。
原因:算符优先分析法在规约时只知道素短语的内容,不知道上下文。当栈里弹出E+T时,它能匹配的产生式有两类:E→E+T 和(不可能,因为右部是 E+T 的只有一条)。真正容易出问题的是 T→T*F 和 E→T 之类会产生歧义的设计,或者你的文法里出现了 E→E+T 和某个 T 的右部恰好也是单个非终结符 E 的形态。更多时候是因为素短语边界定错了,弹出的不是 E+T 而是 E+T 和相邻符号混在一起。
解决:规约前必须打印素短语内容,与产生式右部一一比对。规范做法是只匹配「恰好等于」的右部,不要做子串匹配,也不要出现多个匹配时默认取第一条。多个匹配在标准文法里不应该出现,出现了说明文法不是算符优先文法,或者优先关系表构造有误。排查顺序:先查素短语的左右边界,再查 FIRSTVT/LASTVT 的迭代是否完整。
4.2 把 ( 与 ( 的关系填成了 =,嵌套括号全部乱套
现象:测试(i+i)*i或((i)),分析到第二个左括号时栈顶是(,输入也是(,没有<关系,直接报语法错误。
原因:F→(E) 给出 ( <· FIRSTVT(E),而 FIRSTVT(E) 里包含(,所以 (<·( 必须存在。有人看到括号成对人类直觉就觉得应该相等,把填入的关系覆盖成了=,导致双重括号的左边界识别失败。
解决:不要手工填优先表,用程序生成。程序按规则 1 遇到「终结符+非终结符」形态自动为 ( 和 FIRSTVT(E) 中每个元素建立<关系,天然包含 (<·(。如果你坚持手填,记住等号只出现在「ab」或「aBb」这两种形态里,F→(E) 是 ( 和 ) 相等,不是 ( 和 ( 相等。
4.3 分析栈顶部是非终结符时关系查找错误
现象:合法输入分析到一半突然报「无优先关系」,但错过上一次规约就把问题掩盖了。
原因:栈顶可能是一个刚规约出来的非终结符,比如输入i+i,第一步把i规约成F,此时栈是# F + i,栈顶是i吗?不是,栈顶是i还没入栈?不对——移进 i 后栈是# i,输入是+,比较栈顶终结符 i 和 + 的关系时,i确实在栈顶。但经过一次规约后,栈变成# F + i,此时要比较的是+和#吗?也不是,是栈顶终结符+和输入#。如果代码直接用 stack[-1] 取栈顶元素,取到的是F(非终结符),用 F 去查优先关系表,查不到,报错。
解决:查找符号时用循环跳过栈顶的所有非终结符,取最近的一个终结符。代码里用while stack[top_idx] in non_terminals: top_idx -= 1处理的就是这个。这是算符优先分析实现里最常见、也最容易忽略的细节,很多同学的代码第一次跑合法用例翻车都翻在这里。
4.4 # 号的优先关系漏填,出现死循环或错误接受
现象:输入i#时分析器不报错也不接受,一直在移进和规约之间反复,或者错误地接受了i+i#但处理到最后的#时查不到关系直接崩。
原因:#不在文法产生式的右部出现,所以三条基本规则不会自动为#生成任何关系。边界关系必须补两条:# <· FIRSTVT(开始符号) 和 LASTVT(开始符号) ·> #。漏了第一条,输入串还没开始分析就报错;漏了第二条,规约到 E 之后无法与结束符#建立关系,永远到不了接受状态。
解决:构造表最后显式补三条:# < FIRSTVT(E),LASTVT(E) > #,# = #。第三条约等于在矩阵里写matrix['#']['#'] = '=',是接受状态判定的依据。不补第三条约,最后比较#和#时拿到空串,会走错误分支。
4.5 非法输入的错误处理位置太靠后,报告里没法展示拒斥点
现象:测试i++i,分析器不是在第二个+处报错,而是把i+规约完才报,或者直接忽略第一个+继续分析。
原因:分析器的错误检测时机是「栈顶终结符与当前输入符号之间无关系」。如果两个+之间恰好存在+ > +这种关系,错误可能被误判为一次规约而不是语法错误。算符优先分析对非法输入的检测能力本身就是有限的,它能识别所有「无法建立优先关系」的情形,但不是所有非法输入都会在第一时间被发现。
解决:在报告里把i++i、i+*i这种用例作为「分析过程中报错」的典型展示,不要声称它能精确到字符级别定位错误。如果希望错误位置更早暴露,在移进前先检查输入符号是否属于终结符集合,不属于直接报错;再检查当前符号与前一个移进的符号之间是否满足某种合理性约束。但这超出了算符优先分析的标准范畴,实验报告写到「在 X 步发现无优先关系,报错」就是完整交代了。
5. 把实验报告写出区分度:测试用例设计、运行结果展示与报告骨架
5.1 测试用例不要只跑一个 i+i*i
实验报告最常见的通病是只贴一个i+i*i的成功分析过程。这只能证明代码能跑通一条路径,不能证明分析器处理了文法中的所有产生式和优先关系。合格的测试集至少要覆盖:每个产生式至少触发一次规约、括号配对的嵌套结构、同一优先级运算符的左结合性、优先级不同的运算符混合、非法输入至少 3 条。
| 测试用例 | 覆盖目标 | 预期结果 |
|---|---|---|
| i+i*i | 全部四条产生式、+ 与 * 的优先级 | 接受,归约到 E,触发规约 5 次 |
| (i+i)*i | 括号优先级高于 *,F→(E) 的规约路径 | 接受,括号内的 E 先归约完成 |
| (i)# | 括号包裹单个操作数,=· 关系的使用 | 接受 |
| i+i-i | + 与 - 同一优先级,左结合性 | 接受,且先规约左侧的 i+i |
| i++i | 连续运算符 | 报错,指出+与+无优先关系 |
| (i+i | 括号不匹配,输入提前结束 | 报错,#与)或类似位置关系缺失 |
| i+*i | 运算符后直接跟运算符 | 报错,+与*有 <· 关系,但移进后 * 与 i 的关系会暴露问题 |
每条用例在报告里给三样东西:输入串、分析步骤表(或至少关键步骤)、结果判定。非法输入要标出是在第几步、因为哪对终结符没有关系而报错,这是「分析器能识别非法输入」的直接证据。
5.2 分析步骤表怎么排版:每行四个字段对应一次操作
实验报告里最占篇幅也最好写的是移进规约过程表。每行记录四个信息:步骤号、符号栈(从栈底到栈顶)、剩余输入串(从当前指针到末尾)、动作(移进哪个符号或规约成哪个非终结符)。规约行单独标注使用了哪条产生式,因为编译原理课的批改人往往直接对着产生式核对规约路径。
i+i*i的完整步骤表在报告里大约 12 行,开头是从#栈开始第一次移进i,中间经过 2 次规约后栈变成# E + i * i,再移进i后规约i到F再规约T*F到T,最终栈变为# E + T再规约到# E,遇到#与#相等关系接受。写报告时把这个表完整放出来,比贴十行代码截图更能体现对算法的理解。
5.3 报告骨架按这个顺序组织,批改老师要找什么一眼能看到
一份能拿高分的实验二报告不需要花哨排版,但结构必须完整。我建议的章节顺序是:
- 实验目的——一句话说清「实现算符优先分析器,掌握 FIRSTVT/LASTVT 构造、优先关系表生成、移进归约过程」;
- 文法与数据准备——列出使用的文法,给出 FIRSTVT、LASTVT 的计算结果(注意把迭代过程写出来,不要只写最终集合);
- 优先关系表——完整矩阵,并另起一段说明该矩阵满足算符优先文法条件(即任意两终结符之间至多有一种关系);
- 核心算法描述——用自然语言或伪码描述主循环,不需要贴全部源码,但移进/规约/查表三个核心分支要写清楚;
- 测试与运行结果——按 5.1 的表格逐条给出输入、输出、分析步骤关键行;
- 问题与体会——写 2~3 个实际调试中遇到的问题,比如「栈顶终结符查找」或「# 边界关系遗漏」,每个问题按现象、原因、解决三段写。
报告的区分度通常不在代码量,而在第 3 章和第 5 章。优先关系表完整填出并逐行说明来历,以及非法输入精确到第几步报错,这两点能明显拉开档次。
5.4 用实验验证分析法正确性的三种检查方式
写完分析器之后,先别急着截图,用三种方式验证正确性。第一种是人工对拍:拿i+i*i手工走一遍移进规约,把每一步的栈内容写成纸面记录,再和程序输出比对。第二种是文法变更测试:把文法改成只含加法的E→E+T | T, T→i,重新生成表和运行,看优先关系表是否只包含+、i、( )相关的关系,这能验证程序不是写死表达式文法。第三种是穷举短输入:列出所有长度为 3 的由i + * ( )组成的符号串,逐一喂给分析器,标记哪些被接受、哪些报错,观察接受集合是否符合文法的语言。第三种对算符优先分析器特别有效,因为优先表不处理非终结符选择,某些理论上不合法的串可能被接受,这些边界正是实验讨论部分的好素材。
6. 从实验二往前走一步:让分析器在规约的同时做语义动作
实验做完如果只停留在「能判断输入合法」,价值少了一半。算符优先分析天然适合在规约时挂动作——归约E+T时把两个操作数相加,归约T*F时做乘法,这样分析器就变成一个表达式计算器。做法是在 parse 函数的规约分支里增加对 matched 的判断,维护一个值栈,与符号栈同步操作。每规约一次,从值栈弹出对应数量的操作数,计算结果压回去。这样i+i*(i+i)的输入串经过整个分析过程后,值栈里只剩一个最终结果。
更进一步,可以把打印步骤的文本替换成四元式输出。规约E+T时生成一条(+, E1, T1, E)记录,规约F→(E)时直接把内层 E 的值作为 F 的值传递。这就是中间代码生成的雏形。实验报告在末尾加一小节「扩展:表达式求值」,把语义动作代码和控制台输出放上去,能直接证明分析器有实用价值,而不只是教学演示。我当年做这个实验时最后悔的事就是没有把语义动作加进去,答辩时老师问「你的分析器除了判断合法性还能干什么」,答不上来,只能现场补代码。后来带学生做实验,我总建议他们先把值栈设计好,再把分析器接上,半天就能多做一个功能。这个方向的投入产出比很高,希望你做完实验二别急着交,往前多走一步,会有意外收获。希望帮到你。
本文还有配套的精品资源,点击获取