1. 词法分析到底在做什么
先别急着打开编辑器写代码,我们先把词法分析这件事本身聊透。
很多人第一次在头歌平台上看到“第1关:词法分析”这个任务时,会觉得莫名其妙——不就是把一串字符串拆成一个个单词吗?这有什么可做的?确实,如果只是“拆单词”,这事太简单了,但词法分析真正的核心不在“拆”,而在“分类”和“状态管理”。
1.1 一句话说清词法分析的位置
编译原理的整个流程是:源代码 -> 词法分析 -> 语法分析 -> 语义分析 -> 中间代码生成 -> 优化 -> 目标代码生成。词法分析是第一步,它的任务就是把一段源代码字符串,按照你预先定义好的规则,切成一个一个的Token(记号)。
Token 是什么?你可以把它理解成源代码的“最小有意义单位”,每个 Token 包含两样东西:类型和值。比如int a = 10;这行代码,经过词法分析后大概会变成这样:
| Token 类型 | Token 值 |
|---|---|
| 关键字 | int |
| 标识符 | a |
| 运算符 | = |
| 数字常量 | 10 |
| 界符 | ; |
语法分析器要的就是这一串带类型标记的 Token 流,它根本不在乎源代码里写的是a=10还是a = 10,因为空白字符在词法分析阶段已经被丢掉了。这就是词法分析的定位:把“文本层面”的字符序列,转换成“语言层面”的记号序列。
1.2 从字符流到 Token 流的转换细节
要实现这个转换,核心机制叫做“状态转换图”和“状态机”。你想想看,当你读到一个字符i时,它可能是关键字if的开头,也可能是标识符index的开头,你没法在当前字符上立刻决定它的最终类型,必须继续往下看。这种“边走边看”的过程,就是状态机的工作方式。
以识别标识符为例,它的规则通常是:以字母或下划线开头,后续可以是字母、数字或下划线。对应到状态转换图,就是这样的:
- 状态0(初始状态):如果读入字母或下划线,进入状态1。
- 状态1(接受状态):如果继续读入字母、数字或下划线,仍然留在状态1;如果读入其他字符,则识别结束,当前已积累的字符串就是一个标识符,同时把这个“其他字符”重新放回输入流。
注意这个“重新放回”操作,这在词法分析里是非常重要的细节。比如输入abc123+xyz,当你读完abc123后遇到了+,+不属于标识符的字符集,所以abc123这个标识符已经完整,但+本身是下一个 Token 的开头,不能丢掉。处理方法是把这个字符“回退”一个位置,或者用一个缓冲区来管理。
1.3 为什么这关是编译原理第一道门槛
在头歌平台的课程设计里,词法分析被放在第1关不是没有道理的。它强迫你建立三个认知:第一,任何看似简单的字符串处理,背后都需要状态机来保证逻辑严谨;第二,一个字符的归属可能要到后面才能确定,这就是“超前搜索”的雏形;第三,真正写代码时,你会发现边界条件比你想象的要多得多——空字符、换行、行尾、非法字符,每一步都要想清楚。
很多同学在这儿栽的跟头是:觉得逻辑太简单,上来就写循环判断,结果测试用例一跑就崩。所以磨刀不误砍柴工,先花点时间把 Token 类型和状态转换图设计好,后面实现起来会顺畅很多。
2. 开工前先想清楚:Token 种类与设计方案
写词法分析器之前,有一件看起来枯燥但特别重要的事:把你需要识别的 Token 种类完整地列出来,并且给每种 Token 确定一个内部编号。这就像做菜之前先备菜,菜没备好,炒的时候一定手忙脚乱。
2.1 你需要识别哪些 Token
拿头歌第1关的常见要求来说,一般需要识别这几类:
- 关键字:
if、else、while、return、int、void等。注意,关键字是语言预留的,不能用作变量名。 - 标识符:用来表示变量名、函数名的字符串。
- 数字常量:通常要求识别整数,有些关卡会要求识别小数。
- 运算符:
+、-、*、/、=、==、<、<=、>、>=、!=等。 - 界符:
;、,、(、)、{、}、[、]等。 - 特殊符号:字符串常量、字符常量,看题目要求。
在代码里,最方便的方式是用一个常量类来管理 Token 类型,比如:
public class TokenType { public static final int KEYWORD = 1; public static final int IDENTIFIER = 2; public static final int INTEGER = 3; public static final int OPERATOR = 4; public static final int DELIMITER = 5; public static final int ERROR = -1; }有的同学喜欢用枚举enum,也可以,看个人习惯。平台判题时无非两种方式:一种是把 Token 类型作为整数输出,另一种是输出类型名称字符串。无论哪种,内部管理好编号都不会出错。
2.2 关键字和标识符的“合并识别”套路
一个最常见的坑是:单独给每个关键字画一个状态图,试图在状态机里精确匹配if、else、while。这种思路也能跑,但会非常累,而且容易和标识符的识别产生冲突。
更聪明的做法是统一处理:把关键字也当作标识符来识别,等识别出一个完整的字符串后,再查关键字表,如果在表里,就归类为关键字;否则就是标识符。打个比方,这就好比你收到一个快递,先看包装盒上的名字,如果这个人在“重点联系人名单”里就重点对待,否则就当普通联系人处理。
所以在设计状态转换图时,只需要一张“标识符状态图”就够了,关键是识别完成后做一次查表操作。这样实现简单,逻辑也清晰。对于存储关键字表,用HashSet就好,查询效率高。
Set<String> keywords = new HashSet<>(Arrays.asList( "if", "else", "while", "return", "int", "void" ));2.3 状态转换图:怎么画、怎么看、怎么用
“源程序的词法分析的状态转换图怎么画”是很多人搜索的问题,这里我详细讲一下。
状态转换图无非三种元素:状态节点(画成圆圈)、转换边(画成带箭头的线,边上标着触发字符或字符集合)、接受状态(画成双圈)。
以数字识别为例,规则是:数字由若干个数字字符组成。它的状态图非常简单:状态0遇到数字字符进入状态1;状态1遇到数字字符继续留在状态1;状态1是接受状态。但如果你要支持十进制小数,就得多画一个状态。比如3.14:
- 状态0:遇到数字进入状态1。
- 状态1:遇到数字继续留在状态1;遇到小数点
.进入状态2。 - 状态2:遇到数字进入状态3。
- 状态3:遇到数字继续留在状态3;状态1和状态3都是接受状态。
这里有个细节:状态2(刚刚读完小数点、还没读到小数部分)不是接受状态,因为你不允许一个数字以.结尾。这一点在做自动机时非常容易漏。
画状态转换图的意义不在于“交作业画图好看”,而在于它逼你把每一种输入情况都考虑完整。画完之后,你再照着图写代码,基本就是“翻译”的过程了。
3. 核心实现:一个不算复杂但能跑的 Java 词法分析器
这一节我们直接上代码。我会用一个相对完整、可运行的 Java 版词法分析器做例子,代码结构尽量保持清晰,方便你在头歌平台上改编成自己的实现。
3.1 数据结构与类设计
先定义一个Token类,用来装识别结果:
public class Token { public int type; // 类型编号 public String value; // 原始字符串(属性值) public Token(int type, String value) { this.type = type; this.value = value; } @Override public String toString() { return formatToken(type, value); } }主类Lexer的核心成员变量需要这几个:一个字符数组(或者String)、一个当前扫描位置的指针pos、一个记录行号用的变量(如果要支持报错定位的话)。核心方法就是一个nextToken(),每调用一次就返回下一个 Token。
有人可能会问:为什么不直接一次把整个 Token 列表返回?两种方式都行。一次性返回列表看起来更简单,但流式返回更贴近真实编译器的做法,因为语法分析器是一个一个从词法分析器那儿索取 Token 的。头歌实验如果有多次调用的需求,写nextToken()会更好扩展。
3.2 主循环:逐字符扫描与状态迁移
实现的主循环思路是:每次调用nextToken()时,先跳过所有空白字符,然后根据当前字符的类型进入不同的处理分支。代码骨架如下:
public Token nextToken() throws Exception { // 跳过空白字符 while (pos < src.length() && Character.isWhitespace(src.charAt(pos))) { pos++; } if (pos >= src.length()) { return new Token(TokenType.END, "EOF"); } char ch = src.charAt(pos); if (Character.isLetter(ch) || ch == '_') { return parseIdentifierOrKeyword(); } if (Character.isDigit(ch)) { return parseNumber(); } // 运算符和界符 return parseOperatorOrDelimiter(); }这里每个 parse 方法对应一个状态转换图的子图。以parseIdentifierOrKeyword为例:
private Token parseIdentifierOrKeyword() { int start = pos; while (pos < src.length()) { char ch = src.charAt(pos); if (Character.isLetterOrDigit(ch) || ch == '_') { pos++; } else { break; } } String word = src.substring(start, pos); if (keywords.contains(word)) { return new Token(TokenType.KEYWORD, word); } return new Token(TokenType.IDENTIFIER, word); }你仔细对比一下,这就是前面那个“标识符状态图”的直接翻译:从状态0进入状态1,只要当前字符属于合法字符集合就继续,否则停下来,查表判断最终类型。
3.3 数字、运算符的识别细节
数字识别,如果只考虑整数,逻辑也很直接。但如果考虑多一种情况,比如“数字后面紧跟字母”,那得注意报错。比如输入123abc,很多实验要求报词法错误,而不是分别识别成123和abc。原因是大多数语言的词法规则里,数字后面直接跟字母是非法 token。
private Token parseNumber() throws Exception { int start = pos; while (pos < src.length() && Character.isDigit(src.charAt(pos))) { pos++; } // 如果数字后面紧跟字母,说明输入不合法 if (pos < src.length() && Character.isLetter(src.charAt(pos))) { throw new Exception("词法错误:第 " + pos + " 个字符处,数字后不能紧跟字母"); } String num = src.substring(start, pos); return new Token(TokenType.INTEGER, num); }运算符和界符识别里最需要小心的是“最长匹配”原则。比如输入<=,你不能把它认成<和=两个 Token,而要优先匹配成<=这个运算符。实现手段就是先看当前位置的两个字符是否组成一个双字符运算符,如果是就用双字符;如果不是再退回单字符。
private Token parseOperatorOrDelimiter() throws Exception { char ch = src.charAt(pos); // 双字符运算符优先尝试 if (pos + 1 < src.length()) { String twoChar = src.substring(pos, pos + 2); if (twoChar.equals("==") || twoChar.equals("<=") || twoChar.equals(">=") || twoChar.equals("!=")) { pos += 2; return new Token(TokenType.OPERATOR, twoChar); } } switch (ch) { case '+': case '-': case '*': case '/': case '=': case '<': case '>': pos++; return new Token(TokenType.OPERATOR, String.valueOf(ch)); case ';': case ',': case '(': case ')': case '{': case '}': case '[': case ']': pos++; return new Token(TokenType.DELIMITER, String.valueOf(ch)); default: throw new Exception("词法错误:无法识别的字符 '" + ch + "'"); } }这个写法在平台实验里足够应付绝大多数情况了。不过要提醒一下“/”和注释的冲突问题:有些语言的注释是//和/* */,如果实验要求识别注释,就不能简单地把/当运算符返回,得额外判断下一个字符。这属于扩展内容,但做个有准备的学员总不是坏事。
3.4 错误处理与边界情况
错误处理是很多同学交上去被扣分的地方。我见过最多的失败测试用例,不是识别不了正常代码,而是在非法输入上“表现不当”。比如输入一个@或者#,你的程序应该怎么办?是直接崩溃,还是输出一个错误提示?还是悄悄跳过?
不同实验有不同的判题规则,有的要求输出“ERROR”,有的要求输出“词法错误”。你需要先看题目要求。但无论是哪种,程序都不应该直接抛异常退出,更不应该死循环。最稳妥的做法是:遇到非法字符时,记录错误信息,然后跳过该字符继续扫描,或者立即结束并返回错误 Token,具体看题目怎么要求。
边界情况还有几种:一是输入为空字符串,应该直接返回结束标记;二是输入只有空白字符;三是最后一个 Token 的右边界处理,比如int a,扫描完a后指针已经指向末尾,循环条件要处理好,避免漏掉最后一个 Token 或者索引越界。
4. 实操验证与第1关的应对策略
光把代码写出来还不够,你还得能在头歌平台上通过判题。这一节我聊聊怎么自测,以及平台上常见的几个坑。
4.1 测试用例怎么写:从简单到刁钻
我建议写代码前先准备一组测试用例,按难度递增排列。不要一上来就测复杂的,先保证最简单的情况跑通:
- 空输入:什么字符都没有,应该返回 EOF。
- 单关键字:
if。 - 单标识符:
abc。 - 带数字的表达式:
a = 10 + b;。 - 双字符运算符:
if (a >= 10) return b;。 - 非法字符:
a @ b。 - 关键字与标识符冲突:
int ifx = 1;,这里ifx是标识符而不是关键字。
尤其是第 7 个用例,特别能检验你的“先识别完整字符串再查关键字表”逻辑对不对。如果实现方式是逐个字符匹配关键字,很容易把ifx也误判成if加x。
4.2 头歌平台判题常见坑
头歌这类在线实验平台,判题方式通常是黑盒测试,只比对输出结果,不看你代码长什么样。所以有几个细节特别重要:
第一,输出格式必须严格一致。如果题目要求输出(KEYWORD, if)这种格式,你多打一个空格都不行。建议先看看题目给出的样例输出,对照着写toString()方法。有的平台要求用空格分隔类型和值,有的要求用逗号,有的要求用中括号,务必逐字符匹配。
第二,注意测试用例可能包含多个源文件或者多行输入。也就是说,你的nextToken()方法需要循环调用,直到返回结束标记为止。我见过有同学代码只能跑一行,遇到换行就出错,多半是没在循环里正确处理换行符。换行符属于空白字符,应该在跳过的范围里。
第三,有些平台要求你从标准输入读入源代码,而不是把源代码硬编码在程序里。如果题目这么要求,你就要注意别漏掉读取输入的部分。用Scanner逐行读入并拼接成一个完整字符串,是简单的做法。
4.3 从第1关延伸到后续语法分析
最后说一个容易被忽略的角度:词法分析这个模块,你做得越好,后面的语法分析越省力。我在做实验时的一个经验是,Token 类型划分得越细,语法分析时就越容易判断if后面该不该跟(,while的条件怎么写。Token 类型如果混在一起,比如把(和{都叫“界符”,到语法分析时你怎么区分“表达式括号”和“语句块括号”?所以除非题目明确要求,不然我建议在内部实现里也把它们区分开,哪怕输出到外部时统一成一个类型,内部标识拆分清楚,对自己调试也方便。
如果把词法分析器写成了可复用的模块,那后面的语法分析、语义分析实验就可以直接拿来用,相当于把地基打牢了。很多人在第3关、第4关回头看,发现 bug 出在词法分析没做好——比如标识符被错误切断,或者运算符最长匹配没做,导致语法树解析错位。现在多花十分钟把边界处理好,后面能省几小时的排查时间。
5. 常见问题与排查技巧实录
这一节我把实操里高频踩过的坑统一写出来,有不少是看别人代码才悟到的,也有不少是自己调了半天才发现的。
5.1 典型 Bug 速查表
| 症状 | 可能原因 | 解决办法 |
|---|---|---|
| 标识符被截断成两段 | 状态图里没有让标识符“吸收”数字和下划线 | 检查 while 循环里的字符集,用isLetterOrDigit(ch) || ch == '_' |
ifx被识别成关键字 | 没有先收完整字符串再查表 | 统一走标识符解析,最后查关键字表 |
<=被识别成<和= | 缺少最长匹配逻辑 | 先判断双字符运算符,再退回单字符 |
| 数字后紧跟字母没报错 | 没检查数字串后续字符 | 数字解析结束后判断下一个字符是否字母 |
| 程序在最后一个 Token 后崩溃 | 循环里索引越界或者结束标记没写好 | 每次取字符前判断pos < len,返回 EOF 标记结束 |
| 输出了多余的空格/空行 | 空白字符处理逻辑不完整或者 toString 多加字符 | 检查跳空白逻辑和输出拼接 |
无法识别!=运算符 | 判断运算符时漏掉了! | 把!=放进双字符运算符的清单里 |
5.2 调试时的小技巧:打日志、打边界
调试词法分析器最痛苦的地方在于:你光看输出结果,往往不知道某个 Token 是从哪一行哪一列开始的。所以我建议你在Token类里额外增加line和column两个字段(不输出,仅调试用)。跑测试时打印出来,可以迅速定位问题是出在第几行第几列。
另外一个非常实用的技巧是“暂停观察”。在nextToken()的每一轮返回前,打印当前pos指针的位置和刚识别的 Token 值:
System.out.println("当前指针: " + pos + ", 识别到: " + token);这样你就能清楚地看到指针的移动轨迹。很多 bug 的本质是pos多移了一位或者少移了一位,通过打印指针位置,三分钟之内就能定位。
还有一个容易被忽略的坑:如果你在运算符识别里用src.charAt(pos + 1)来预读下一个字符,一定要先判断pos + 1是否越界,不然最后两个字符处容易抛异常。类似的,所有涉及pos + 1或pos + 2的地方,都要先判断边界。
最后再分享一个体会:我见过很多同学把词法分析写得特别“炫”,又是自动机生成器,又是正则引擎,结果实验平台一跑,反而因为复杂度过高出了各种诡异问题。实验场景下,最朴素的手写状态机反而最稳,代码短、逻辑直白、容易调试。编译器业界在工具链里用 Flex、Lex 这样的生成器,是因为面对的是几百种语言规则;你做一个实验性的词法分析器,手写完全够用。别为了炫技而炫技,能跑通判题才是硬道理。