ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

编译原理课设实战:Python词法分析与递归下降语法分析样例解析

编译原理课设实战:Python词法分析与递归下降语法分析样例解析 简介这份课程设计围绕UESTC编译原理课程中的词法分析与语法分析两大核心模块展开面向正在学习编译器构造、需要完成课程设计或实验的高校学生。资源共8个文件压缩包仅4KB包含两个Python源码以及文法定义、符号表、分词输出、语法树文本、错误报告等辅助文件可完整还原从源代码到词法单元再到抽象语法树的处理链路。目前已有176人学习下载。使用者既能借助词法分析代码理解正则表达式与有穷自动机如何识别关键字、标识符、运算符又能通过语法分析代码学习LL(1)或LR(1)一类自顶向下/自底向上的解析策略以及语法错误时如何给出诊断信息。配套的中间结果文件也为对照调试提供了便利适合作为编译原理课程设计的可运行参考帮助快速掌握两个编译阶段的工程实现。1. 编译原理课设的正确打开方式一份能直接跑通的词法和语法分析样例编译原理课设最难受的时刻不是看不懂龙书里的推导树而是手里只有一份任务书却缺一个能对照的正确实现。UESTC 这份“词法分析器 语法分析器”课程设计资源补的正是这个缺口压缩包里是两个用 Python 写成的分析器脚本配套六个样例输入输出文件构成一组可以完整复现的编译原理实验闭环。你不需要从零设计 token 类别也不用纠结文法文件长什么样按顺序跑一遍再对照输出文件理解每一步就能把这门课最核心的两个阶段吃透。这份资源适合两类人一类是正在做编译原理课设、想找一个正确基线来对齐自己实现的学生另一类是已经写完代码但总在边界输入上报错、想看看标准做法怎么处理错误恢复的人。它把词法分析和语法分析拆成两个独立脚本中间用文件交换数据结构清楚到可以直接照着改。接下来的内容我会按“资源结构 → 词法实现 → 语法实现 → 踩坑记录 → 验证技巧”的顺序拆每一段都给到能直接抄作业的代码和参数而不是泛泛讲原理。2. 资源拆解与 token 流设计七个文件各自在管线里扮演什么角色2.1 压缩包文件清单与职责边界打开压缩包之前先搞清楚一件事compiler 课设的工程目录文件名往往比代码更迷惑人。这份资源一共七个文件包含两个 Python 分析器、一个文法描述、三个词法中间结果、一个错误报告和一个符号表输出。先看清单文件角色说明Lexical_Analyzer.py词法分析器主程序读取源代码输出 token 流写入.dyd/.dys/.errGrammar_Analyzer.py语法分析器主程序消费 token 流做递归下降分析输出语法树.pas与符号表.varsample.pro文法定义文件BNF 风格产生式供语法分析器对应查阅也方便验收时核对规则sample.dyd词法输出详细版每个 token 附带行列号、类别、原始文本sample.dys词法输出紧凑版精简的 token 序列直观看到“把源码切成了什么”sample.err错误报告词法/语法两阶段共用的错误输出文件记录错误行号、列号与描述sample.var符号表输出变量、常量、类型的登记信息语法分析阶段生成注意一个容易踩的认知误区sample.pas不是 Pascal 源程序而是语法分析器产出的抽象语法树文本表示。源程序在这份课设里是直接内嵌在Lexical_Analyzer.py的字符串常量里或者从同目录的.src文件读入——这取决于脚本头部读文件的那一行。很多同学一看到.pas就以为是输入样例往改代码方向研究半天最后才发现它是输出白白浪费一晚上。2.2 词法 token 的类别设计与判定顺序整个词法分析器的工作本质上是把一串字符切成一串有类型的 token。这份课设里 token 类别划分很典型和 Pascal 子集的语言元素一一对应token 类别匹配依据典型示例下游用途关键字属于保留字集合PROGRAM, BEGIN, IF, WHILE决定语法分支标识符字母开头后接字母/数字/下划线main, count, temp符号表登记、赋值目标常量整数或小数字面量0, 15, 3.14表达式叶子节点运算符按最长匹配识别: ; , . ( )拼接表达式、分隔语句判定顺序比匹配规则本身更重要。我第一次写词法分析器时把正则按类型分组后直接用re.match逐个试结果:总是被拆成:和关键字IF在标识符扫描时被当成普通变量名。正确做法是固定一个扫描顺序先跳过空白和注释再按标识符/关键字模式抓“字母类”token接着抓数字常量再抓多字符运算符最后才轮到单字符界符。且注意每个扫描分支都走“能匹配多长就匹配多长”的最长匹配原则。2.3 为什么中间文件要拆成 dyd 和 dys 两份.dyd与.dys的存在不是冗余。详细版.dyd保留了每个 token 的行号、列号、字符串值和 token 类型供调试时定位“某个 token 是从源码哪一行来的”紧凑版.dys只保留 token 类型和文本直接作为语法分析器的输入流。语法分析器不关心源码里某个;到底在第几行它只要知道“下一个 token 是分号”就足够了。这就带出两个分析器的协作模式词法分析器是生产者语法分析器是消费者。两者之间传递的契约不是字符串而是排列好的 token 序列。只要 token 的类别名约定一致词法分析器换成自己写的语法分析器不用改任何逻辑。这也是这份课设值得照着拆的原因——它把两阶段解耦得足够彻底你可以单独验证任何一个脚本的输出。3. 词法分析器 Lexical_Analyzer.py匹配顺序、保留字处理和错误恢复3.1 核心循环从字符流到 token 流词法分析器的主循环是整份脚本的地基。常见的实现方式是一个while循环配合pos游标逐字符前进每次循环识别一个 token。核心逻辑大致长这样# 按 Lexical_Analyzer.py 的常规实现方式还原核心循环 # token 类型用字符串常量表示方便下游语法分析器直接判断 KEYWORDS { PROGRAM, BEGIN, END, IF, THEN, ELSE, WHILE, DO, FOR, TO, READ, WRITE, VAR } def tokenize(source: str): tokens [] i, n 0, len(source) line col 1 while i n: ch source[i] # 跳过空白与换行注意 \r 也要归入空白 if ch.isspace(): if ch \n: line 1 col 1 else: col 1 i 1 continue # 跳过 { } 注释注释跨行时行号要累加 if ch {: j i 1 while j n and source[j] ! }: if source[j] \n: line 1 col 1 j 1 i j 1 col 1 continue # 标识符 / 关键字先抓完整词再查集合 if ch.isalpha() or ch _: j i while j n and (source[j].isalnum() or source[j] _): j 1 word source[i:j] ttype KEYWORD if word in KEYWORDS else IDENTIFIER tokens.append((ttype, word, line, col)) col j - i i j continue # 数字常量支持整数与小数避免把 3.14 拆成 3 和 .14 if ch.isdigit(): j i while j n and source[j].isdigit(): j 1 if j n and source[j] .: j 1 while j n and source[j].isdigit(): j 1 tokens.append((CONSTANT, source[i:j], line, col)) col j - i i j continue # 多字符运算符要优先于单字符界符匹配 if source.startswith(:, i): tokens.append((OPERATOR, :, line, col)) i 2 col 2 continue if source.startswith((, , )): op source[i:i2] tokens.append((OPERATOR, op, line, col)) i 2 col 2 continue # 单字符运算符与界符 if ch in -*/;,.():: tokens.append((OPERATOR if ch in -*/: else SEPARATOR, ch, line, col)) i 1 col 1 continue # 到这里还匹配不到说明是非法字符报告后继续前进 tokens.append((ERROR, ch, line, col)) i 1 col 1 tokens.append((EOF, #, line, col)) return tokens这段代码有几个参数是课设里最容易改错的地方KEYWORDS集合决定了哪些标识符会被当作保留字如果你后续要加CASE、CONST这些关键字只需要往集合里添加即可数字匹配分支中的小数判断用的是“看到.就继续吃数字”的策略它不处理3.这种缺失小数位的情况但作为子集语言足够用多字符运算符必须写在单字符之前否则:会被拆成两个 token。逻辑说明就一句话每次循环只吃掉一个 token把解析过的区间从源码头部剪掉直到遇到字符串末尾最终补一个EOFtoken 让语法分析器知道输入结束。3.2 关键字与标识符的区分集合查表法而不是正则分支很多初写词法分析器的同学会把关键字和标识符写成两个独立正则先试IF|THEN|...再试[A-Za-z_][A-Za-z0-9_]*。这种做法在 Pascal 这样关键字不区分大小写的语言里很容易翻车正则顺序稍微放错IF在标识符分支被吃掉或者if小写形式匹配不上。正确且稳妥的方案是统一走“先识别标识符再查关键字集合”的路线。上面代码里扫描分支先用ch.isalpha() or ch _切入一直吃到标识符的末尾拿到完整的word然后一次性判断word in KEYWORDS。这样不管源码里写的是IF还是if只要在进入查表前对word做upper()归一化就能保证统一命中。课设验收时如果有“关键字不能作为变量名”的要求查表法天然满足它俩本来就是同一个 token 类别区别只在集合是否包含这个词。这里还有一个容易被忽略的小点词法分析器输出的关键字 token 文本是原始大小写但类型一定是KEYWORD。语法分析器在递归下降时通常只判断ttype不比对文本大小写所以你可以在词法层统一把关键字文本转成大写让语法分析器的match(IF)判断稳定命中。3.3 错误处理非法字符要报告但不能中断分析.err文件的生成逻辑值得单独讲因为它是课设评分时明确要看的产物。词法分析阶段的错误主要有两类一是非法字符例如源码里出现、#、$这种不属于语言字符集的符号二是注释未闭合扫描到文件末尾都没找到配对的}。常见错误处理代码是这样一个分支# 非法字符处理写入 err_reporter而不是直接 raise if unrecovered: err_reporter.add(line, col, fillegal character: {ch!r}) i 1 col 1 continue这个设计的思路是“跌跌撞撞也要跑到终点”。词法分析器如果遇到第一个非法字符就raise RuntimeError语法分析器就拿不到后面所有 token一次编译只能暴露一个错误。而课设要求的错误报告往往希望能一次列出尽可能多的问题所以正确做法是把错误记录进sample.err然后跳过该字符继续扫描。语法分析器拿到的是带ERROR类型的 token 流它可以选择直接跳过或者终止但至少错误收集器里已经有完整信息了。同样的道理适用于注释未闭合不必报错后终止而是把剩余所有内容当作注释处理在sample.err里记一条“unterminated comment”这样后续 token 的定位信息不会被完全破坏。这个“记录后继续”的习惯是词法分析器工程化和玩具正则扫描的核心差别。4. 语法分析器 Grammar_Analyzer.pyLL(1) 递归下降与语法树产出4.1 sample.pro 里的文法定义从 BNF 产生式到非终结符函数语法分析器拿到 token 流之后需要一套规则判断这个序列是否合法。规则以上下文无关文法的形式写在sample.pro里典型的 BNF 片段长这样program :: PROGRAM ident ; body . body :: decl-part stmt-part decl-part :: VAR ident-list : type ; | ε stmt-part :: BEGIN stmt-list END stmt-list :: stmt | stmt-list ; stmt stmt :: assign | if-stmt | while-stmt assign :: ident : expr if-stmt :: IF cond THEN stmt [ ELSE stmt ].pro文件在这里的角色更接近“为人准备的文法参考”而非动态驱动表。这份课设的Grammar_Analyzer.py走的是递归下降路线每一个非终结符对应一个parse_xxx方法方法体里的匹配顺序直接对应产生式右部的符号顺序。这样做的好处是代码可读性强出错时能直接定位到文法的那一条产生式。在开始写每个parse_xxx之前强烈建议先在纸面上算一遍 FIRST 集和 FOLLOW 集。递归下降解析器要求每个非终结符的每个产生式首 token 集合互不相交这恰好就是 LL(1) 的可行性条件。比如上面文法里stmt的三个产生式首 token 分别是IDENTIFIER、IF、WHILE三者互不相同所以不需要回溯凭当前的 lookahead token 就能决定走哪个分支。如果某天你发现两个产生式首 token 相同比如同时都能以IDENTIFIER开头那你得先做左因子提取而不是强行在代码里做猜测式回溯。4.2 递归下降核心代码每个非终结符一个函数一个 lookahead 定方向核心结构是建立一个 token 流指针维护一个lookahead变量每个匹配函数如下# Grammar_Analyzer.py 中语句解析部分的常见实现方式 # tokens 在初始化时已经由词法分析器生成self.pos 指向当前消费位置 def parse_stmt(self): # 根据当前 token 类型决定走哪个产生式这是 LL(1) 的“1” if self.check(IDENTIFIER): # 赋值语句 name self.advance() # 吃掉标识符 self.match(:) # 必须紧跟赋值号 expr self.parse_expr() return AssignNode(name, expr) if self.check(IF): # if 语句 self.match(IF) cond self.parse_cond() self.match(THEN) then_stmt self.parse_stmt() else_stmt None if self.check(ELSE): # else 可选 self.match(ELSE) else_stmt self.parse_stmt() return IfNode(cond, then_stmt, else_stmt) if self.check(WHILE): # while 循环 self.match(WHILE) cond self.parse_cond() self.match(DO) body self.parse_stmt() return WhileNode(cond, body) raise SyntaxError(funexpected token: {self.lookahead}) def match(self, expected): # 校验当前 token 是否符合预期失败时记入 sample.err 并做同步恢复 if self.lookahead.ttype expected: self.advance() else: self.error(fexpected {expected}, got {self.lookahead.ttype})这里有两个关键设计值得抄进自己的代码里。第一个是advance()的行为它取当前 token 存入返回值然后把指针后移重新读下一个 token 到lookahead。所有匹配都通过match()完成match()失败时不会立刻抛异常终止而是把错误写入sample.err后做一次 token 同步——通常做法是跳过 token 直到遇见分号或END这类语句边界词再继续尝试解析后续语句。这种同步恢复机制能让一份错误输入产生多条错误报告而不只是第一条。第二个是parse_expr()的层次划分。表达式解析需要处理运算符优先级 -的优先级低于* /所以表达式被拆成expr - term ((|-) term)*、term - factor ((*|/) factor)*、factor - CONSTANT | IDENTIFIER | ( expr )三层。每一层一个函数层层调用这样2 3 * 4会正确构造成2 (3 * 4)的树形结构而不是从左到右平铺成((2 3) * 4)。4.3 从 token 流到语法树sample.pas 与 sample.var 的产出语法树是语法分析器真正的产出。每个parse_xxx函数在成功匹配一个产生式后返回一个树节点对象节点结构通常带一个children列表这样整棵语法树就是嵌套的节点集合。对上文那个简单的WHILE语句AST 序列化成文本后会写到sample.pas常见格式如下Program ├── Decls │ ├── VAR n │ └── VAR r └── Body ├── r : 0 └── WHILE n 0 DO └── r : r nsample.var的生成依赖一个独立的符号表结构。词法分析阶段已经知道源码里出现过哪些标识符但“出现过”不等于“是变量”只有经过语法分析确认出现在变量声明里的标识符才被登记进符号表。符号表条目至少包含名字、类型、作用域深度、声明行号。这块逻辑通常在parse_decl_part()内部维护一张字典每条记录在变量声明被匹配时写入。# 符号表登记逻辑 self.symbol_table[ident_name] { type: var_type, # INTEGER / REAL depth: self.scope_depth, # 嵌套过程会产生层次 line: self.current_line # 声明所在行 }这三个输出文件形成完整验证链词法分析器产出.dyd/.dys语法分析器消费后产出.pas、.var、.err。如果.err为空说明文法验证通过.pas里的树形结构就是该源码对应的语法结构。这也是整个编译原理实验里最容易获得“即时成就感”的瞬间——你亲手写的分析器把一段文字变成了结构化的树。5. 复现中的高频坑与排查手段四份踩坑记录与对应解法5.1 现象直接双击运行脚本报 FileNotFoundError 或 UnicodeDecodeError这是拿到压缩包后最容易撞上的问题。原因几乎都是路径和编码脚本内部用相对路径读取sample.pro或写sample.dyd而 Python 的当前工作目录是命令行启动时所在的目录如果你在 PyCharm 里直接按运行按钮默认工作目录可能是项目根目录但脚本和样例文件放在子目录里相对路径自然找不到文件。另外源码文件如果带非 ASCII 注释而控制台或 Python 默认编码是 GBKopen()读文件时会直接抛 UnicodeDecodeError。解决方式先在终端cd到压缩包解压出来的目录再执行python Lexical_Analyzer.py。同时在脚本内部读文件处把open(sample.pro)改成open(sample.pro, encodingutf-8)。最后不要依赖 IDE 的运行按钮用命令行跑才能保证工作目录可控。cd UESTC-compiler-project python Lexical_Analyzer.py python Grammar_Analyzer.py5.2 现象sample.err 里大量词法错误但源码看起来完全正常典型输出是每个字符都报 “illegal character”。大概率问题出在注释和空白处理上源码里的{ }注释内部有换行你的注释跳过逻辑只处理了单行遇到换行就退出了注释状态或者源码用的是\r\n换行而空白识别分支只处理了\n\r落到默认分支被当成非法字符。解决分两步第一把\r显式并入空白处理第二注释跳过分支要循环到匹配的}中途遇到\n除了跳过还要累加行号。做完这两步再看.dyd非法字符条目应该全部消失。# 注释跳过分支的标准写法 while j n and source[j] ! }: if source[j] \n: line 1 j 15.3 现象语法分析器在 PROGRAM 头就报 expected BEGIN源码明明是对的这类报错几乎全部源于“文法设计与 token 流不匹配”。最常见的场景是.pro里定义的 program 产生式要求PROGRAM后跟标识符再跟分号但词法分析器没有把PROGRAM识别成关键字它被当成IDENTIFIER输出了语法分析器match(PROGRAM)对着IDENTIFIER当然失败。排查方法先打开sample.dys看PROGRAM那一条的 ttype 到底是什么。如果是IDENTIFIER回到Lexical_Analyzer.py的KEYWORDS集合补上这个词。另一个诱因是文法里PROGRAM头后跟的参数列表形式不一致有的课设要求PROGRAM name;有的要求PROGRAM name(input, output);而语法分析器只实现了其中一种。对照.pro文件的产生式逐个 token 核对就能定位到底是谁和谁不一致。5.4 现象同一份样例本地输出和参考结果对不上这是课设答辩前夜最让人血压升高的情况。问题通常不在逻辑而在三个细节行号从 0 开始还是从 1 开始EOF token 是否参与了语法分析字符串比较时是否忽略大小写。我的血泪经验是把三个输出文件逐个 diffsample.dyd先比对 token 类别再比对行号起点sample.err里错误顺序是否一致.pas的 AST 文本结构是否对齐。一旦发现本地比参考多出一整段先检查是不是把注释内容当成了有效代码。6. 用基线 diff 与新增文法两种方式验证你的分析器拿到这份资源后最实用的验证手段不是盯着屏幕看而是建立基线对比流程。第一步把Lexical_Analyzer.py和Grammar_Analyzer.py原样跑一遍把生成的sample.dyd、sample.dys、sample.pas、sample.err、sample.var全部存为 baseline 版本。之后每次改动代码都重新跑一遍并逐文件 diff任何一行输出差异都要能解释来源。这样能避免“改完代码发现输出变了但不知道是哪一步弄坏的”的尴尬。第二步是在词法层动手加新关键字来测试扩展性。翻开KEYWORDS集合把CASE、CONST加进去直接在源码里补一段带CASE的样例输入重新跑词法分析器看新关键字是否被标记为KEYWORD而不是IDENTIFIER。这步成本极低却能验证你对整个词法框架的理解是否到位。第三步是验证语法层的可扩展性。在.pro里新增一条产生式例如for-stmt :: FOR ident : expr TO expr DO stmt然后在Grammar_Analyzer.py的parse_stmt()里加一个if self.check(FOR):分支。这个分支必须放在parse_expr调用链能正确处理:的位置同时TO要加进KEYWORDS集合。做完这三步再跑基线 diff你能直观看到文法变了token 流变量多了语法树多出分支但错误报告机制没有崩。这三个步骤走完这套课设资源的原理和边界就在你手里了。从那以后我每次拿到任何课设代码第一件事永远是先跑原样基线再做增量改动每次改完词法或语法模块都强制走一遍完整管线加 diff。这个习惯帮我避开了无数“看起来没问题但一交就挂”的坑也希望帮到你。本文还有配套的精品资源点击获取
返回列表