尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

南邮编译原理实验一:词法分析器DFA设计与Python实现

南邮编译原理实验一:词法分析器DFA设计与Python实现 简介本资源为南京邮电大学编译原理实验一的词法分析器构造资料面向计算机科学与技术等专业修读编译原理课程的学生以及需要完成词法分析实验、理解单词识别与编码原理的学习者。压缩包内共1个doc文档约7.69MB内容为完整实验报告涵盖实验目的、环境配置、设计概要、状态转换图、实现分析与代码解析等模块。报告以C语言子集为分析对象给出关键字、运算符、界限符、整型常数与标识符的正规文法定义并说明单词类别编码规则同时提供关键字检测、界限符与运算符识别、字母数字判断及保留字表查询等核心函数的实现思路。读者可借助该报告理解词法分析器从读取源文件、切分token到返回类别编码的完整流程对照代码梳理状态转换逻辑为后续语法分析实验打下基础。目前已有196人学习适合需要参考实验报告结构、排错思路与编码实现细节的同学使用。1. 词法分析器到底在做什么从南邮实验一的输入输出说起如果你手头正好有一份南京邮电大学编译原理实验一的题目大概率第一反应是“词法分析不就是把字符流切成单词吗”然后打开编辑器准备写一个巨大的 switch-case。但真正动手跑一遍测试用例你会发现事情没那么简单题目给的源程序里可能有//注释、可能有/* */跨行注释、可能有12.5e-3这种带指数的浮点数、可能有和这种需要最长匹配的运算符。这些边界情况才是实验一真正要练的东西。这份资源就是围绕南邮编译原理实验一的词法分析任务整理的核心目标是把一段类 C 或类 Pascal 的源程序逐字符扫描后输出种别码, 属性值形式的 token 序列。它适合正在上编译原理课、需要交实验报告但不想从零造轮子的同学也适合已经工作但想回头补一补编译器前端基础的开发者。关键词就一个编译原理实验里的词法分析把 DFA 从纸面搬到代码里。2. 从正则到 DFA词法分析的状态机怎么设计2.1 先分清三类 token 的处理策略词法分析的本质是把正则表达式翻译成有限自动机再让自动机逐字符跑。但实验一里通常不会让你真的去写一个正则引擎而是手动构造状态转移。我一般会把所有 token 分成三类来处理第一类是关键字和标识符。关键字是标识符的子集所以先按标识符的规则读入一整串字母数字下划线再去查关键字表。如果命中关键字表就返回关键字种别码否则返回标识符种别码。这样做的好处是不用为每个关键字单独写一条状态路径。第二类是常量和数字。整数、浮点数、科学计数法浮点数要分开处理。整数就是纯数字串浮点数需要处理小数点科学计数法还要处理e或E后面的正负号和指数部分。这里最容易翻车的是12.这种只有小数点没有小数部分的写法以及12.5e这种指数部分缺失的写法必须做合法性校验。第三类是运算符和界符。这里的关键是最长匹配读到不能立刻返回要看下一个字符是不是读到也要看下一个字符是不是。常见做法是用一个 peek 函数预读下一个字符匹配成功后再消费掉。2.2 状态转移表怎么写才不容易乱手动写状态机最怕的就是状态爆炸。我的经验是先把所有 token 的正则写出来然后合并成一张状态转移表。以南邮实验一常见的类 C 语言子集为例状态表大概长这样当前状态输入字符下一状态动作S0字母/下划线S1开始读标识符S0数字S2开始读数字S0空白S0跳过S0/S3可能是注释或除号S1字母/数字/下划线S1继续读S1其他S4查关键字表返回S2数字S2继续读S2.S5进入小数部分S2e/ES6进入指数部分S3/S7行注释S3*S8块注释S3其他S9返回除号这张表的好处是每个状态只关心“遇到什么字符往哪走”不用在代码里写一堆嵌套 if。实际写代码时可以用一个二维数组或者字典来存但实验一规模不大直接写 switch-case 也够用。2.3 用 Python 实现一个可跑通的扫描器骨架下面这段代码是我自己写实验一时常用的骨架核心逻辑是get_token函数每次调用返回一个 token 或遇到文件结束。代码里保留了状态机的痕迹方便对照上面的状态表。# 词法分析器核心骨架 # 输入源程序字符串 # 输出token 列表每个 token 是 (种别码, 属性值) 元组 KEYWORDS { int: 1, float: 2, if: 3, else: 4, while: 5, return: 6, void: 7 } # 种别码约定关键字 1-7标识符 10整数 11浮点数 12 # 运算符 20-39界符 40-49注释和空白不返回 class Lexer: def __init__(self, source): self.src source self.pos 0 self.line 1 def peek(self, offset0): # 预读字符不消费 idx self.pos offset if idx len(self.src): return self.src[idx] return \0 def advance(self): # 消费一个字符 ch self.src[self.pos] self.pos 1 if ch \n: self.line 1 return ch def skip_whitespace_and_comments(self): # 跳过空白、行注释、块注释 while self.pos len(self.src): ch self.peek() if ch in \t\r\n: self.advance() elif ch / and self.peek(1) /: # 行注释跳到行尾 while self.pos len(self.src) and self.peek() ! \n: self.advance() elif ch / and self.peek(1) *: # 块注释跳到 */ self.advance() # 消费 / self.advance() # 消费 * while self.pos len(self.src): if self.peek() * and self.peek(1) /: self.advance() self.advance() break self.advance() else: break def get_token(self): self.skip_whitespace_and_comments() if self.pos len(self.src): return None # 文件结束 ch self.peek() # 标识符或关键字 if ch.isalpha() or ch _: buf [] while self.peek().isalnum() or self.peek() _: buf.append(self.advance()) word .join(buf) if word in KEYWORDS: return (KEYWORDS[word], word) return (10, word) # 数字整数、浮点数、科学计数法 if ch.isdigit(): buf [] while self.peek().isdigit(): buf.append(self.advance()) # 检查小数点 if self.peek() . and self.peek(1).isdigit(): buf.append(self.advance()) # 消费 . while self.peek().isdigit(): buf.append(self.advance()) # 检查指数部分 if self.peek() in (e, E): buf.append(self.advance()) if self.peek() in (, -): buf.append(self.advance()) if not self.peek().isdigit(): raise SyntaxError(f第 {self.line} 行指数部分缺少数字) while self.peek().isdigit(): buf.append(self.advance()) return (12, .join(buf)) return (11, .join(buf)) # 运算符和界符最长匹配 two_char_ops {, , , !, , ||} pair ch self.peek(1) if pair in two_char_ops: self.advance() self.advance() return (20 list(two_char_ops).index(pair), pair) single_ops {: 20, -: 21, *: 22, /: 23, : 24, : 25, : 26, !: 27} if ch in single_ops: self.advance() return (single_ops[ch], ch) delimiters {(: 40, ): 41, {: 42, }: 43, ;: 44, ,: 45} if ch in delimiters: self.advance() return (delimiters[ch], ch) raise SyntaxError(f第 {self.line} 行无法识别的字符 {ch})这段代码里几个关键点值得展开说。peek和advance分离是词法分析器的标准做法peek只看不消费advance消费并推进位置。skip_whitespace_and_comments把空白和注释统一处理掉这样get_token只需要关心真正的 token。数字处理里先读整数部分再判断小数点再判断指数每一步都做了合法性检查。运算符部分用了一个two_char_ops集合来做最长匹配先拼出两个字符看看是不是双字符运算符不是再退回单字符。参数方面种别码的分配没有强制标准但建议按类别分段关键字 1-9标识符 10常量 11-19运算符 20-39界符 40-49。这样在语法分析阶段做递归下降时判断 token 类型会方便很多。行号追踪在报错时非常有用实验报告里如果能输出“第几行第几个字符出错”老师一般会给加分。3. 把代码跑起来输入输出格式与测试用例设计3.1 输入文件的组织方式南邮实验一通常会给一个test.c或test.pas作为输入要求输出 token 序列到文件或控制台。我一般会写一个main函数把整个流程串起来# 主程序读文件、跑词法分析、输出结果 import sys def main(): if len(sys.argv) 2: print(用法: python lexer.py 源文件) return with open(sys.argv[1], r, encodingutf-8) as f: source f.read() lexer Lexer(source) tokens [] while True: tok lexer.get_token() if tok is None: break tokens.append(tok) # 输出格式每行一个 token for code, value in tokens: print(f{code}, {value}) # 同时输出统计信息方便写实验报告 print(f\n共识别 {len(tokens)} 个 token, filesys.stderr) if __name__ __main__: main()运行方式就是python lexer.py test.c。输出格式种别码, 属性值是实验一最常见的格式要求有些老师会要求输出到output.txt改一下print的目标就行。统计信息输出到stderr是为了不干扰标准输出的 token 序列方便用重定向做自动化对比。3.2 测试用例要覆盖哪些边界实验一最容易丢分的地方不是主流程而是边界用例。我建议至少准备下面这几组测试第一组是关键字和标识符的区分。比如int intx 1;int是关键字intx是标识符不能因为前缀相同就误判。第二组是数字的边界包括0、123、12.5、12.、.5、1e10、1.5e-3、1e。其中12.和.5在类 C 语言里通常不合法你的分析器应该报错或者按最长匹配原则拆成12和.。第三组是注释包括// 行注释、/* 块注释 */、/* 跨行\n注释 */、以及未闭合的/*。第四组是运算符的最长匹配a b不能拆成和a b不能拆成两个。提示测试用例不要只写正确的故意写几个错误的看看报错信息是否包含行号实验报告里放一张报错截图比放十张正确输出更有说服力。3.3 输出结果怎么验证验证 token 序列是否正确最笨也最可靠的办法是手工推一遍。拿一个十行以内的小程序自己逐字符走一遍状态机把期望的 token 序列写下来再和分析器的输出逐行对比。如果数量对不上先看是不是注释或空白被多算了如果某个 token 的属性值不对看是不是最长匹配没做对。另一个办法是写一个简单的单元测试把几个典型输入和期望输出写成断言# 单元测试示例 def test_lexer(): src int a 10; lexer Lexer(src) tokens [] while True: tok lexer.get_token() if tok is None: break tokens.append(tok) expected [(1, int), (10, a), (26, ), (11, 10), (44, ;)] assert tokens expected, f期望 {expected}实际 {tokens} print(测试通过) test_lexer()这种测试跑起来很快改代码之后跑一遍能立刻发现回归问题。实验报告里如果附上单元测试的代码和通过截图说明你不仅实现了功能还考虑了可验证性这是加分项。4. 避坑与排查词法分析器最容易翻车的五个地方4.1 现象被拆成和原因没有做最长匹配解决预读下一个字符这是最经典的翻车现场。代码里读到就直接返回了根本没看后面跟着什么。解决方法是读到单字符运算符后用peek(1)看一下下一个字符如果能组成双字符运算符就一起消费掉。注意peek不能越界文件末尾要返回\0而不是抛异常。4.2 现象12.5e-3被识别成12.5、e、-、3原因指数部分没有纳入数字状态解决在浮点数状态里增加对e/E的判断数字的识别不能只处理小数点科学计数法是实验一常见的考点。正确做法是在读完小数部分后检查当前字符是不是e或E如果是就继续读指数部分指数部分可以带正负号但后面必须跟至少一位数字。如果e后面没有数字应该报错而不是默默返回。4.3 现象块注释/* ... */跨行时行号统计错误原因advance里没有更新行号解决在消费字符的统一入口里维护行号行号统计看起来是小事但报错信息里行号不对调试成本会翻倍。我的做法是在advance函数里统一判断只要消费的字符是\n就把line加一。这样不管是在读标识符、读注释还是读字符串行号都是准的。注意\r\n的情况Windows 换行符里\r不增加行号\n才增加。4.4 现象未闭合的/*导致死循环原因块注释循环没有退出条件解决在循环里检查pos是否越界块注释的结束条件是遇到*/但如果源文件里根本没有*/循环就会一直跑到文件末尾然后越界。正确写法是在 while 循环里同时检查self.pos len(self.src)循环结束后如果没找到*/应该报一个“块注释未闭合”的错误并给出行号。4.5 现象关键字表用列表导致大小写敏感问题原因没有统一大小写策略解决明确语言是否大小写敏感关键字表用字典类 C 语言是大小写敏感的Int和int不是同一个东西。但有些实验题目用的是类 Pascal 语言大小写不敏感。这个必须在动手前确认清楚。如果大小写不敏感读入标识符后统一转小写再查关键字表如果敏感就保持原样。用字典而不是列表来存关键字查询是 O(1)代码也更清晰。5. 进阶技巧把词法分析器接上语法分析做递归下降实验一做完之后很多人会把代码扔在一边等实验二语法分析时再重新写。其实词法分析器的输出直接可以喂给递归下降分析器只要 token 接口设计得干净。我一般会在 Lexer 类上加一个peek_token方法返回下一个 token 但不消费这样语法分析器在做选择时可以预读一个 token。# 在 Lexer 类里增加预读 token 的能力 def peek_token(self): saved_pos self.pos saved_line self.line tok self.get_token() self.pos saved_pos self.line saved_line return tok这个peek_token的实现方式是保存当前位置跑一次get_token再恢复位置。虽然效率不高但实验规模下完全够用而且逻辑简单不容易出错。有了这个接口递归下降里的if self.lexer.peek_token() (44, ;)这种判断就很好写。另一个进阶方向是把 token 序列输出成 JSON 格式方便用脚本做批量对比。比如输出{line: 1, tokens: [{code: 1, value: int}, ...]}然后用 Python 的json.load读回来做断言。这样实验报告里的测试部分可以自动化生成不用手动截图。还有一个容易被忽略的点是错误恢复。真正的编译器不会遇到一个非法字符就退出而是会跳过这个字符继续分析尽量多报几个错误。实验一里可以简单实现遇到无法识别的字符时记录错误信息然后advance跳过这个字符继续下一个 token。这样一次运行就能看到所有错误不用改一个跑一次。注意错误恢复要小心不要陷入死循环每次恢复必须至少消费一个字符。从那以后我每次写词法分析器都会先把状态转移表画在纸上再写代码最后用边界用例跑一遍。这个习惯帮我省了很多调试时间也让我在实验报告里能写清楚每个状态的设计理由。希望帮到你。本文还有配套的精品资源点击获取
返回列表