ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

编译原理词法分析器实战:从正规式到token流的Python实现

编译原理词法分析器实战:从正规式到token流的Python实现 简介一份编译原理词法分析器实现代码的Word文档面向编译原理课程学习者与需要动手实现词法分析模块的程序员。代码以C语言编写通过宏定义预设关键字长度、标识符最大长度等参数并构造关键字表、错误代码表、标识符表与常量表等数据结构完整呈现预处理、错误处理、词法分析等核心函数能帮助读者快速理解从字符读取、单词识别到错误定位的完整流程。资源共1个docx文件大小约18KB轻量易用目前已有143人学习下载。文档重点展示了预处理函数如何去除注释和空白字符GetChar函数如何根据当前字符识别关键字、标识符、运算符与界符并通过相关辅助函数完成单词分类与符号表登记同时提供错误处理机制以及标识符表、常量表、32个C语言关键字表和六类错误代码表便于调试和扩展词法分析功能。这份资料适合作为编译原理课程设计、实验报告或期末复习的参考材料。1. 词法分析器不只是上课交作业那么简单看到“编译原理词法分析器代码.docx”这个标题多半是正在修《编译原理》这门课或者补实验报告。但词法分析器这东西真不是只在课程设计里出现。IDE 里的语法高亮、代码补全、linter 报错、甚至你用的正则表达式引擎核心都是同一个东西把字符串流拆成有意义的 token 流。理解它对你读编译器前端源码、写 DSL 解析器、甚至调 grep 都有直接帮助。这章不打算从龙书第一章开始抄定义。假定你已经知道什么是 token什么是正规式我们直接谈一个更实际的问题给你一份.docx里写好的代码你怎么把它跑起来又怎么知道它写的对不对。后面几章会用一个最小但完整的实现带你走完“概念—代码—运行—调试”的全过程。适合正在写实验、但不想只对着文本来回改的同学也适合想把手写词法分析器逻辑理顺的工程师。2. 词法分析的三个核心概念正规式、DFA 和 token 流2.1 从正规式到识别器不只是“匹配”那么简单词法分析器的任务是把源代码字符串切分成 token。切分的依据是模式而模式的最常见表达方式就是正规式。你写的每个 token 规则比如IDENT [a-zA-Z_][a-zA-Z0-9_]*本质上就是定义了一个正规语言。但正规式本身不能被计算机直接执行。它需要被编译成一个识别器通常是DFA确定性有限自动机。这一步是“编译原理”里“编译”这个词的真正体现——你写的规则被转换成了状态转换表或状态转换图。实际工程里手写词法分析器通常不会显式构造 DFA而是用“模拟 DFA”的方式直接写状态判断逻辑或者用生成器从正规式自动构建。// 伪代码模拟 DFA 的核心循环 state start_state while (c next_char()): state transition[state][c] if state error_state: break这里的transition就是状态转换表。如果表是稀疏的常见做法是用哈希表存合法转换如果表是稠密的直接二维数组更快——经典的空间换时间。2.2 token 流和符号表词法分析的输出到底是什么词法分析器输出的 token 流不是一个简单的字符串数组。一个标准的 token 至少包含三样东西token 类型如KEYWORD、IDENTIFIER、token 值字面文本以及它在源码中的位置行号、列号。位置信息在报错时非常重要没有它语法分析器报错你根本定位不了问题。符号表在这个阶段一般只做预埋。词法分析器可以顺手把标识符查重或登记进一个哈希表但完整的多遍编译中符号表往往在语法分析和语义分析阶段才真正填充。这里有个常见的实验误区把符号表当成词法分析器必须完成的输出。实际上词法分析器更重要的 KPI 有两个最长匹配和规则优先级。# token 结构的 Python 表示 class Token: def __init__(self, type, value, line, column): self.type type # 例如 IDENT 或 NUMBER self.value value # 原始文本 self.line line # 行号 self.column column # 列号value保留原始文本而不是转换成 int 或别的类型。类型转换放到后续阶段做可以让词法分析器保持纯粹也便于调试。2.3 常见误用一上来就写自动机而不是先列 token 规格很多同学拿到实验题第一反应是打开代码编辑器直接写 if/else。这是最容易翻车的做法。正确的顺序是先写 token 规格表明确每个 token 的正规式、优先级、是否需要忽略。优先级通常按“最长匹配优先同长则先定义者优先”处理。token 类型正规式示例说明KEYWORDif|else|while优先级高于标识符IDENT[a-zA-Z_][a-zA-Z0-9_]*关键字之外的自然走这里NUMBER[0-9]暂不支持浮点保持简单OPERATOR|-|*|/单字符运算符WHITESPACE[ \t\n]跳过不产出 token注意KEYWORD和IDENT的优先级问题。你写if时希望它是关键字但单独一个ifx应该是个标识符。这就是“最长匹配优先”的意义——先取最长的合法 token 串再看这个串是不是关键字。3. 用 Python 手写一个最小词法分析器代码与参数说明3.1 为什么选 Python 而不是 C/Java 做实验很多学校的实验要求用 C 或 Java这确实有教学考量——让你手动管理缓冲区体会“前看字符”的痛苦。但如果你想先理解逻辑再用目标语言重写Python 是最合适的快速原型工具。它的字符串切片和 Unicode 支持能省掉大量和词法分析无关的琐碎代码。另一个原因是调试环境友好。在WSL Ubuntu下用 Python 写不需要编译改完直接python3 lexer.py就能跑。对比 C 语言文件读写操作的老一套Python 的词法分析器核心逻辑大概只有 150 行。用VS Code 搭配微软的 Python 扩展代码补全和调试断点都顺手字体用 Cascadia Code 或 JetBrains Mono 在终端里的显示效果接近 macOS 的体验也不会出现中文注释乱码。3.2 定义 token 种类和规则映射表写码之前先定义配置文件。用字典做规则映射比把规则散落在 if/else 里好维护得多。# token_rules.py import re # token 类型对应的正规式 TOKEN_RULES [ (KEYWORD, r\b(if|else|while|return)\b), # 关键字 (IDENT, r[a-zA-Z_][a-zA-Z0-9_]*), # 标识符 (NUMBER, r\d), # 整数 (ASSIGN, r|), # 赋值或判断 (OPERATOR, r[\-*/]), # 四则运算 (LPAREN, r\(), (RPAREN, r\)), (LBRACE, r\{), (RBRACE, r\}), (SEMICOLON, r;), (WHITESPACE, r\s), # 空白最后处理 ]规则必须按优先级从高到低排列。结合上一节的误用提醒ASSIGN的规则是或按顺序匹配的话必须在前面否则最长匹配优先会被破坏——会被切分成两个。3.3 主循环最大匹配与错误恢复的实现核心代码不复杂但细节决定成败。我们要实现一个get_token()方法它从当前位置尝试匹配所有规则挑选匹配文本最长的那个 token。# lexer.py import re from token_rules import TOKEN_RULES class Lexer: def __init__(self, code): self.code code self.pos 0 # 当前扫描位置字符偏移 self.line 1 self.col 1 self.tokens [] def get_next_token(self): if self.pos len(self.code): return None for token_type, pattern in TOKEN_RULES: # 每次从当前位置开始匹配 match re.match(pattern, self.code[self.pos:]) if match: lexeme match.group(0) # 更新位置和行列号 self.pos len(lexeme) self.col len(lexeme) # 简化处理不考虑换行中的列号重置 return Token(token_type, lexeme, self.line, self.col - len(lexeme)) # 未识别字符 unknown_char self.code[self.pos] self.pos 1 return Token(UNKNOWN, unknown_char, self.line, self.col) def tokenize(self): while True: token self.get_next_token() if token is None: break if token.type WHITESPACE: continue # 跳过空白 self.tokens.append(token) return self.tokens这里有几个参数和设计值得说明。self.pos是当前扫描的绝对位置self.col是列号但由于我们直接跳过了空白列号没有严格执行这在后面会讲一个更严谨的修正版。re.match默认从字符串开头匹配所以不需要加^锚点。但是扫描的性能问题——code[self.pos:]每次生成一个新字符串在长文件下会比较慢实验代码可接受生产级代码应该用索引范围切片或re.compile后手工管理匹配位置。3.4 设置断言的调试策略如何验证扫描逻辑词法分析器写完第一件事不是跑整个程序而是做单元测试。用一个简单输入跑一遍看结果。echo if x 10 { return x; } | python3 lexer.py若输出如下说明基本逻辑通了Token(typeKEYWORD, valueif, line1, col1) Token(typeIDENT, valuex, line1, col4) Token(typeASSIGN, value, line1, col6) Token(typeNUMBER, value10, line1, col9) Token(typeLBRACE, value{, line1, col12) Token(typeKEYWORD, valuereturn, line1, col14) Token(typeIDENT, valuex, line1, col21) Token(typeSEMICOLON, value;, line1, col22) Token(typeRBRACE, value}, line1, col24)注意if被归到了KEYWORD而不IDENT没有被拆分10是NUMBER而不是两个单独的数字。这三条验证点就是词法分析器是否合格的基本盘。4. 把 .docx 里的代码提取出来运行踩坑记录4.1 为什么不能直接把 .docx 当文本读你的实验题目给的是编译原理词法分析器代码.docx。如果你用open(xxx.docx, r)直接读拿到的是一堆乱码——因为.docx本质是 ZIP 压缩包内部是 XML 文件。直接读文件得到的二进制流没有任何词法意义。常见做法有两种用 Python 的python-docx库提取段落文本或者在 WSL 里用pandoc命令转成纯文本。# 在 WSL Ubuntu 终端里 pandoc 编译原理词法分析器代码.docx -t plain -o lexer_input.txtpandoc的转换会把标题、代码块的格式全部丢掉保留纯文本内容。对于包含代码块的 docx 来说这是最省事的路径。python-docx的优点是能保留段落结构但你需要自己处理表格、图片和代码块的嵌套代码量反而多。4.2 富文本里的代码陷阱全角字符和自动编号从 .docx 提取代码最隐蔽的坑是全角字符。Word 自动把中文标点替换为全角比如引号会变成“ ”括号会变成 。你的词法分析器一般不会处理全角符号于是会抛UNKNOWNtoken。处理办法有两个在词法分析器里加一条规则把全角字符映射到半角或者在提取文本后用str.translate()做映射。# preprocess.py FULL_TO_HALF { “: , ”: , ‘: , ’: , : (, : ), : ;, : :, : # 全角空格 } def normalize(text): return .join(FULL_TO_HALF.get(ch, ch) for ch in text)这里参数说明映射表只覆盖了中文常用标点你可以扩展。注意全角空格\u3000必须映射到半角空格否则\s匹配不到它会导致 token 切分断裂。另一个坑是Word 的自动编号列表。如果你的代码在 docx 里是编号列表项pandoc转换后会变成1. if (x 10)这样的形式数字和点会混入代码直接让词法分析器报错。最稳妥的办法是转完文本后人工浏览一遍头部和尾部把不属于代码的编号行删掉。4.3 编码问题的最后一道防线WSL 下从 Windows 拷贝文档经常遇到gb2312或gbk编码的文本文件。Python 默认读取是 UTF-8直接读会抛UnicodeDecodeError。即使pandoc帮你转了格式最终得到lexer_input.txt也可能不是 UTF-8。# 在终端里查看文件编码 file lexer_input.txt如果显示Non-ISO extended-ASCII或ISO-8859就用iconv转码iconv -f GBK -t UTF-8 lexer_input.txt lexer_utf8.txt这一步在写实验报告时最容易忽略。你的词法分析器跑了半天没输出可能不是逻辑问题而是输入文本本身就有编码噪音。5. 一个能处理中型代码的实用词法分析器性能和边界5.1 手写状态机 vs 生成器什么时候你需要换方案我们前面用 Python 正则实现的词法分析器处理几百行的教学代码完全够用。但如果你要处理一个几千行、包含字符串字面量和注释的真正的编程语言正则方案会撞墙。典型困境是字符串里的星号/和操作符/冲突注释//要跳过直到行尾。re.match做不到“记住当前状态”因为正则本质是无状态的。这时你需要手写一个带状态的分支逻辑。以识别注释为例遇到/时看下一个字符如果是/就进入“行注释”状态一直跳到换行如果是*就进入“块注释”状态直到遇到*/。def get_token_with_state(self): if self.code[self.pos] /: if self.pos 1 len(self.code) and self.code[self.pos1] /: # 行注释跳过直到换行 end_pos self.code.find(\n, self.pos) self.pos end_pos if end_pos ! -1 else len(self.code) return self.get_token_with_state() # 递归获取下一个真正的 token elif self.code[self.pos1] *: end_pos self.code.find(*/, self.pos 2) self.pos end_pos 2 if end_pos ! -1 else len(self.code) return self.get_token_with_state()递归调用在这里是安全的因为注释之后必然会有新的 token 或文件结束。但你需要设一个上限防止意外死循环比如记录连续跳过的注释次数超过 1000 次就报错。5.2 输入流缓冲与性能不用 str[pos:] 切片的替代如果实验要求实现一个面向文件的词法分析器比如用一个read()把整个文件读进内存在几十 MB 的源码上会明显卡顿。建议使用io.StringIO或 mmap 来避免反复切片。以mmap为例import mmap with open(large_source.py, rb) as f: mm mmap.mmap(f.fileno(), 0, accessmmap.ACCESS_READ) # mm[pos:pos10] 依然是切片但 mmap 的切片不会复制原文而是引用映射区域 ch mm[self.pos]参数说明mmap.mmap(f.fileno(), 0, accessmmap.ACCESS_READ)第一个参数是文件描述符第二个参数0表示映射整个文件第三个参数表示只读。之后所有mm[i]操作返回的是单字节的整数Py3 里是 int用chr(mm[pos])转换即可。这比反复code[pos:]少 O(n^2) 的字符串复制。实际的 C / C 手写版本还会用缓冲区双指针哪个指针指向当前扫描字符哪个指向 token 起点配合ungetc处理前看一个字符。实验代码不用做到那种程度但了解原理能让你更快定位正则方案的瓶颈。5.3 错误处理与恢复报错后不要立刻崩溃生产级词法分析器遇到非法字符比如#不在设计范围内时一般会跳过该字符记录ERRORtoken并继续扫描。这样语法分析器可以一次性看到多个错误。实验代码往往只需要打印错误并退出但如果你想让自己的项目拿高分建议这样设计class Lexer: def __init__(self, max_errors10): self.errors [] self.max_errors max_errors def report_error(self, message, line, col): if len(self.errors) self.max_errors: raise RuntimeError(Too many lexer errors) self.errors.append(f({line},{col}): {message})这里max_errors是防御性参数防止错误刷屏把内存打爆。你的实验报告里列出这个参数怎么设、为什么设 10比写一百行 README 都更能体现对异常路径的思考。6. 如何用可视化和断言快速验证你的词法分析器6.1 输出状态转换表或 token 流到文件比起在终端里瞪眼观察更科学的验证方法是把 token 流导出为 CSV 或 JSON再和预期结果做 diff。这里给一个生成调试用 JSON 的命令python3 lexer.py input.txt tokens.json然后写一个简单的校验脚本检查每个 token 的行号是否单调不减。很多隐藏 bug比如换行后列号没重置可以据此暴露。需要特别说明的是校验脚本的断言不要只断言 token 类型序列要同时断言 value 和行列号。行列号错误到语法分析阶段会变成找不到报错位置的玄学问题。6.2 用trace查看每一步匹配过程Python 的trace模块可以打印每行执行细节适合查逻辑分支跑到了奇怪的地方python3 -m trace --trace lexer.py trace.log实测跑一个简单文件时trace.log会膨胀到几千行。常见的做法是只 trace Lexer 的核心方法。你可以在get_next_token开头加打印def get_next_token(self): print(fDEBUG: pos{self.pos}, char{self.code[self.pos:self.pos20]!r}, filesys.stderr)这类调试输出走stderr避免污染标准输出导致后续管道处理出问题。作者强烈建议每次改完正则都跑一轮 stderr 日志看看到底匹配到哪个规则。6.3 一个你一定能用上的技巧把 token 流可视化成对齐表我们在真实项目中常用来做 code review 的工具是生成一张 token 对齐表每一列宽度对齐类型、值、行列号。在终端里用column命令实现for t in tokens: print(f{t.type}\t{t.value}\t{t.line}:{t.col})然后管道到column -t -s $\tpython3 lexer.py input.txt | column -t -s $\t输出效果整洁一眼就能看出哪个 token 的列号错位、哪个值被截断。这比直接看对象输出直观得多。这里列号计算要包含换行符的处理每遇到\n要重置列号为 1而不是简单地加长度。否则column表格就会在换行后呈现错位的对齐方式。修正方法很简单在循环中检查lexeme是否含\n若有则更新行号并重置列号。验证完成后你对词法分析器的正确性就有数了接下来该做的是把这份代码和实验报告压缩打包提交或者在本地把中间结果写入 JSON 供语法分析器调用——那是下一步的事了。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进