简介:编译原理实验的完整源码包,内容涵盖词法分析器、LL(1)语法分析器和LR(1)语法分析器,适合正在完成编译原理课程设计或实验的学生参考。源码基于第一个实验扩展而来,除可识别关键字、标识符、运算符、分界符与无符号数外,还增加字符/字符串和行间注释的匹配,并配有图形界面前端,演示效果直观。压缩包共五十二个文件、约六点八八兆字节,其中cpp与h为源代码和头文件,in与out为测试用例及预期输出,pdf文档和html页面提供说明与界面展示,gif动图可查看运行效果,另有makefile等构建文件便于项目复现。已有1089人浏览学习,借助项目说明与目录结构可快速了解使用方式;从词法分析到LL(1)与LR(1)两种语法分析策略,形成完整实现链条,方便对比不同分析方法的处理流程与代码差异,对深入理解编译原理非常有帮助。
1. 编译原理实验三件套:为什么词法分析器、LL(1)语法分析器和LR(1)语法分析器总是一起出现
拿到「编译原理实验之词法分析器、LL(1)语法分析器、LR(1)语法分析器.zip」这个压缩包标题的人,多半正处于编译器课程最焦灼的阶段:实验一要求你写一个词法分析器,把源代码切成token;实验二要求你用LL(1)方法做自顶向下的语法分析;实验三又升级成LR(1)自底向上分析。三个实验看似独立,其实是一条完整编译前端的拆解——词法分析器输出token流给语法分析器,LL(1)和LR(1)只是两种不同的语法分析策略,前者适合手写递归下降,后者适合处理更复杂的文法。这篇文章就是照着这三件套的落地思路写的:每种分析器用什么数据结构、代码怎么组织、参数怎么调、验证结果时看什么,以及那些只有亲手写过才会踩到的边界问题。适合正在做课程设计、需要在一周内交付可运行代码的读者,也适合想搞明白预测分析表和LR(1)项目集为什么那样构造的入门者。
2. 词法分析器:从正则到状态机的落地与第一个坑
词法分析器是整个实验里最容易被低估的一块。很多同学觉得它无非是读字符、判断是不是字母数字、切出单词,写个几百行的if-else就完事了。但等到LL(1)和LR(1)分析器开始吃token流时,词法层的边界问题才会暴露:注释没跳过、字符串里的关键字被误判、最长匹配没做导致>=被切成>和=,这些都会让后面的语法分析器拿到错误的输入序列,而且报错位置根本不指向词法错误本身。
2.1 为什么词法分析器是后续两个分析器的地基
语法分析器的输入不是源代码,而是词法分析器产出的token序列。LL(1)预测分析表按终结符查表,LR(1)的ACTION表也按终结符做状态迁移,所以词法层输出的token类别必须和语法层定义的终结符一一对应。比如你定义了一个>=运算符,却不在词法层做最长匹配,语法分析器会收到>和=两个token,查表时发现文法里根本没有这个产生式,报一个完全看不懂的语法错误。这类问题排查起来特别费时间,因为问题出在词法层,症状却表现在语法层。
词法分析器的常见实现有两种:手工状态转移和基于正则表达式构造DFA。课程实验里通常要求后者,因为能体现正则到NFA再到DFA的转化过程。但实际写代码时,绝大多数人能跑通的方案是直接构造DFA驱动表——把状态转移用手工推导出来,写成一个二维表,用一个循环驱动。这样既避免了NFA转DFA的子集构造复杂度,又能看到状态机的本质。
我一般会建议先用一种最直观的方式理解词法分析器:把源程序看成字符流,词法分析器就是一个带记忆的字符阅读器,它记住当前状态、当前已匹配的字符序列,每读一个字符就查一次状态转移表,决定是继续读、还是切出一个token、还是报错。这个模型一旦建立,后面的DFA代码就是照着填表。
2.2 最小可用实现:DFA驱动表与最长匹配代码
下面这段代码是一个极简的DFA驱动词法分析器核心,支持标识符、整数、>=、<=、==、!=等常见运算符。状态转移表被硬编码成一个字典,键是当前状态,值是字符类别到下一状态的映射。
import re # 状态定义 # 0: 初始状态, 1: 标识符, 2: 整数, 3: 比较符 >=, 4: 比较符 <=, 5: 赋值 = # 6: 比较符 ==, 7: 比较符 !=, 8: 错误状态 def char_type(c): if c.isalpha() or c == '_': return 'letter' if c.isdigit(): return 'digit' if c == ' ' or c == '\n' or c == '\t': return 'space' if c in '+-*/%(){};,.': return 'op_sep' if c in '><!=': return 'compare' if c == '"': return 'quote' return 'unknown' # 状态转移表: 当前状态 -> 字符类别 -> 下一状态 # 用 -1 表示"应该在此切分token" trans_table = { 0: {'letter': 1, 'digit': 2, 'compare': 3, 'space': 0, 'op_sep': 8, 'quote': 9, 'unknown': -1}, 1: {'letter': 1, 'digit': 1, 'space': -1, 'op_sep': -1, 'compare': -1, 'quote': -1, 'unknown': -1}, 2: {'letter': 8, 'digit': 2, 'space': -1, 'op_sep': -1, 'compare': -1, 'quote': -1, 'unknown': -1}, 3: {'letter': -1, 'digit': -1, 'space': -1, 'op_sep': -1, 'compare': -1, 'quote': -1, 'unknown': -1}, 4: {'letter': -1, 'digit': -1, 'space': -1, 'op_sep': -1, 'compare': -1, 'quote': -1, 'unknown': -1}, 5: {'letter': -1, 'digit': -1, 'space': -1, 'op_sep': -1, 'compare': -1, 'quote': -1, 'unknown': -1}, } # 为简化,这里把已完整识别为 >=, <=, ==, !=, = 的判定放到主循环里做 def tokenize(src): tokens = [] i = 0 n = len(src) state = 0 start = 0 while i < n: c = src[i] ct = char_type(c) if ct == 'space': if state != 0: # 有缓存的token需要切出 tokens.append(make_token(src[start:i], state)) state = 0 i += 1 start = i continue nxt = trans_table[state][ct] if ct != 'compare' and state == 0 else 0 # 简化版:比较符单独处理,做最长匹配 if ct == 'compare' and state == 0: # 先吃下第一个比较符字符 state = 3 i += 1 continue if state == 0: state = trans_table[0][ct] start = i i += 1 elif trans_table[state].get(ct, -1) == -1: # 当前字符无法继续当前token,先切token,再从当前字符重新开始 tokens.append(make_token(src[start:i], state)) state = 0 start = i # 不增加i,下一轮重新从当前字符开始 else: state = trans_table[state][ct] i += 1 if state != 0: tokens.append(make_token(src[start:], state)) return tokens def make_token(lexeme, state): if state == 1: return ('ID', lexeme) if state == 2: return ('INT', int(lexeme)) if state == 3: if lexeme == '>=': return ('GE', lexeme) if lexeme == '>': return ('GT', lexeme) if state == 4: if lexeme == '<=': return ('LE', lexeme) if lexeme == '<': return ('LT', lexeme) return ('OP', lexeme)逻辑说明:这段代码的核心是状态转移表trans_table,它把词法分析器抽象成“当前状态 + 当前字符类型 -> 下一状态”的映射。主循环里的关键分支是trans_table[state].get(ct, -1) == -1,表示当前字符不能延续已有的token,于是先把已匹配的字符序列切出来,再从当前字符重新开始。这里用的是最长匹配思路:状态机在遇到空格或无法转移的字符时才切token,保证了>=这类双字符运算符不会被切成两个单字符token。
参数说明:char_type函数决定了字符分类粒度,这里把>、<、=、!归为compare类型,让状态机在初始状态统一走状态3,然后在实际切token时再判断具体是哪个运算符。这样做的好处是新增一个比较运算符时只需要改make_token,不用改转移表。trans_table里的-1表示“此路不通”,对应切分token或者报错,和DFA里非接受状态遇到非法输入的处理一致。注意代码里状态0到状态3的转移我直接跳过了trans_table查询,因为>、<、=、!这四个字符在初始状态的下一个状态都相同,是3,这种简化写法在实验代码里很常见,但如果你要处理+=、-=这类赋值运算符,就得为每个运算符单独设状态了。
2.3 词法分析器的三个必调参数与常见误用
词法分析器真正需要关注的参数不是代码里的某个魔法数字,而是三个设计决策:最大标识符长度、字符串与注释的处理方式、错误恢复策略。
最大标识符长度在课程实验里通常设为64或128个字符。有的实验指导书会要求“标识符长度不超过8个字符”,这是早期编译器的遗留规则,如果你按这个限制实现,现代代码里稍微长一点的变量名就会被截断。我的建议是不要做硬性截断,而是在超过64个字符时报错或者记录警告,这样既满足实验要求又不会莫名其妙丢失字符。你可能会问这个参数有什么用——它在状态机的实现里体现为一个计数器,每次进入标识符状态就加1,超过阈值就标记当前token为错误token。真正的坑在于:很多同学把这个阈值做成了固定数组大小,一旦输入超长就直接越界或崩溃,而不是在错误恢复路径里处理它。
字符串和注释的处理是词法分析器最容易翻车的地方。字符串字面量里可能出现关键字、运算符、甚至换行(多行字符串),如果状态机没有单独的字符串状态,就会出现字符串内容被切碎成多个token的情况。注释同理,//和/* */都必须作为独立的词法状态处理。我见过的最典型的错误是:有人把注释跳过逻辑写在主循环外,结果遇到多行注释时状态没有保存,注释内的所有内容都被当成代码切了出去。正确的做法是给注释单独设状态,让状态机在读入/*后一直停留在注释状态,直到遇到*/才回到初始状态。
错误恢复策略决定了你在遇到非法字符时报错后是继续扫描还是直接终止。课程实验通常要求报告错误的位置(行号和列号)并且跳过非法字符继续分析。实现这个需要维护行号和列号计数器:遇到\n行号加1、列号归零,其他字符列号加1。踩坑在于很多同学的报错信息只输出“illegal character”,不输出位置,语法分析器拿到残缺的token流后报的错误让人完全无法定位。建议从一开始就把(line, column)嵌进token结构里,后面LL(1)和LR(1)报错时能直接引用token的位置信息。
3. LL(1)语法分析器:FIRST/FOLLOW集合与预测分析表怎么才不算错
语法分析器实验通常有两种实现路线:递归下降分析和表驱动的预测分析。LL(1)这个名字里的第一个L表示从左到右扫描输入,第二个L表示最左推导,1表示向前看一个词法单元。表驱动的LL(1)要求你构造预测分析表,而预测分析表的质量取决于FIRST集合和FOLLOW集合算得对不对。这一步是很多同学第一次写就卡住的地方——FIRST和FOLLOW的定义都能背,但真正动手算的时候不是漏了ε的传递,就是FOLLOW集合里忘了加$。
3.1 LL(1)的判定边界:不是所有文法都能直接进表
LL(1)文法必须满足两个条件:同一非终结符的多个产生式的FIRST集合不相交,且如果某个产生式能推导出ε,它的FIRST集合(去掉ε后)不能和FOLLOW集合相交。这两个条件就是预测分析表构造过程中有没有冲突的判据。你拿到一个文法后,第一步不是上手写代码,而是先手算一遍FIRST和FOLLOW,看看有没有冲突。比如经典的左递归文法E -> E + T | T就不是LL(1)文法,因为E的两个产生式都能以T的FIRST集合开头,直接冲突,必须先消除左递归才能继续。
边界情况在于:有的文法消除左递归后仍然不是LL(1),比如存在公共左因子A -> aB | aC的文法。这时候需要提取左因子,把A -> aB | aC改写成A -> aA'、A' -> B | C。实验里给了文法还好,如果让你自己设计文法,建议先检查是不是存在公共左因子和左递归,有就提前处理,不然预测分析表构造出来全是冲突项,根本没法查。
3.2 生成FIRST/FOLLOW集合的计算顺序与代码骨架
FIRST集合的计算顺序是:先处理所有非终结符的终结符产生式,再迭代处理非终结符产生式,直到没有新元素可以加入。FOLLOW集合则依赖FIRST集合的结果,还要处理产生式右部末尾的非终结符。
def compute_first(rules, nonterminals, terminals): first = {nt: set() for nt in nonterminals} # 初始化:处理 X -> a... 形式的产生式 for lhs, rhs_list in rules.items(): for rhs in rhs_list: if rhs[0] in terminals: first[lhs].add(rhs[0]) # 迭代:处理 X -> Y1 Y2 ... Yn 形式 changed = True while changed: changed = False for lhs, rhs_list in rules.items(): for rhs in rhs_list: # 如果右部第一个符号是非终结符 if rhs[0] in nonterminals: before = len(first[lhs]) first[lhs].update(first[rhs[0]] - {'ε'}) # 如果 Y1 能推导出 ε,还要看 Y2 idx = 0 while idx < len(rhs) and rhs[idx] in nonterminals and 'ε' in first[rhs[idx]]: idx += 1 if idx < len(rhs): if rhs[idx] in terminals: first[lhs].add(rhs[idx]) else: first[lhs].update(first[rhs[idx]] - {'ε'}) else: first[lhs].add('ε') if len(first[lhs]) != before: changed = True return first def compute_follow(rules, nonterminals, terminals, first, start_sym): follow = {nt: set() for nt in nonterminals} follow[start_sym].add('$') changed = True while changed: changed = False for lhs, rhs_list in rules.items(): for rhs in rhs_list: for i, sym in enumerate(rhs): if sym in nonterminals: before_len = len(follow[sym]) # 处理 A -> α B β 形式 if i + 1 < len(rhs): rest = rhs[i + 1:] # 计算 FIRST(β) rest_first = set() for rs in rest: if rs in terminals: rest_first.add(rs) break else: rest_first.update(first[rs] - {'ε'}) if 'ε' not in first[rs]: break rest_first.add('ε') follow[sym].update(rest_first - {'ε'}) # 如果 FIRST(β) 包含 ε,则 FOLLOW(B) 包含 FOLLOW(A) if 'ε' in rest_first: follow[sym].update(follow[lhs]) else: # 处理 A -> α B 形式 follow[sym].update(follow[lhs]) if len(follow[sym]) != before_len: changed = True return follow逻辑说明:compute_first的迭代过程反映了FIRST集合的传递性:X -> Y1 Y2 ...中,X的FIRST集合必须包含Y1的FIRST集合去掉ε,如果Y1能推导出ε,还要继续看Y2,依此类推,直到遇到一个不能推导出ε的符号,或者右部全部扫描完(此时X能推出ε)。compute_follow的关键在于处理产生式右部末尾的情况:A -> α B时,FOLLOW(B)必须包含FOLLOW(A);A -> α B β时,FIRST(β)里如果有ε,FOLLOW(B)也得包含FOLLOW(A)。这个ε的判断是整个FOLLOW集合最容易漏的地方——很多同学只算了FIRST(β),忘了检查里面有没有ε,导致FOLLOW集合缺项,预测分析表构造出来也自然对不上。参数说明:这两个函数都用了changed标志位做不动点迭代,每次循环至少有一个集合在变大,当所有集合都不再变化时终止。这是集合计算的通用套路,比三重循环更不容易出错,代码也更清晰。
3.3 预测分析表的冲突检查与报错定位
预测分析表是一个二维表,行是非终结符,列是终结符(包括$),表项是产生式右部。构造规则只有两条:对每个产生式A -> α,把α加入M[A, b](其中b属于FIRST(α));如果α能推导出ε,就把α加入M[A, b](其中b属于FOLLOW(A))。如果某个表项在填表过程中被填了两次,说明文法不是LL(1)文法。
实验里最常见的冲突场景是:FIRST(α)中包含ε,而FOLLOW(A)里恰好有某个终结符也在FIRST(α)里。这种冲突在代码里体现为一个表项被赋值两次。排查思路是:先打印出所有出现二次赋值的(A, b)组合,然后回看A的哪些产生式引发了冲突,再检查这个产生式的FIRST集合和A的FOLLOW集合的交集。定位到具体产生式后,要么改写文法,要么接受这门语言不是LL(1)的事实,换LR(1)。
弹性的处理方式是给预测分析表加一列错误恢复标志。当查表发现表项为空时,常见的做法是跳过当前token,继续往后看,直到找到一个能让分析继续的token。这个策略在课程实验里通常不需要实现,但你要知道:表驱动的LL(1)分析器在报错后能不能恢复,取决于错误恢复逻辑,而不是预测分析表本身。如果你只做到“查表为空就报错终止”,实验验收时一旦遇到非法输入,你会被追问“为什么不能指出具体错误位置”。
4. LR(1)语法分析器:项目集规范族与ACTION/GOTO表的构造思路
LR(1)是自底向上的分析方法,比LL(1)能处理更多文法,但构造过程也更复杂——需要构造LR(1)项目集规范族、计算闭包、生成ACTION表和GOTO表。课程实验里LR(1)往往是综合性最强的那个,因为数据结构的组织方式直接决定代码好不好调。LR(1)分析表的核心是状态集合之间的迁移关系,编码方式是每个状态一个编号,ACTION表的键是(状态, 终结符),GOTO表的键是(状态, 非终结符)。
4.1 为什么LR(1)比LL(1)更“能打”:LR(1)与LL(1)的对比选型
LR(1)的优势在于它的分析栈里保存的不是文法符号,而是状态。每个状态是一个项目集,项目是“产生式 + 圆点位置 + 向前看符号”。这种结构让分析器在做决策时不只依赖当前token,还依赖整个分析栈的状态,所以LR(1)能处理左递归文法、处理更复杂的运算符优先级关系,也不需要先消除左递归。如果你拿到的实验文法里有左递归或者有二义性,建议直接选LR(1)路线,省去改写文法的麻烦。
代价则是表的规模。LL(1)的预测分析表行数等于非终结符数量,LR(1)的ACTION/GOTO表行数等于状态数量,而LR(1)的状态数量可能膨胀到几百甚至上千。课程实验里文法规模小,建表过程内存完全够用,不需要考虑压缩。真正需要选型的时候是你要在报告里写清楚“为什么选LR(1)而不选LL(1)”——推荐从文法类型和处理能力两个角度说:实验文法是左递归的还是右递归的,算符优先级能不能用LL(1)表达,这些判断做出来,选型理由就自然成立了。
4.2 项目集规范族构造与ACTION/GOTO表生成的代码实现
LR(1)项目集规范族的核心是closure和goto两个操作。closure负责把一个项目的圆点后面的非终结符对应的产生式加入当前项目集,同时计算向前看符号;goto负责把项目集按某个文法符号迁移到新的项目集。
def closure(items, rules, first): # items: 项目集合, 每个项目是 (lhs, rhs, dot_pos, lookahead) result = set(items) changed = True while changed: changed = False new_items = set() for lhs, rhs, dot, la in result: if dot < len(rhs) and rhs[dot] in nonterminals: # 需要计算 FIRST(β a), 其中 β = rhs[dot+1:] B = rhs[dot] beta = rhs[dot + 1:] # 计算 FIRST(beta + la) first_beta_la = set() first_beta_la.update(compute_first_of_string(beta, first, terminals, nonterminals)) if 'ε' in first_beta_la: first_beta_la.discard('ε') first_beta_la.add(la) else: # beta 不能推导空串, 用 FIRST(beta) first_beta_la = first_beta_la - {'ε'} # 把 B 的所有产生式加进来 for prod in rules[B]: new_items.add((B, prod, 0, frozenset(first_beta_la))) # 合并新项目 before_len = len(result) result.update(new_items) if len(result) != before_len: changed = True return result def goto(items, sym, rules, first): moved = set() for lhs, rhs, dot, la in items: if dot < len(rhs) and rhs[dot] == sym: moved.add((lhs, rhs, dot + 1, la)) if not moved: return None return closure(moved, rules, first)逻辑说明:closure的循环里,每当圆点后面出现非终结符B,就把B的所有产生式作为新项目加入当前集合,且圆点位置为0。向前看符号的计算是LR(1)和LR(0)的关键区别:如果项目是A -> α · B β, a,那么对B的产生式B -> · γ,向前看符号是FIRST(β a)。代码里用compute_first_of_string计算β的FIRST集合,如果包含ε就并入la。注意这里我把每个项目的lookahead设成frozenset,因为同一个项目可能有多个向前看符号,合并时不能丢。goto做的是把圆点从B前面移动到B后面,然后对新集合再做闭包。参数说明:la的类型用不可变集合,是为了能放进Python的set里做去重;实际跑的时候,不同产生式来源的项目向前看集合可能不同,用frozenset避免哈希出错。
ACTION表和GOTO表的生成逻辑是:对每个项目集(状态)和每个终结符a,如果项目是A -> α · a β,则ACTION[状态, a] = 移入;如果项目是A -> α ·,则对每个向前看符号a,ACTION[状态, a] = 归约。识别接受状态的方法是:如果项目集里包含增广产生式S' -> S ·,则ACTION[状态, $] = acc。GOTO表则直接用goto函数的结果填充:对每个非终结符A,GOTO[状态, A]= 对应goto返回的新状态编号。
4.3 LR(1)表规模膨胀的处理边界
LR(1)表的规模膨胀有两个常见诱因:向前看符号的组合爆炸和状态集合的过度细分。前者出现在closure计算里,一个项目的向前看符号集合可能因为多个产生式的FIRST集合叠加而变得很大;后者则是因为每个状态都携带完整的向前看信息,导致状态数量远多于LR(0)。解决方法有一个经典的折中:使用LALR(1)——把向前看符号相同但核心项目相同的状态合并。课程实验里如果验收要求是LR(1)而不是LALR(1),就不做合并;但如果实验说明里只写着“LR分析器”,建议确认清楚是不是允许LALR(1),因为合并后表的规模小很多,代码也更容易调试。
一个实际的边界是:当文法包含几十个产生式时,LR(1)状态可能达到几百个,调试工具输出状态图会非常长。这时候建议加一个自动导出功能,把ACTION表和GOTO表输出成CSV,再用表格工具打开看,比盯着终端输出舒服得多。这个习惯在你需要面对大量状态时非常有用——直接把ACTION表贴到实验报告里,验收时也更有说服力。
5. 实验环节最常见的5个坑:从报错信息到结果验证的排查路径
无论词法分析器、LL(1)还是LR(1),实验报告里写得再漂亮,代码跑不通就是零分。下面5个坑是这几个实验里出现频率最高的,每一条都是实际调试经验,按“现象、原因、解决”的结构写,照着排查能节省大量时间。
坑1:语法分析器收到的token流里混入了EOF或$之外的意外字符。现象是LL(1)预测分析表查表时报“表项为空”,但输入串明明符合文法。原因多半是词法分析器把非法字符跳过之后没有报告位置信息,而语法分析器是按token位置信息报错的,导致看到报错位置是一堆乱七八糟的行列号。解决方法是给词法分析器输出的每个token都带上行列号,并且在语法分析器入口打印整个token流,先确认输入是否正确,再排查分析表。
坑2:FIRST集合计算结果和手算不一致。现象是预测分析表某几个表项缺失或冲突,但手工推导觉得没问题。原因通常是迭代终止条件写错:有的代码在while changed里更新changed时用了局部变量,循环体里别的集合变化没有反映到全局标志位上。解决方法是把changed的逻辑改成“任何集合有变化就置True”,并且在每次迭代后打印所有非终结符的FIRST集合,和手算结果对比,通常两三轮就能发现问题。
坑3:LL(1)预测分析表把ε当作终结符填了进去。现象是查表时遇到ε报错,或者分析栈弹出ε后没法正确归约。原因是构造表时忘了把FIRST(α)里的ε去掉,直接把含ε的集合填进表项。解决方法是填表前统一执行first[nt].discard('ε'),只在FOLLOW相关的表项处理里使用含ε的原始集合。
坑4:LR(1)构造出来的ACTION表里同一个状态在同一个终结符下既有移入又有归约。现象是运行分析器时发现移入/归约冲突。原因可能是文法本身有二义性,也可能是在计算closure时向前看符号集合算错了。二义性文法没法通过程序修补,必须改写文法。如果确认原文法没有二义性,那就打印当前状态的项目集合,逐个检查冲突项对应的项目来源,常见错误是FIRST(β a)计算时把β和a的顺序写反了。
坑5:归约时弹出栈后压入GOTO状态时栈顶判断错误。现象是分析过程中栈里的状态序列和符号序列对应不上,最终报“栈顶符号不匹配”。原因是把归约动作实现成了“弹出一个符号就压入GOTO状态”,正确的做法是按产生式右部长度弹出所有相关符号,记录弹出的符号序列,再把产生式左部非终结符压入符号栈,然后按当前符号栈栈顶查GOTO表。这个坑在递归下降实现的LL(1)里不存在,但在表驱动的LR(1)里特别常见,因为分析栈和符号栈是两条栈一起维护的,弹错栈就会连环出错。
6. 把三个分析器串成一条完整链路:一个可执行的集成验证方法
三个分析器单独跑通之后,最值得做的事是把它们串成一条流水线:源代码字符串进入词法分析器,产出token流;token流进入LR(1)分析器(或LL(1)),产出语法分析树;语法分析树的根节点就是程序入口。这个集成验证并不需要写复杂的中间表示,核心是统一token接口和错误传递方式。
具体做法是定义一个Token类,字段包括type、value、line、column。词法分析器输出list[Token],语法分析器接收同样的类型。错误处理也统一:词法错误直接抛异常带位置信息,语法错误则在分析表查空时抛异常并带上当前token的位置。这样测试时只要写一个测试脚本,喂几段有代表性的代码,就能直观看到三件套配合是否正常。
验证用例至少要有四类:完全合法的程序、含有词法错误(如非法字符@)、含有语法错误(如缺少右括号)、边界输入(空输入和只有$的输入)。跑完以后检查输出:合法程序应该成功构建语法树并打印分析过程;词法错误应该报出具体行列号;语法错误应该报出出错token和期望的终结符。这几个用例一过,实验验收的核心要求就基本满足了。
最后说一个这些年调编译实验的心得:三个分析器不是各自为战的零件,而是一整条错误定位链。词法分析器的行列号信息如果丢一层,语法分析器的报错就失去方向;预测分析表如果填错一格,分析过程会毫无道理地卡死在某个状态。所以动手写之前先设计接口,把token类型、位置信息、错误类型统一好;动手写之后每跑通一个阶段就固化一个测试用例。这套习惯能让你少走一半弯路,希望帮到你。
本文还有配套的精品资源,点击获取