十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

编译原理词法分析器实战:Token扫描、最长匹配与错误恢复

编译原理词法分析器实战:Token扫描、最长匹配与错误恢复 如果你正在上编译原理这门课大概率会在开课三五周之后收到第一份实验任务实现一个简单的词法分析器。很多人拿到题目第一反应是这不就是个字符串切割吗然后花一个晚上写了两百行 if-else跑通课本上那几行示例就交了。等到后面做语法分析实验时才发现前面这个字符串切割留下的坑一个比一个大——Token 里没带行号报错时定位不到注释没处理干净被当成除号1.和.5这类边界输入直接把程序搞崩。这篇文章想做的事情很具体把一个简单词法分析器从能跑推到能用的层次讲清楚每一步为什么这么设计以及在实验课、课程设计、面试里真正会被追问的那些细节。适合刚接触编译原理的同学也适合已经忘得差不多、需要重新捡起来的人。1. 先弄清楚词法分析器在编译流水线里到底负责哪一段编译器处理源码的过程粗略看是字符流 → Token 流 → 语法树 → 中间代码 → 目标代码。词法分析器站在最靠前的位置它的输入是一整段没有结构的字符输出是一串带类型标注的 Token。这个转换听起来简单但它是整条流水线上唯一直接面对原始文本的模块所以脏活累活基本都在这里注释要吞掉、空白要跳过、字符串里的转义要解开、数字的进制要识别、非法字符要报错并给出位置。理解它的定位有个很实用的判断标准词法分析器只关心这个词长什么样不关心这个词放在哪里合不合适。比如if出现在表达式中间词法分析器照样把它识别成关键字 IF至于if 出现在这里语法上对不对那是语法分析器的事。这条边界划清楚之后很多设计上的犹豫就自然消解了——不需要在扫描器里判断括号是否匹配也不需要检查变量是否声明过那些都是后面阶段的责任。1.1 Token、模式、词素三个概念分不清就会写错代码课本上会给出三个术语很多人翻过去就忘了但它们直接对应代码里的三个东西模式Pattern描述某一类词长什么样的规则通常写成正则表达式。比如标识符的模式是以字母或下划线开头后跟任意个字母、数字或下划线。词素Lexeme源码里实际出现的那段字符。count、_tmp1、MAX_SIZE都是词素它们匹配同一个模式但具体内容不同。Token词法单元把类型 词素 位置信息打包后的结果对象。对应到 Java 代码里模式是你写在扫描函数里的判断逻辑词素是你从源代码里切出来的那个子串Token 是你返回给调用方的对象。很多人写的时候把它们混在一起最典型的症状是扫到标识符之后直接把词素当类型返回结果后面语法分析拿到的全是字符串比较代码里到处是token.equals(if)性能差还容易出错。一个合格的 Token 至少要有四个字段类型、词素、起始行号、起始列号。行号和列号不是可选项它们是你后面做错误提示的唯一依据一旦在词法阶段丢掉后面想补回来就得重写整个扫描器。1.2 为什么不能顺手把词法分析和语法分析合成一层初学的时候很容易产生一个想法既然扫描一遍就能拿到 Token那我边扫边判断语法不就行了省一次遍历。这个思路在小语言上确实能跑但它会在两个地方翻车。第一个是前看lookahead需求。语法分析经常需要看到下一个 Token 才能决定当前怎么归约比如遇到(时可能是函数调用也可能是强制类型转换得往后看一个标识符才能区分。如果扫描和解析耦合在一起你就得在扫描器里维护一个能推回去的队列代码复杂度反而更高。第二个是关注点分离带来的可维护性。词法规则改动通常很频繁——加个新关键字、支持个新注释风格、加个新的数字字面量格式。这些改动如果只影响扫描器改一处就完事如果和语法规则缠在一起改一个关键字可能牵动十几处解析逻辑。工程上的经验是凡是可以清晰分层的就别合在一起除非有明确的性能理由。2. 把 Token 规则写成正则表达式从自然语言到形式化定义写代码之前先做一件看起来多余的事把所有 Token 的规则用正则表达式完整写一遍。这一步的价值在于它强迫你把脑子里模糊的印象变成精确的判定条件。很多人写扫描器时卡壳不是因为不会写代码而是因为压根没想清楚数字到底能不能以小数点开头标识符能不能包含美元符号这类问题。2.1 常用 Token 的正则定义清单下面这张表是我在做实验和带课设时反复用到的版本覆盖了一门类 C 或类 Java 教学语言需要用到的绝大部分 TokenToken 类别正则模式说明与常见变体关键字if | else | while | for | int | return | ...本质是标识符的子集识别顺序上要特殊处理标识符[A-Za-z_][A-Za-z0-9_]*有的语言允许$开头需按目标语言调整十进制整数[1-9][0-9]* | 0单独列出0是为了避免007这类前导零被误判为合法十六进制整数0[xX][0-9A-Fa-f]必须放在十进制整数规则之前靠最长匹配消歧浮点数[0-9]\.[0-9]*([eE][-]?[0-9])? | \.[0-9]([eE][-]?[0-9])?尾数和小数部分至少有一侧非空字符串字面量([^\\\n] | \\.)*显式排除换行避免未闭合字符串吞掉整个文件字符字面量([^\\\n] | \\.)注意与单引号作运算符的语言区分运算符 | ! | | | | || | | - | * | / | | | | ...多字符运算符必须排在单字符之前分隔符( ) { } [ ] ; , .一般每个字符单独成 Token注释与空白//[^\n]* | /\*([^*] | \*[^/])*\*/ | [ \t\r\n]通常直接丢弃但注释里的换行必须计入行号这张表里藏着两个新手最容易忽略的规则值得单独拎出来说。第一个是最长匹配优先。当和都能匹配当前输入时必须选更长的那个。所以扫描器的判断顺序一定是先试双字符运算符再退回到单字符。如果你先判断单字符写出来的代码就会把a b拆成、两个 Token语法分析阶段直接炸掉。第二个是规则优先级。当两条规则匹配的长度相同时按声明顺序取靠前的那条。关键字和标识符的冲突就是靠这个解决的if既能匹配关键字规则也能匹配标识符规则长度一样所以必须让关键字规则排在前面。手写扫描器里对应的做法是先按标识符切出来再查关键字表本质上就是用一次哈希查找代替了规则的顺序扫描。2.2 正则到 NFA 到 DFA这条链条每一步在做什么课程里会花不少篇幅讲 Thompson 构造法、子集构造法、DFA 最小化这一整套流程。实验课通常不要求你真的实现这套转换但理解它在做什么对调试手写扫描器有直接帮助。整条链条的逻辑是这样的先给每个 Token 类别写一条正则表达式然后给每条正则构造一个 NFA非确定有限自动机把所有 NFA 用一个新起点串联起来变成一个大 NFA再用子集构造法把它确定化成 DFA最后做最小化得到一个状态数最少、每个状态对每个输入字符最多只有一条出边的自动机。这个 DFA 就是词法分析器的理论模型——你在代码里写的那个switch加while循环本质上就是在模拟这个 DFA 的状态转移。理解这层对应关系的实际价值在于当你的扫描器出现多吃了字符或者少吃了字符的问题时你可以回到 DFA 图上定位是哪个状态的转移写错了。比如字符串扫描里忘了处理转义字符对应到自动机上就是少了一条从字符串内部状态出发、经过反斜杠回到自身的边。2.3 手工推导 DFA 的两个实用技巧如果你确实要手推 DFA 交实验报告有两个技巧能省很多时间。第一个是把字符集合并。不要为每个字母单独画一条边而是把[A-Za-z_]合成一条边标注字符类。DFA 的定义允许边上标注字符集合画图时合并之后状态数会少一大半可读性也好得多。只有当某个字符需要走不同分支时才单独拆出来。第二个是先处理最长的公共前缀。比如注释的//和除法的/字符串的和字符字面量的都是前缀重叠的典型。做法是从起始状态出发把这类字符指向一个中间状态在这个中间状态里再看下一个字符决定走哪条路。这其实就是手写代码里读到/之后再看一眼是不是/或*的逻辑来源。3. 手写扫描器还是用生成器两种路线的取舍实验课通常明确要求手写但如果你在做课程设计或者真实项目这个问题值得认真想一下。两条路线的差异不只是自己写还是用工具而是代码的可控性与开发速度之间的权衡。3.1 手写扫描器的代码骨架与优势手写扫描器的基本形状是一个大循环加若干分支核心结构大概是这样的public Token nextToken() { skipWhitespaceAndComments(); if (isAtEnd()) return makeToken(TokenType.EOF); int startLine line, startCol col; char c peek(0); if (isIdentStart(c)) return scanIdentifier(startLine, startCol); if (Character.isDigit(c)) return scanNumber(startLine, startCol); if (c ) return scanString(startLine, startCol); if (c \) return scanChar(startLine, startCol); return scanOperator(startLine, startCol); }这个骨架的优势很直接加规则的成本极低。想支持一种新的注释风格加一个if分支就完了想在报错信息里带上更友好的提示直接在对应的分支里写。生成器路线的规则全部写在单独的.lex文件里动作代码和正则交织在一起稍微复杂一点的逻辑比如跨越多个 Token 的上下文状态就得靠生成器提供的特殊机制可读性反而下降。另外一个容易被忽略的点是上下文敏感的处理。有些语言里同一个字符在不同位置含义不同比如某些模板语法里{在表达式内部是对象起始在外部是块起始。手写扫描器可以维护一个状态栈来区分生成器也能做但要多绕几圈。教学语言里这种需求少见但一旦遇到手写的优势就体现出来了。3.2 生成器路线的代价用 JFlex、Flex 这类工具好处是正则规则直接变成代码DFA 由工具生成性能通常比手写的 if-else 链更好而且天然支持最长匹配和规则优先级。代价主要有三个。一是调试困难。工具生成的代码动辄几千行出了问题只能靠日志和断点往里钻看不到逻辑在哪。二是构建依赖变重多一个代码生成步骤CI 配置、IDE 插件都得跟着配。三是表达复杂动作的能力有限涉及状态切换或者需要跨 Token 缓存的逻辑写起来别扭。3.3 我的选型判断标准我自己的判断标准很朴素规则数量少于 60 条、需要上下文状态、或者这是一次性交付的教学项目就手写规则多、变更频繁、对性能有硬要求就用生成器。教学场景几乎永远落在第一类。一个类 C 教学语言的 Token 类别也就三四十种手写扫描器的代码量在一千行以内远远没到需要工具介入的规模。而且手写一遍的价值不在于产出那个扫描器而在于你会被迫想清楚最长匹配、优先级、回溯这些概念到底是怎么落地成代码的。4. 手写一个可运行词法分析器的完整拆解接下来是实打实的实现部分。我用 Java 写因为教学语言里 Java 的字符处理工具类比较全如果你用 C 或者 Python核心逻辑完全一样只是字符串处理那部分要自己写几个辅助函数。4.1 Token 的数据结构与类型枚举先定义类型枚举。这里有个小建议把运算符和分隔符各自合成一个大类具体是哪个符号放在词素里。有些实现喜欢给每个运算符单独定义一个枚举值结果是PLUS、MINUS、STAR、SLASH一大堆枚举列表长得没法看而且加一个运算符就得改枚举。合并成OPERATOR之后语法分析阶段用token.lexeme.equals()判断即可代价是一次字符串比较收益是枚举表清爽很多。不过如果你的语言里有大量需要专门处理的运算符比如赋值、复合赋值、自增自减那还是建议给它们独立的类型因为语法分析里会对它们做特殊处理。public enum TokenType { KEYWORD, IDENT, INT_LIT, FLOAT_LIT, STRING_LIT, CHAR_LIT, OPERATOR, DELIMITER, EOF, ERROR }Token 类用不可变对象四个字段全部final。这样做的原因是 Token 会大量创建和传递不可变对象天然线程安全也不会有谁偷偷改了词素导致后面报错信息对不上。public final class Token { public final TokenType type; public final String lexeme; public final int line; public final int col; public Token(TokenType type, String lexeme, int line, int col) { this.type type; this.lexeme lexeme; this.line line; this.col col; } Override public String toString() { return String.format(%s(%s) at %d:%d, type, lexeme, line, col); } }4.2 主扫描循环与前看字符的推进逻辑扫描器内部只需要三个状态变量当前下标pos、当前行号line、当前列号col。所有对源码的读取都通过peek(offset)和advance()两个方法绝不直接访问src.charAt这是保证行列号不乱的唯一纪律。private char peek(int offset) { int i pos offset; return i src.length() ? src.charAt(i) : \0; } private char advance() { char c src.charAt(pos); if (c \n) { line; col 1; } else { col; } return c; }这里有个细节值得强调换行符的处理必须放在advance()里不能放在跳过空白的函数里。因为字符串字面量、注释里也可能出现换行注释里的换行虽然被丢弃但行号要增加如果只在跳过空白时更新行号遇到多行注释之后的所有行号都会偏。我第一次写的时候就在这儿栽过报错信息里的行号整体偏移了三行查了半天才发现是块注释的问题。另一个细节是peek越界返回\0。用一个不可能出现在源码里的哨兵字符可以让所有往后看一个字符的判断不用额外做边界检查。用别的字符也行但要注意别和源码里可能出现的字符冲突——用\u0000是最省心的。4.3 标识符与关键字为什么最后一步才查表标识符和关键字共用同一套字符规则所以扫描逻辑只有一份private Token scanIdentifier(int startLine, int startCol) { int start pos; while (isIdentPart(peek(0))) advance(); String text src.substring(start, pos); TokenType type KEYWORDS.contains(text) ? TokenType.KEYWORD : TokenType.IDENT; return new Token(type, text, startLine, startCol); }关键字表用HashSetString就够了几十个关键字的一次哈希查找成本可以忽略。有人会问要不要用 Trie前缀树来加速我的看法是在教学语言的规模下完全没必要。Trie 的优势在于大量具有公共前缀的字符串查找而关键字查找的输入长度通常只有两三个字符哈希表在这个长度上的表现比 Trie 更好代码还简单得多。真正需要注意的是关键字表的大小写敏感性。有些语言区分大小写IF是标识符而不是关键字有些语言不区分。这必须在实现前确定不能含糊。另外如果你的语言允许标识符包含关键字作为前缀比如iffy那KEYWORDS.contains这种精确匹配的写法刚好是对的不用改。4.4 数值字面量最长匹配优先级与进制前缀数字扫描是手写扫描器里最容易写错的部分因为要处理的变体太多十进制、十六进制、浮点、科学计数法还有.5和1.这类边界形态。核心策略是先看开头两个字符决定大类0x或0X开头走十六进制分支否则按十进制走并在扫描过程中动态判断是不是浮点数。private Token scanNumber(int startLine, int startCol) { int start pos; if (peek(0) 0 (peek(1) x || peek(1) X)) { advance(); advance(); if (!isHexDigit(peek(0))) return error(十六进制字面量缺少数字, startLine, startCol); while (isHexDigit(peek(0))) advance(); return new Token(TokenType.INT_LIT, src.substring(start, pos), startLine, startCol); } while (Character.isDigit(peek(0))) advance(); boolean isFloat false; // 只有小数点后面还跟着数字才算浮点数的一部分 if (peek(0) . Character.isDigit(peek(1))) { isFloat true; advance(); while (Character.isDigit(peek(0))) advance(); } if (peek(0) e || peek(0) E) { int save pos; advance(); if (peek(0) || peek(0) -) advance(); if (Character.isDigit(peek(0))) { isFloat true; while (Character.isDigit(peek(0))) advance(); } else { pos save; // 不是科学计数法回退 } } TokenType t isFloat ? TokenType.FLOAT_LIT : TokenType.INT_LIT; return new Token(t, src.substring(start, pos), startLine, startCol); }这段代码里有三处值得说明的设计。第一处小数点后面必须跟数字才算浮点数。这是为了区分a.b这种成员访问——如果看到点就吞进去a.b会被识别成一个数字加标识符的怪东西。这个判断正是最长匹配规则的一个具体体现1.有两种解释一种是数字1加运算符.一种是浮点数1.。大多数语言选择前者因为那样更符合实际使用习惯。第二处科学计数法的指数部分必须先探测再决定。看到e之后不能直接吞因为1e里的e可能是一个标识符的开头比如1 else这种极端情况虽然语法上不合法但词法阶段不该报错。正确做法是记录当前位置尝试解析指数失败就回退。这是回溯最简单的应用场景也是为什么位置指针可回退这个设计很重要。第三处十六进制分支里0x后面没有数字就立刻报错。因为0x单独出现不可能是合法的任何东西早报错比晚报错好定位。4.5 注释、空白与换行处理的隐藏细节跳过空白和注释的函数看起来最无聊实际上埋坑最多。private void skipTrivia() { while (pos src.length()) { char c peek(0); if (c || c \t || c \r || c \n) { advance(); } else if (c / peek(1) /) { while (pos src.length() peek(0) ! \n) advance(); } else if (c / peek(1) *) { int startLine line, startCol col; advance(); advance(); boolean closed false; while (pos src.length()) { if (peek(0) * peek(1) /) { advance(); advance(); closed true; break; } advance(); } if (!closed) reportError(块注释未闭合, startLine, startCol); } else { break; } } }坑一块注释未闭合。如果不做检测一段忘了写*/的注释会一路吞到文件结束所有的 Token 全部消失最终报的错是意外的文件结束完全定位不到问题源头。加上未闭合检测之后报错点直接指向注释开始的位置效率天差地别。坑二嵌套注释。标准 C 和 Java 都不支持嵌套块注释/* /* */ */会在第一个*/处结束。如果你的教学语言要求支持嵌套就必须用一个计数器而不是布尔值来跟踪深度。这个需求在实验指导书里偶尔会出现先确认清楚再写。坑三注释里的换行。因为advance()已经统一处理了换行所以注释里的换行会自动更新行号不需要额外代码。这也是前面强调行号更新必须集中在advance()的原因。坑四/的歧义。skipTrivia只看//和/*两种情况单独的/不能被吞掉必须留给运算符扫描函数。判断顺序上先检查注释再检查运算符这个顺序不能反。5. 位置追踪与错误恢复真正拉开差距的部分同样一份实验报告有的同学得 80 分有的得 95 分差距往往不在主流程代码而在错误处理这部分。一个能正确扫描合法输入的分析器是及格线一个能在非法输入上给出有用信息、并且不崩溃的分析器才是完整作品。5.1 字符推进时同步维护行列号行号和列号的维护原则只有一条所有字符消费都必须经过advance()。只要坚持这条行列号就永远不会错。反过来说如果你在某个地方图省事写了pos 2直接跳过两个字符那里就会成为行号错乱的源头。列号的起始值我习惯用 1也就是第一个字符在第 1 列。有些工具用 0这纯粹是约定问题但你必须在文档或者注释里写清楚否则后面配合编辑器跳转时会差一格。还有一个容易被忽略的场景制表符怎么算列宽。如果按一个字符算那么看起来在同一列的两个 Token 实际列号会不同。工业级编译器一般会按 tab 宽度展开教学项目里按一个字符算完全可以接受但要在报告里说明这个取舍显示你考虑过这个问题。5.2 非法输入的错误恢复策略对比遇到非法字符时扫描器有三种常见做法各有适用场景策略做法优点缺点直接抛出终止立刻抛异常结束整个分析实现最简单绝不产生错误 Token一次只能报一个错用户要反复编译跳过并继续生成一个 ERROR Token继续扫描一次能报出所有错误体验好后续可能出现级联错误噪音大恐慌模式恢复跳到某个同步点如分号、换行再继续减少级联错误实现复杂同步点的选择需要经验教学项目里我推荐第二种配合一个封顶的错误数量。大于 20 个错误之后直接停止避免一个错误的括号导致后面几百个 Token 全部报错输出刷屏反而找不到真正的问题。这是很多真实编译器比如某些前端工具的常见做法体验比死磕要舒服得多。字符串未闭合是另一类特殊错误。它不像非法字符那样能立刻发现只有读到行尾或者文件尾才知道出问题了。我的处理方式是遇到换行就判定字符串未闭合把已扫描的内容作为 ERROR Token 返回然后把指针停在换行处。这样后面的代码还能继续扫错误信息也指向了准确的起始位置。5.3 一份容易漏掉的边界情况清单下面这些输入是实测下来最常导致崩溃或者误判的建议直接拿去做测试用例空文件以及只有空白的文件。应该只产出一个 EOF Token不报错。文件末尾没有换行符。所有依赖最后一行有换行的逻辑都会在这里暴露。0x、1e、1e这类不完整的数字字面量。后面直接跟文件结束。反斜杠出现在字符串末尾abc\。//注释在文件最后一行且没有换行结尾。/*从未闭合。连续的大量运算符ab应该切成a、、、b如果语言支持。中文标点混入比如全角分号和半角分号。超长标识符比如一万个字符的变量名。这张清单里的每一条我都实际遇到过其中文件末尾没有换行这条最阴险因为它平时不会出问题只有在生成测试文件时手工删掉最后一行才会触发很多人的扫描器在这里会抛越界异常。6. 怎么验证你的词法分析器真的写对了写完之后怎么确认它对跑课本上那两个例子是不够的。课本例子总是挑最好看的那种输入正好避开所有边界。6.1 单元测试用例的设计思路我的做法是给每一类 Token 建一个测试方法每个方法里覆盖正常形态和两到三个边界形态。测试的断言不是跑通了而是逐个校验 Token 序列的类型、词素、行列号。行列号必须一起校验因为它是你后面报错体验的基础如果一开始就不对后面更难查。举个具体的例子测浮点数的时候我会覆盖3.14、0.5、.5、1.、1e10、1.5e-3、1e这七种输入并明确写出每种期望得到什么。写完之后你会发现.5和1.这两条往往和最初的设计意图不一致——这时候要么改代码要么改设计文档但不能含糊过去。还有一种测试方式是属性测试随机生成一串合法 Token把它们拼接成源码再扫描一遍看是否能还原出原来的 Token 序列。这个测试能发现拼接后产生歧义的问题比如两个相邻的拼在一起会被解析成。虽然这类问题在真实代码里很少出现但能发现它对理解最长匹配很有帮助。6.2 用最长匹配规则反推 bug当扫描结果和预期不一致时最快的定位方式不是打断点而是回到最长匹配和优先级这两条规则上问自己当前输入有没有更长的匹配如果有我的代码为什么没选它如果没有更长匹配那两条等长规则谁优先我的代码顺序对吗实测中绝大多数识别错了的问题都能用这个思路在几分钟内定位。比如被切成、、就是多字符运算符的判断顺序没排对else被识别成标识符就是关键字表里漏了一条或者查表的时机早了。这套方法的好处是它不依赖调试工具纯靠推演在纸上也能做。6.3 大文件下的缓冲与性能观察教学项目通常不关心性能但有一个性能问题会实际影响体验用什么方式读取源码。如果你的扫描器是从InputStream一个字符一个字符读那每次peek(1)都可能触发一次系统调用几万行的文件会明显变慢。最简单的做法是先把整个文件读成一个String或者char[]。这样做有两个直接好处随机访问是 O(1)回溯只需要保存一个下标同时代码里不用处理 IO 异常逻辑干净很多。内存开销方面一个几万行的源文件也就几百 KB完全不值得为省这点内存去搞流式读取。如果你确实想体验一下工业级的做法可以了解一下双缓冲区加哨兵的策略用两个大小固定的缓冲区交替加载每次读到缓冲区末尾时补一个 EOF 哨兵这样主循环里的边界判断可以省掉。这个思路在经典的编译原理教材里有详细描述也常出现在面试追问里。不过对于课程实验整文件读入的方案在简洁性和性能上都更划算。7. 实验课与面试里反复出现的那些考点最后聊点务实的。这门课的实验和面试里有些问题出现频率极高提前想清楚能省很多时间。7.1 实验报告里最容易被扣分的几个点根据我见过的情况扣分集中在这么几个地方。一是没有明确说明 Token 的定义报告里直接贴代码看不出你设计的 Token 类别有哪些、每个类别的规则是什么。二是边界情况处理没有说明比如前导零、未闭合字符串、空文件这些如果你处理了但没写等于白处理。三是错误恢复策略没有交代遇到错误是终止还是继续为什么这么选。四是正则表达式和代码对不上报告里写的正则是一回事代码实现的是另一回事这种情况在1.这类边界上特别容易暴露。另外如果你的实验要求写出对应的 DFA注意画图时要标清楚起始状态和接受状态并且字符类要合并。见过太多报告画了三十多个状态每个字母一条边虽然不算错但评阅体验很差。7.2 面试问法与实际考的是什么面试里问到词法分析问法通常不会太课本。常见的几种问法及其真实考点如下问法实际考察点词法分析和语法分析为什么要分开你是否理解关注点分离和前看需求最长匹配和规则优先级分别解决什么问题是否真正动手写过还是只背了概念ab应该怎么切最长匹配的具体应用以及对歧义的处理意识手写扫描器和用生成器怎么选工程判断能力不是知识记忆怎么给用户一个有用的语法错误提示位置追踪和错误恢复的实践经验大量输入下扫描器怎么优化缓冲策略和 IO 成本的认知回答这类问题的诀窍是给具体例子。说最长匹配很重要是空话说和都能匹配时如果不按最长匹配走a b会被切成四个 Token语法分析没法处理才是有信息量的回答。我在实际做课程设计和帮别人看代码的过程中最大的体会是词法分析器这个题目看起来简单恰恰因为简单它把工程习惯上的差异放大了。同样是三百行代码有的人写完就能直接支撑后面的语法分析实验有的人每做一个新实验就要回头改一遍扫描器。差别不在编程能力而在动手之前有没有把规则想清楚、有没有把边界情况列出来、有没有把位置信息当成一等公民对待。先把这三件事做扎实后面的语法分析和语义分析会轻松非常多。另外还有一个小技巧写完扫描器之后用它去扫描你手边任意一个真实源文件比如你自己写的某个 Java 类的源码看看能不能不崩溃地跑完。这个练习比任何人造测试用例都有效因为真实代码里什么奇怪的写法都有。
返回列表