简介:这是面向编译原理课程设计的一款Java词法分析器实现,适合计算机专业学生与编译器初学者快速入门。压缩包共2个文件,大小仅2KB,均以Java源码呈现:TokenType.java采用枚举类型完整定义Java关键字、标识符、运算符、分隔符及常量等token类别,ScanWords.java负责从源代码字符流中逐字读取、模式匹配并生成token流,同时包含空白处理、非法字符报错与基本错误恢复机制。通过阅读源码并动手运行,读者可以清晰理解词法分析在编译流程中的位置,掌握用正则表达式描述token、处理注释与字符串字面量等关键细节,也能为后续语法分析打下基础。资源已有459人学习下载,适合作为课程设计参考或教学辅助材料。
1. Java 词法分析程序:先把源码“切”成 Token 这件事,为什么没那么简单
拿一段最常见的代码int a = 42;举例,编译器前端干的第一件事不是“读懂”这行代码,而是把它切成int、a、=、42、;这样的小片段,每个片段就是一个 Token。这个切分过程就是词法分析。很多人觉得这一步很简单,不就是正则匹配吗?真正动手写一个 Java 词法分析程序才发现,最长匹配、关键字与标识符冲突、注释跳过、字符串转义、Unicode 编码,每一条都能让程序在真实输入上翻车。
词法分析处在编译流程最靠前的位置,输入是字符流,输出是 Token 流,语法分析器靠这串 Token 构建抽象语法树。你问它解决什么问题:任何需要“读懂”源码的程序都离不开它,编译器、IDE 语法高亮、代码格式化器、DSL 解释器、静态检查工具,全都在词法分析的基础上工作。适合谁?正在啃编译原理想动手跑通一个前端的人,准备 Java 面试题里编译基础部分的人,以及想给自己开发的脚本语言或配置语言写解析器的人。下面从选型讲到实现,再讲工程上最常见的坑。
2. 三条构造路线怎么选:手写、正则引擎与自动生成器的能力边界
2.1 词法分析在编译前端里的位置:字符流变成 Token 流后,语法分析才有饭吃
一个完整编译器的前端大致是:源码字符流 → 词法分析 → Token 流 → 语法分析 → 抽象语法树 → 语义分析 → 中间代码。词法分析是第一步,但这一步的产出质量直接影响后面所有阶段。为什么要把词法单独拆出来做,而不是让语法分析直接处理字符?两个原因:一是代码文法里如果混入词法规则,会产生大量左递归和二义性,语法分析器根本没法写;二是很多字符级的问题(空白、注释、非法字符)放在词法阶段处理,可以让语法分析只关心结构,不用管字符细节。
Token 是词法分析的输出单元,Java 里一个 Token 至少包含三样东西:类型(枚举,比如 KEYWORD、ID、INT_LITERAL、OP)、原始文本(在源码里的样子)、行列号位置。后两个字段极易被忽略,但真正做工程的人都知道,语法分析报错全靠行列号定位,没有位置信息的 Token 流只配在玩具项目里用。
我给一个直观例子:int a = 42;会被切成如下 Token 序列:
int→ KEYWORD(关键字)a→ ID(标识符)=→ OP(运算符)42→ INT_LITERAL(整数字面量);→ OP(分隔符)
空白和注释在这个过程里被直接丢弃。注意“丢弃”是设计决策,不是必然行为——代码格式化工具就需要保留空白和注释位置,所以严谨的说法是:词法分析器决定哪些 Token 进流、哪些不进流。
2.2 正则引擎、手写 DFA、JFlex:三条路线的真实差距
我在不同项目里三条路都走过,各自的适用场景很不一样。
第一,直接用java.util.regex的正则引擎做词法分析。写起来最快,适合一次性脚本、临时工具,或者给某个 DSL 做 100 行以内的原型。但正则引擎的内部是回溯驱动的,遇到精心构造的输入可能出现指数级匹配时间,做词法分析这种需要遍历整个编译单元的场景,性能和稳定性都不可控。
第二,手写 DFA(确定有限状态自动机)。这是编译原理课程里强调的路线,用状态转移表或状态模式手工编码。优点是运行时间严格线性、零第三方依赖、报错信息完全可控;缺点是开发慢,加一个 Token 类型就要改状态表和驱动逻辑,规则一多容易漏转移。
第三,用 JFlex 这类生成器。JFlex 接受一个.flex文件,里面用正则表达式描述 Token 规则,生成一个完整的 Java 类,内部自动构造 DFA 并做最小化。这是工程上的标准做法,兼顾开发效率和运行性能,错误定位也直观。JavaCC 也能做,但它同时覆盖语法分析,定位更重,如果你只需要词法层,JFlex 更合适。
| 路线 | 开发效率 | 运行速度 | 错误定位 | 可维护性 | 适合阶段 |
|---|---|---|---|---|---|
| 正则引擎 | 极高 | 不稳定,可能有回溯 | 差 | 低 | 一次性脚本、原型 |
| 手写 DFA | 低 | 高,严格线性 | 好,可定制 | 中 | 教学、无依赖场景 |
| JFlex | 高 | 高,自动优化 | 好 | 高 | 工程、持续迭代 |
2.3 正规文法与 DFA 的最小认知:为什么词法分析可以不用“栈”
词法分析能被 DFA 搞定,根本原因在于词法规则属于乔姆斯基谱系里的 3 型文法(正规文法)。正规文法的每个产生式右侧至多有一个非终结符,比如标识符 -> 字母 标识符 | 字母,翻译成人话就是:标识符以字母开头,后面可以跟零个或多个字母数字。这类文法不依赖上下文,所以识别它只需要有限个状态,不需要栈这种无限存储结构——这是词法分析和语法分析的本质分界,语法分析需要栈,词法分析不需要。
DFA 的五个要素:状态集合、输入字母表、转移函数、起始状态、接受状态。以标识符为例:起始状态S0遇到字母或下划线进入S1,S1遇到字母数字下划线继续留在S1,遇到其他字符时“接受”,也就是识别出一个标识符结束。正则表达式[a-zA-Z_$][a-zA-Z0-9_$]*和这个 DFA 描述的是同一个语言,这也是“正则表达式等价于有限状态自动机”这句课本论断的实际含义。
有人会问:Java 的正则引擎不就是正则的吗?为什么不能直接拿它构建 DFA 语义?关键区别是:理论上的正则表达式对应线性时间的 DFA,但java.util.regex实现的是带回溯的 NFA 模拟,支持回溯引用、环视等非正规特性,代价就是最坏情况指数级时间。词法分析要处理的是整份源码,输入长度不可控,用回溯引擎就是在赌输入数据没有恶意构造,这赌注在工程上不能接受。
3. 用正则表达式在 Java 里先跑通一个最小词法分析器:Token 定义与匹配顺序
3.1 先定 Token 集合与优先级:关键字排在标识符前,== 排在 = 前
写任何词法分析器之前,第一件事不是写代码,而是列 Token 清单。Java 基础层面的 Token 至少分这几组:关键字(if、else、while、return、int等)、标识符、整数与浮点字面量、字符串字面量、运算符(+、-、==、<=、&&等)、分隔符((、)、{、}、;、,),以及被丢弃的空白和注释。
清单列完之后要定优先级,这是正则版词法分析最容易翻车的地方。两条硬规则:第一,关键字规则必须排在标识符规则之前,因为if同样匹配[a-zA-Z_$][a-zA-Z0-9_$]*,如果标识符先声明,if永远被识别成 ID;第二,多字符运算符必须排在对应单字符运算符之前,==要在=前面,<=要在<前面,否则==会被切成两个=。
这背后其实是两个公认的裁决准则:同一位置匹配多条规则时,取最长匹配;长度相同时,取先声明的规则。Java 标识符命名规则允许字母、数字、下划线和$,必须以字母、下划线或$开头,且不能是关键字——这最后一条“不能是关键字”,靠的就是规则顺序而不是正则本身。
3.2 Java 正则做词法分析的三个坑:matches 与 lookingAt、回溯、\w 的 Unicode 行为
第一个坑是 API 误用。Matcher.matches()要求整个区域完全匹配,find()是从任意位置搜索,而词法分析需要的是“在当前位置尝试匹配并返回匹配长度”,正确工具是lookingAt():它从区域起始位置开始匹配,不要求匹配到区域末尾。我在评审别人代码时经常看到用find()的版本,结果字符串字面量里的if被误识别成关键字,因为find()会跳过当前位置往前找。
第二个坑是回溯。正则引擎不是纯 DFA,模式里的.*、嵌套量词在失败时会回溯。词法分析面对的是整个源码文件,一旦某个模式写得贪婪,遇到超长行时程序可能卡死几分钟。解决思路是:规则尽量写精确,避免大范围的.*;如果一定要匹配注释或字符串,用字符类排除法而不是非贪婪量词兜底。
第三个坑是\w的 Unicode 行为。默认情况下 Java 的\w等价于[a-zA-Z0-9_],中文、日文、带变音符号的拉丁字母都不会被识别为标识符的一部分。Java 的Pattern.UNICODE_CHARACTER_CLASS标志可以让预定义类感知 Unicode,但开了之后\w会包含大量 Unicode 字符,和 Java 语言规范里标识符的定义又不完全一致。所以写 Java 词法分析,标识符规则明确写[a-zA-Z_$][a-zA-Z0-9_$]*最可靠,别指望预定义类。
3.3 正则版 Tokenizer 完整代码:按位置尝试、最长匹配优先
下面这个RegexTokenizer能跑通一个 Java 子集的词法切分。设计思路是:维护一个pos游标,在每个游标位置遍历所有规则,用lookingAt()尝试匹配,记录所有能匹配的规则里长度最大的那一个,然后按它的类型生成 Token 并推进游标。
import java.util.ArrayList; import java.util.List; import java.util.regex.Matcher; import java.util.regex.Pattern; public class RegexTokenizer { public enum TokenType { KEYWORD, ID, INT_LITERAL, STRING_LITERAL, OP, SKIP } public record Token(TokenType type, String text, int line, int col) {} // 规则顺序 = 同长度时的优先级,多字符运算符和关键字必须靠前 private static final String[] PATTERNS = { "[ \\t\\r\\n]+", // 0: 空白,跳过 "//[^\\n]*|/\\*([^*]|\\*[^/])*\\*/", // 1: 注释,跳过 "\\b(if|else|while|return)\\b", // 2: 关键字 "\\d+\\.\\d+|\\d+", // 3: 整数或浮点字面量 "\"(?:[^\"\\\\]|\\\\.)*\"", // 4: 字符串字面量 "==|!=|<=|>=|&&|\\|\\|", // 5: 双字符运算符 "[a-zA-Z_$][a-zA-Z0-9_$]*", // 6: 标识符 "[+\\-*/=<>(){};,]" // 7: 单字符运算符/分隔符 }; private static final TokenType[] TYPES = { TokenType.SKIP, TokenType.SKIP, TokenType.KEYWORD, TokenType.INT_LITERAL, TokenType.STRING_LITERAL, TokenType.OP, TokenType.ID, TokenType.OP }; private static final Pattern[] COMPILED = new Pattern[PATTERNS.length]; static { for (int i = 0; i < PATTERNS.length; i++) { COMPILED[i] = Pattern.compile(PATTERNS[i]); } } public List<Token> tokenize(String src) { List<Token> tokens = new ArrayList<>(); int pos = 0; int line = 1; int lastLineStart = 0; while (pos < src.length()) { int bestLen = -1; int bestIdx = -1; for (int i = 0; i < COMPILED.length; i++) { Matcher m = COMPILED[i].matcher(src).region(pos, src.length()); if (!m.lookingAt()) continue; int len = m.end() - pos; if (len > bestLen) { // 最长匹配优先 bestLen = len; bestIdx = i; } } if (bestIdx == -1) { throw new IllegalArgumentException( "无法识别的字符: '" + src.charAt(pos) + "' 在第 " + line + " 行"); } String text = src.substring(pos, pos + bestLen); if (TYPES[bestIdx] != TokenType.SKIP) { tokens.add(new Token(TYPES[bestIdx], text, line, pos - lastLineStart + 1)); } // 推进游标并更新行号;这里假定 Token 不跨行,跨行字符串需提前处理 for (int i = pos; i < pos + bestLen; i++) { if (src.charAt(i) == '\n') { line++; lastLineStart = i + 1; } } pos += bestLen; } return tokens; } public static void main(String[] args) { String code = "int a = 42; // 注释\nif (a == 42) return 1;"; try { for (Token t : new RegexTokenizer().tokenize(code)) { System.out.println(t); } } catch (IllegalArgumentException e) { System.err.println(e.getMessage()); } } }代码逻辑拆开看:region(pos, src.length())把匹配范围限制在当前游标到结尾,lookingAt()只在游标位置尝试,这就避免了find()跳过非法字符乱匹配的问题。bestLen记录当前游标能匹配的最长结果,配合规则顺序完成“最长匹配 + 同长取先声明”的裁决。SKIP 类型不进入 Token 流,注释和空白在这里被丢弃。
这个版本有一个明显短板:对每个位置要循环所有规则并新编译一次 Matcher,性能较差,只适合教学和原型。实际工程里至少要把 Pattern 预编译好,或者直接跳到第 5 章的 JFlex 方案。它最大的价值是让你理解“按位置尝试”和“最长匹配”这两个词法分析的核心机制,这两点在无论用手写还是生成器时都是通用的。
4. 手写 DFA 版词法分析器:状态转移表、回退机制与驱动循环
4.1 数字和标识符的 DFA 怎么设计:状态、输入、接受态
正则版的问题是引擎不可控,手写 DFA 就是把控制权完全拿回来。设计词法用的 DFA,核心工作是画状态转移表。以“整数 + 浮点数”识别为例,状态定义如下:
| 状态 | 含义 | 可接受输入 | 下一状态 |
|---|---|---|---|
| S0 | 起始 | 数字 0-9 | S_INT |
| S_INT | 整数中间 | 数字 0-9 | S_INT |
| 同上 | 整数中间 | . | S_DOT |
| 同上 | 整数中间 | 其他字符 | 接受,回退 |
| S_DOT | 遇到小数点 | 数字 0-9 | S_FRAC |
| 同上 | 遇到小数点 | 其他字符 | 回退一个字符,整数接受 |
| S_FRAC | 小数部分 | 数字 0-9 | S_FRAC |
| 同上 | 小数部分 | e/E | S_EXP |
| 同上 | 小数部分 | 其他字符 | 浮点数接受 |
| S_EXP | 指数部分开头 | 数字或 +/- | S_EXP_DIGIT |
| S_EXP_DIGIT | 指数数字 | 数字 0-9 | S_EXP_DIGIT |
| 同上 | 指数数字 | 其他字符 | 浮点数接受 |
标识符的状态表更简单:起始遇到字母或_或$进入 ID 状态,ID 状态遇到字母数字_$留在原地,遇到其他字符就接受并回退。注意这里故意没有把+-纳入数字的起始输入,因为-42里的负号在 Java 里是运算符而不是数字字面量的一部分,这归语法分析管,词法阶段不要去抢。
4.2 驱动循环与字符回退:识别到不该属于当前 Token 的字符怎么办
手写 DFA 最大的坑是“多读了一个字符”。比如输入42;,状态机在 S_INT 读到;时知道数字结束了,但;已经被消耗掉了,如果不做处理,下一个 Token 就会从;后面的字符开始。这就是回退机制的由来。
两种常见做法。用String做输入时,维护一个pos游标,回退就是pos--,代码里表现为在“接受”分支里先把游标减一再返回 Token。用Reader流式读取时,则需要PushbackReader,unread()把多读的字符推回流里。实际工程中如果处理大文件,用 Reader 加缓冲是必须的,单纯把整个源码塞进 String 会撑爆堆内存——这我在处理几 MB 的代码文件时真实遇到过,内存直接吃满。
回退还有一个边界:如果已经处于接受状态但输入还有剩余,必须先回退再返回,否则下一次扫描会丢字符。这是手写词法分析器最常见的逻辑漏洞,症状表现为解析出来的 Token 流里无缘无故丢字符,比如int被识别成in。
4.3 能跑的最小手写 DFA:标识符和整数的扫描器
这里给一个只识别标识符和整数的最小实现,状态驱动用switch表达,逻辑比查表直观。代码里刻意分开了isIdStart和isIdPart,这正是状态转移的实体化。
import java.util.ArrayList; import java.util.List; public class MiniDfaScanner { public enum TokenType { ID, INT, EOF, ERROR } public record Token(TokenType type, String text, int line, int col) {} private enum State { START, ID, INT, ERROR } private final String src; private int pos; public MiniDfaScanner(String src) { this.src = src; } public Token next() { skipWhitespaceAndLineComments(); if (pos >= src.length()) { return new Token(TokenType.EOF, "", -1, -1); } int start = pos; int line = currentLine(); State state = State.START; while (pos < src.length()) { char c = src.charAt(pos); switch (state) { case START: if (isIdStart(c)) { // 标识符起始字符:字母、_、$ state = State.ID; pos++; } else if (Character.isDigit(c)) { state = State.INT; pos++; } else { return new Token(TokenType.ERROR, String.valueOf(c), line, colOf(start)); } break; case ID: if (isIdPart(c)) { // 标识符后续字符:字母、数字、_、$ pos++; } else { // 遇到不属于标识符的字符,立即接受当前 Token return new Token(TokenType.ID, src.substring(start, pos), line, colOf(start)); } break; case INT: if (Character.isDigit(c)) { pos++; } else { // 这里同样存在回退问题:c 不属于数字, // 但 pos 已经在 c 上,返回时不需要改 pos, // 因为下次 next() 会从 c 开始 return new Token(TokenType.INT, src.substring(start, pos), line, colOf(start)); } break; default: return new Token(TokenType.ERROR, "", line, colOf(start)); } } // 输入扫描结束,按当前状态收尾 switch (state) { case ID: return new Token(TokenType.ID, src.substring(start, pos), line, colOf(start)); case INT: return new Token(TokenType.INT, src.substring(start, pos), line, colOf(start)); default: return new Token(TokenType.ERROR, src.substring(start), line, colOf(start)); } } private boolean isIdStart(char c) { return Character.isLetter(c) || c == '_' || c == '$'; } private boolean isIdPart(char c) { return Character.isLetterOrDigit(c) || c == '_' || c == '$'; } private void skipWhitespaceAndLineComments() { while (pos < src.length()) { char c = src.charAt(pos); if (Character.isWhitespace(c)) { pos++; } else if (src.startsWith("//", pos)) { while (pos < src.length() && src.charAt(pos) != '\n') { pos++; } } else { break; } } } private int currentLine() { int count = 1; for (int i = 0; i < pos; i++) { if (src.charAt(i) == '\n') count++; } return count; } private int colOf(int start) { int lastNl = src.lastIndexOf('\n', start - 1); return start - lastNl; } public List<Token> tokenizeAll() { List<Token> tokens = new ArrayList<>(); Token t; while ((t = next()).type() != TokenType.EOF && t.type() != TokenType.ERROR) { tokens.add(t); } return tokens; } public static void main(String[] args) { MiniDfaScanner scanner = new MiniDfaScanner( "int a = 42; // 注释\nfoo_1 $x 99" ); for (Token t : scanner.tokenizeAll()) { System.out.println(t); } } }这个实现没有直接演示回退,因为在返回 Token 时,游标pos已经停在下一个待解析字符上,不需要显式pos--。真正的回退场景在处理42.这种输入时会出现:状态机进入 S_DOT 后发现下一个字符不是数字,这时pos已经越过小数点,必须pos--才能让小数点被下一次next()当作运算符处理。在 DFA 里增加一个 DOT 状态,然后把这种分支写清楚,就是完整的浮点数识别。核心规律只有一句:进入新状态时多消耗的字符,在准备接受上一个 Token 之前必须还回去。
4.4 手写 DFA 在什么场景下才划算
很多人学完 DFA 就非要用它写生产级词法器,我劝你冷静。上面这个最小实现已经暴露了问题:加一个运算符类型就要改状态枚举、改switch、改收尾逻辑,规则超过二十条后维护成本指数上升。手写 DFA 真正划算的场景是:编译目标环境不允许额外依赖(某些嵌入式 JVM)、需要针对每个非法字符输出定制报错信息、或者你正在拿这个词法分析程序做教学演示。如果不是这三类,直接用 JFlex,把精力留给后面的语法分析。
5. 用 JFlex 生成工程级 Java 词法分析器:.flex 文件写法与常见避坑
5.1 三段式文件结构与关键指令:%class、%type、%unicode、%line、%column
JFlex 是 Java 生态里最常用的词法分析器生成器,输入一个.flex文件,输出一个完整的 Java 类。它内部把规则编译成 DFA,运行时线性扫描,性能和手写几乎没有差距,但开发效率高一个数量级。
.flex文件分成三段,用%%分隔。第一段是选项和用户代码,第二段从第一个%%到第二个%%,是规则区,第三段是用户代码辅助方法。常用指令如下:
| 指令 | 作用 |
|---|---|
%class Lexer | 指定生成的类名 |
%type Token | 指定yylex()返回值类型,动作里必须return该类型 |
%unicode | 启用 Unicode 字符集支持 |
%line | 启用yyline行号变量 |
%column | 启用yycolumn列号变量 |
%public | 生成的类设为 public |
规则区每行格式是正则表达式 { Java 动作 },匹配后执行动作,动作里通常用yytext()取匹配文本,return一个 Token。注释里的yyline、yycolumn是 JFlex 注入的变量,分别代表匹配时的行号和列号,Token 构造时直接引用即可。
5.2 覆盖 Java 子集的 .flex 实例:关键字、字面量、运算符、注释一条条写
下面是一个能处理 Java 小儿子集的.flex文件。关键字的规则全部排在标识符前面,多字符运算符排在单字符运算符前面,这是我在第 3 章就强调过的顺序问题,在 JFlex 里同样适用。
%class SubsetLexer %type Token %unicode %line %column %public %{ // 如果需要在动作里复用状态,可以在这里加成员变量 %} %% [ \t\r\n]+ { /* 空白,跳过 */ } "//"[^\n]* { /* 行注释,跳过 */ } "/*"([^*]|"*"[^/])*"*/" { /* 块注释,跳过 */ } "if" { return new Token(TokenType.IF, yytext(), yyline, yycolumn); } "else" { return new Token(TokenType.ELSE, yytext(), yyline, yycolumn); } "while" { return new Token(TokenType.WHILE, yytext(), yyline, yycolumn); } "return" { return new Token(TokenType.RETURN, yytext(), yyline, yycolumn); } "int" { return new Token(TokenType.INT, yytext(), yyline, yycolumn); } [a-zA-Z_$][a-zA-Z0-9_$]* { return new Token(TokenType.ID, yytext(), yyline, yycolumn); } 0|[1-9][0-9]* { return new Token(TokenType.INT_LITERAL, yytext(), yyline, yycolumn); } ">="|"<="|"=="|"!="|"&&"|"||" { return new Token(TokenType.OP, yytext(), yyline, yycolumn); } [+\-*/=<>(){};,.] { return new Token(TokenType.OP, yytext(), yyline, yycolumn); } \"([^\"\\]|\\.)*\" { return new Token(TokenType.STRING_LITERAL, yytext(), yyline, yycolumn); } <<EOF>> { return null; } [^] { throw new RuntimeException("非法字符: " + yytext()); }生成方式是命令行执行java -jar jflex.jar SubsetLexer.flex,生成一个SubsetLexer.java,然后把它和自定义的Token类放进同一个目录编译,Token的构造函数按Token(TokenType type, String text, int line, int column)编写即可。Maven 项目也可以用jflex-maven-plugin绑定在 generate-sources 阶段自动生成,避免手工执行命令。
这条正则"\"([^\"\\]|\\.)*\""在 JFlex 和 Java 正则里都能工作,它匹配的是:一个双引号,后面跟任意数量的“非引号非反斜杠字符”或“反斜杠加任意字符”,最后以双引号结束。\\在正则里表示一个反斜杠,所以它能把\"这种转义引号正确吞进去,不会提前终结字符串。块注释的写法([^*]|"*"[^/])*同理,它明确排除了*/组合,避免注释正文里出现*就误以为注释结束。
5.3 五个常见的坑:现象、原因和解决
这五个坑是我在实际项目和对别人代码 review 里反复见到的,每一条都是真实流过的血泪经验。
坑一:关键字永远被识别成 ID。现象是if、while输出的 Token 类型是 ID 而不是关键字。原因是关键字规则写在标识符规则后面,JFlex 对同等长度的匹配按规则顺序选择,标识符规则先把if抢走了。解决方法是把关键字规则全部挪到标识符规则前面。
坑二:==被拆成两个=。现象是a == b解析出三个 Token:a、=、=。原因是单字符运算符规则写在多字符规则前面,JFlex 在长度相同时选了先声明的规则。解决方法是把==、<=、>=、&&这类多字符运算符整体声明在单字符运算符之前,上面示例里">="|"<="|"=="..."这一行就是因为这个原因放在[+\-*/=<>(){};,]前面的。
坑三:块注释匹配把整份文件吞了。现象是源码里有多个/* ... */注释时,从第一个/*到最后一个*/之间的所有代码都被当作注释跳过。原因是用"/*".*"*/"这类正则匹配注释,.默认不匹配换行又会尽量多吃字符,JFlex 还不支持非贪婪量词*?,所以..之间会一直延伸到最后一个*/。解决方法是写"/*"([^*]|"*"[^/])*"*/",让中间部分不能包含未转义的*/序列,匹配范围被自然约束在单个注释块内。
坑四:字符串里含转义引号时被提前切断。现象是String s = "a\"b";被切出两个字符串 Token:"a\和b。原因是字符串正则写成了\"[^\"]*\",中间部分不允许任何双引号,遇到\"里的引号就误认为字符串结束。解决方法是使用\"([^\"\\]|\\.)*\",把转义序列作为一个分支纳入中间部分。
坑五:中文注释导致乱码或非法字符异常。现象是含中文的源码在生成词法分析产物或运行时抛异常,错误信息指向注释位置。原因是输入流没有按 UTF-8 解码,JFlex 生成的扫描器读到半个 UTF-8 字符。解决方法是两件事一起做:.flex文件本身保存为 UTF-8 编码,传入 JFlex 的Reader/InputStream明确用StandardCharsets.UTF_8解码;如果使用%unicode指令,JFlex 生成的类会正确处理 Unicode 字符,但前提是字符流先被正确地解码成 JavaString。
6. 验证与继续往前走:用 Token 快照测试、覆写纵深、再喂给语法分析
6.1 用 Token 序列快照做回归测试:正、负样本都能凑
写完词法分析器,第一件事不是去看性能,而是确认它切得对。我的习惯是准备一组文本样本,分为正样本和负样本两类。正样本是合法代码,比如int a = 1 + 2;、String s = "a\"b";、if (a >= 3) return;,预期输出是已知的 Token 序列;负样本是故意构造的非法输入,比如int 9a = 1;、未闭合的字符串"abc,期望行为是抛出明确异常而不是静默切错。把这些断言直接写进单元测试,每次改规则后跑一遍,比肉眼盯输出可靠得多。
快照比较可以用一个简单的joining收集 Token 流:
String actual = tokens.stream() .map(t -> t.type() + ":" + t.text()) .collect(Collectors.joining(" ")); assertEquals("KEYWORD:int ID:a OP:= INT_LITERAL:1 OP:+ INT_LITERAL:2 OP:;", actual);这样改规则时看 diff 就很直观。Token 类型和文本一起比对,能同时抓住“类型错”和“切分位置错”两类问题。
6.2 下一步:把 Token 流接入调用者视角的语法分析入口
词法分析本身不产生抽象语法树,它是给语法分析喂料的。当 Token 流稳定之后,下一步通常是用递归下降法写语法分析器,核心模式是持有一个全局的 Token 列表和一个peek()指针,语法规则对应一个个递归方法。最小骨架长这样:
public class Parser { private final List<Token> tokens; private int idx; public Parser(List<Token> tokens) { this.tokens = tokens; } private Token peek() { return tokens.get(idx); } private void advance() { if (idx < tokens.size()) idx++; } public void parse() { // 在这里按文法规则调用 parseStatement、parseExpression 等方法 } }这类方法把“当前 Token 是什么”作为分支条件,把“吞掉一个 Token”作为前进动作,程序设计模式和我第 4 章写的 DFA 驱动循环颇有几分神似。如果你想把词法分析程序做成完整的 Netherlands 语言前端,把 Token 流接入这样的 Parser 后,后面就是语义分析和目标代码生成的事了。
最后分享一个我自己的习惯:写词法分析器先写负样本测试,后写真规则。因为规则写完之后,负样本才会暴露出正则表达式最脆弱的地方,比如回溯、贪婪匹配和转义处理。这个顺序帮我避开过好几次“看起来能跑、换一个输入就翻车”的尴尬。希望帮到你。
本文还有配套的精品资源,点击获取