编译原理词法分析器:DFA、最长匹配与Java手写实现

发布时间:2026/9/17 14:57:00
编译原理词法分析器:DFA、最长匹配与Java手写实现 做编译原理课程实验的时候十个人里有八个卡在第一个环节——词法分析器。这东西听起来唬人其实就是个切词工具你给它一串源代码字符流它吐出一串带类型的词法单元Token。可别小看这一步后面语法分析、语义分析、代码生成全都建立在它切出来的Token质量上切歪一个字符整个编译器后面全线崩塌。我前后写过三四个版本的词法分析器从最初那个用一堆if-else硬怼、遇到1.5.6就崩的版本到后来能正确处理浮点数、注释、字符串转义、并且带错误恢复的版本中间踩的坑足够写满两页纸。这篇就把我实际做这个实验的完整思路、代码实现、以及那些课本上不会告诉你的细节一次性摊开讲清楚。不管你是刚开编译原理课、连DFA都没搞明白的小白还是想回头把这个实验做扎实的老手下面这些内容都能直接拿去用。1. 词法分析器到底在干什么为什么它是编译器的第一道门1.1 从字符流到Token流一次彻底的视角转换很多人第一次接触词法分析最大的障碍不是算法而是 mindset 没转过来。你写代码的时候脑子里想的是一个完整的表达式a b 1;但词法分析器眼里的世界完全不是这样它看到的是一串扁平的字符a、空格、、空格、b、空格、、空格、1、;。它的任务就是把这串字符重新组装成有意义的最小单位最后产出这样一组东西IDENTIFIER, a ASSIGN, IDENTIFIER, b PLUS, INTEGER, 1 SEMICOLON, ;每一行是一个Token由两部分组成Token类型category和Token的值lexeme/attribute。类型告诉语法分析器这是个什么东西值告诉它具体内容是什么。为什么要分这两层因为语法分析器关心的是结构比如赋值语句的形式是 标识符 表达式 ;它不需要知道具体是a还是b只需要知道这里是个标识符。而到了语义分析阶段才需要把a和b塞进符号表里去做类型检查。这种分层设计是编译器架构里非常经典的一招把结构和内容解耦各管各的。理解了这一点你就能明白词法分析器的核心职责边界它不需要关心语法对不对。a b这种句子词法分析器照样能切出五个合法的Token它是语法分析器的活儿去报 unexpected token。词法分析器只负责一件事——把字符流里那些符合模式的片段认出来打上类型标签。这个边界感很重要很多新手写着写着就把语法判断混进去了最后两个模块耦合得没法维护。1.2 为什么不直接用正则一把梭理论上词法分析处理的是正则语言每种Token都能用一个正则表达式描述。比如标识符是[a-zA-Z_][a-zA-Z0-9_]*整数是[0-9]浮点数是[0-9]\.[0-9]。既然这样为什么不全用正则匹配解决这问题我当年也问过答案是可以但代价很高而且很多场景下不划算。第一个原因是性能。正则引擎尤其是回溯型的在匹配失败时会疯狂回退一个几万行的源文件逐token做正则匹配性能会明显吃紧。而手写的扫描器本质是一个确定性有限自动机DFA每个字符只处理一次时间复杂度是线性的 O(n)几乎不可能更优。真实的编译器像 GCC、Clang 用的都是手写的扫描器就是为了这份确定性。第二个原因是最长匹配原则和上下文处理。词法分析有一条铁律尽可能匹配最长的那个Token。比如遇到1.5你得先吃到小数点判断后面是不是数字如果是就延长成浮点数如果遇到1..5这种吃到第二个点的时候要能反应过来这个点不属于数字得回退。手写扫描器处理这种先试探再回退非常自然而正则要表达这种交错模式就得写得很别扭。注意正则适合描述词法规则但不一定适合实现词法规则。用正则去规范和文档化你的Token定义是极好的但真正跑起来的引擎手写扫描器通常更靠谱。第三个原因是错误恢复。手写的扫描器可以在遇到非法字符时做精细的处理——记录位置、跳过、继续扫描下一个Token、最后统一报告。正则通常一失败就返回 null你很难知道它到底匹配到哪一步失败了。这一点在做课程实验要求输出错误信息并继续分析时差别就体现出来了。1.3 手写扫描器、DFA表格驱动、生成器工具三条路怎么选实际做词法分析器主流有三条技术路线我列个表对比一下你按自己的需求挑方案核心思路优点缺点适用场景手写扫描器用 switch/while 逐字符判断好调试、性能好、错误处理灵活代码量大、规则多了容易乱课程实验、真实编译器前端DFA表驱动把状态转移做成二维表查表跳转逻辑和规则分离、易扩展建表麻烦、调试不直观教学演示、需要频繁改规则的场景生成器工具写正则/规则文件工具生成代码写得快、规则清晰生成代码难读、联合调试困难快速原型、规则极多的语言我个人的建议是课程实验老老实实手写。为什么因为手写的痛苦恰恰是这个实验想让你体会的——你会真正理解什么是状态、什么是回退、什么是 lookahead。用Lex或者JFlex生成一个跑是能跑但你除了会写.l文件对词法分析本身还是没感觉。而且面试的时候面试官问你最长匹配是怎么实现的你总不能回答生成器帮我处理的吧。不过有个折中方案我强烈推荐先用DFA把状态图画清楚再手写代码去实现这个状态图。这样你既有了理论上的严谨DFA保证无歧义、无回溯需求又有了实现上的灵活代码里方便加错误处理。这篇文章后面的实现就走这条路。2. 动手之前必须敲定的几个核心设计决策2.1 Token类型怎么划分才算合理这是开工前第一个要拍板的东西。Token类型划分得太粗语法分析器不好写划分得太细词法分析器自己一堆类型还容易混淆。我的经验是按语法地位划大类按语义需要分类别。下面是我做实验时常用的一套Token分类你可以直接参考public enum TokenType { // 字面量 INT_LIT, FLOAT_LIT, STRING_LIT, CHAR_LIT, // 标识符与关键字 IDENTIFIER, KEYWORD, // 运算符 PLUS, MINUS, STAR, SLASH, PERCENT, EQ, NEQ, LT, LE, GT, GE, ASSIGN, AND, OR, NOT, BIT_AND, BIT_OR, BIT_XOR, // 界符 LPAREN, RPAREN, LBRACE, RBRACE, LBRACKET, RBRACKET, SEMICOLON, COMMA, DOT, // 特殊 EOF, ERROR }这里有几个设计细节值得说道。关键字到底单独成一类还是也当标识符两种做法都有人用。单独成类的做法是在词法阶段就把关键字表和标识符区分开好处是语法分析器可以直接用KEYWORD加值来判断写法干净。另一种做法是词法阶段先全部识别成标识符在语法分析或预处理阶段再查关键字表。我倾向于词法阶段直接查表区分因为这样每个Token的信息是自洽的后面不用再依赖外部状态。运算符要不要按值细分比如和一个赋值一个比较语义完全不同必须分成两个类型。但和-呢其实可以都归为ADDITIVE_OP由值来区分。不过我还是倾向细分因为语法分析器写起来更直接case PLUS:比case OP: if (val.equals())清爽太多了。多写几个枚举值换来的可读性完全值得。EOF文件结束要不要作为一个Token必须要有。这是很多新手会漏的点。语法分析器需要一个明确的输入结束信号否则它在读到文件末尾时不知道该停还是继续。EOF是隐式推导出来的不需要真的从源文件里读出来。2.2 字符分类表和DFA状态设计确定了Token类型接下来要把每种Token的识别过程画成状态机。这一步是词法分析器设计的灵魂。我习惯先定义字符分类因为很多状态转移其实就是遇到某类字符跳到某状态// 字符分类 private int charClass(char c) { if (c || c \t || c \n || c \r) return 0; // 空白 if (Character.isDigit(c)) return 1; // 数字 if (Character.isLetter(c) || c _) return 2; // 字母 if (c ) return 3; // 字符串开始 if (c \) return 4; // 字符开始 return 5; // 其他符号 }分类的意义在于简化判断。你不需要到处写if (c 0 c 9)只要算一下字符类然后看当前状态下遇到这个类该往哪走。下面把最常见的几类Token的状态转移列出来这就是你的实现蓝图Token起始状态后续状态接受条件标识符/关键字遇字母或下划线遇字母/数字/下划线继续遇非上述字符结束整数遇数字遇数字继续遇非数字结束浮点数遇数字遇数字继续遇.转小数态小数点后必须有数字字符串遇遇转义符转义态遇结束闭合引号出现以浮点数为例它的状态是这样的一开始在数字累积状态吃到足够多数字后如果下一个字符是.并且再下一个是数字才进入小数部分状态。这里判断再下一个是数字就是 lookahead是为了避免把1.之后跟一个.或者;的情况误判成浮点数。这个 lookahead 的判断就是最长匹配和回退的具体实现手段。提示设计状态图的时候一定要把接受状态标出来。因为扫描过程可能走过头比如吃到了不该吃的字符你必须在最后回退到最近一个接受状态才能切出正确的Token。2.3 双指针缓冲与回退机制这一块是手写扫描器里最容易写乱、也最能体现功底的地方。核心问题只有一个当你想延长当前Token时怎么保证不破坏已读内容最朴素的做法是用一个可变长度的StringBuilder边读边拼但这样一来回退就麻烦——你得把多余读进去的字符再塞回输入流。我更喜欢的是双指针 字符数组方案整个源文件先一次性读入一个char[]然后用两个指针lexemeBegin和forward。lexemeBegin指向当前Token的起始位置forward向前扫描。当确认了Token的边界直接把lexemeBegin到forward之间的一段用new String(chars, begin, len)取出来即可。回退只需要把forward往回挪一两位就行不涉及任何输入流操作。private char[] input; // 全部源代码 private int lexemeBegin; // 当前Token起点 private int forward; // 扫描指针 private int line 1; // 行号用于报错 private char peek() { if (forward input.length) return \0; return input[forward]; } private char advance() { char c input[forward]; if (c \n) line; return c; } private void retract(int n) { forward - n; }这套写法的好处是清晰、可测、回退成本几乎为零。它唯一的代价是需要先把整个文件读进内存对于课程实验和绝大多数场景完全够用。真要做流式编译超大文件才需要换成带缓冲区的方案那个复杂度不是课程实验该操心的。2.4 错误处理策略要提前想好课程实验一般会要求遇到非法字符要报错但不能直接崩溃要继续分析后面的内容。这要求你在设计阶段就想清楚错误处理策略。我的做法是分三级可以恢复的错误比如遇到一个没法识别的字符记录错误信息行号、列号、字符跳过它继续扫描下一个Token最后在Token流末尾附加一个ERROR类型的Token或者统一打印错误列表。需要panic-mode恢复的错误比如字符串没闭合一路读到文件末尾都没找到。这时候记录错误把已经读到的部分吐成一个ERRORToken然后从当前forward位置继续。致命错误文件读不进来、内存溢出这类直接抛异常。关键是永远不要用异常来做正常的错误报告。每遇到一个非法字符就throw会导致后面几个Token都没法分析用户体验极差。正确做法是收集错误到一个列表里扫描全部结束后一起输出。这样写出来的词法分析器能一口气把源文件里的所有词法错误都报给你而不是一次报一个、改一个再跑一次。3. 从零实现一个能打的词法分析器Java版3.1 先把数据结构和骨架搭起来讲再多理论不如直接把代码怼出来。下面这套是我做实验时反复打磨过的版本支持标识符、关键字、整数、浮点数、字符串、字符、各种运算符、注释和错误恢复。先看整体骨架public class Lexer { private final char[] input; private int lexemeBegin; private int forward; private int line; private final ListString errors new ArrayList(); // 关键字表 private static final SetString KEYWORDS Set.of( if, else, while, for, int, float, return, break, continue, void, char ); public Lexer(String source) { this.input source.toCharArray(); this.lexemeBegin 0; this.forward 0; this.line 1; } public ListToken tokenize() { ListToken tokens new ArrayList(); while (forward input.length) { Token t nextToken(); if (t ! null) tokens.add(t); } tokens.add(new Token(TokenType.EOF, EOF, line)); return tokens; } // ... nextToken 等实现见下文 }Token类本身很简单就是个三元组类型、字面量、行号。public class Token { public final TokenType type; public final String lexeme; public final int line; public Token(TokenType type, String lexeme, int line) { this.type type; this.lexeme lexeme; this.line line; } Override public String toString() { return String.format(%s, \%s\, line %d, type, lexeme, line); } }这里有个我个人很推崇的小做法Token 里带行号。你可能会说语法分析阶段才需要行号吧对但如果你在词法阶段不记录后面没法补——因为Token的值可能被规范化比如字符串去引号、数字转int原本的位置信息就丢了。提前带上行号后面报错体验会好很多。3.2 核心扫描循环nextToken 怎么写nextToken()是整个词法分析器的心脏。它的整体逻辑是跳过空白和注释然后看第一个字符判断进入哪个分支调用对应的子扫描器。骨架如下private Token nextToken() { skipWhitespaceAndComments(); if (forward input.length) return null; lexemeBegin forward; char c peek(); if (Character.isLetter(c) || c _) { return scanIdentifierOrKeyword(); } if (Character.isDigit(c)) { return scanNumber(); } if (c ) { return scanString(); } if (c \) { return scanCharLiteral(); } return scanOperatorOrDelimiter(); }这种先分类再分发的结构比一堆 if-else 堆在一起要清晰得多。每个分支只负责处理自己那类Token的识别逻辑出了问题也容易定位。下面逐个拆。skipWhitespaceAndComments负责跳过空白和注释。空白处理简单遇到 、\t、\n、\r就往前推。注释有两种//行注释和/* */块注释。private void skipWhitespaceAndComments() { while (forward input.length) { char c peek(); if (c || c \t || c \r) { advance(); } else if (c \n) { advance(); } else if (c / forward 1 input.length input[forward 1] /) { // 行注释跳到行尾 while (forward input.length peek() ! \n) advance(); } else if (c / forward 1 input.length input[forward 1] *) { // 块注释跳到 */ advance(); advance(); while (forward input.length) { if (peek() * forward 1 input.length input[forward 1] /) { advance(); advance(); break; } advance(); } } else { break; } } }注意块注释这里要特别小心。如果一直读到文件末尾都没遇到*/那是未闭合注释错误。上面的版本没有处理这个实际写的时候应该加个标志位读到末尾还在注释态就报错。这个坑我踩过当时程序直接卡住——其实是把后面所有代码都当注释吃掉了Token流突然变空排查了半天才发现。3.3 标识符和关键字的识别查表那一下很关键标识符的识别逻辑是起始字符是字母或下划线然后一直吃到非字母、非数字、非下划线的字符为止。拿到字符串后查一下关键字表在表里就是KEYWORD不在就是IDENTIFIER。private Token scanIdentifierOrKeyword() { while (forward input.length) { char c peek(); if (Character.isLetterOrDigit(c) || c _) { advance(); } else { break; } } String lexeme new String(input, lexemeBegin, forward - lexemeBegin); TokenType type KEYWORDS.contains(lexeme) ? TokenType.KEYWORD : TokenType.IDENTIFIER; return new Token(type, lexeme, line); }这里有一个很多人会忽略的性能细节关键字表用HashSet还是用switch或HashMap在标识符识别这个场景里绝大多数标识符都不是关键字而HashSet.contains是 O(1) 的哈希查找。所以直接用Set就很合适。如果是那种关键字极多、且标识符必然频繁查表的语言可以考虑首字符分桶但对课程实验来说完全没必要HashSet足够。再一个细节标识符的字符集要不要支持中文Java里Character.isLetter会把汉字也判成字母。如果你不希望变量一被识别成合法标识符得改成(c a c z) || (c A c Z) || c _。这个取决于语言定义自己决定就行但要意识到isLetter的语义范围比你想的宽。3.4 数字的识别整数和浮点数怎么区分数字识别是词法分析里第一个能让新手翻车的点。核心难点在于看到小数点的时候你不知道它是浮点数的一部分还是别的Token。比如1.5是浮点数1..5里的.应该单独作为 DOT Tokenarr[1].x里的.是成员访问运算符。识别逻辑如下private Token scanNumber() { // 先吞掉所有整数部分 while (forward input.length Character.isDigit(peek())) { advance(); } // 看是否有小数点并且小数点后还有数字 浮点数 if (forward 1 input.length peek() . Character.isDigit(input[forward 1])) { advance(); // 吃掉 . while (forward input.length Character.isDigit(peek())) { advance(); } // 可选支持科学计数法 e/E if (forward input.length (peek() e || peek() E)) { int save forward; advance(); if (forward input.length (peek() || peek() -)) { advance(); } if (forward input.length Character.isDigit(peek())) { while (forward input.length Character.isDigit(peek())) { advance(); } } else { // 不是合法的 e 指数回退 forward save; } } String val new String(input, lexemeBegin, forward - lexemeBegin); return new Token(TokenType.FLOAT_LIT, val, line); } String val new String(input, lexemeBegin, forward - lexemeBegin); return new Token(TokenType.INT_LIT, val, line); }这里有好几处值得细说。第一个判断浮点数时用了两次 lookahead —— 先看当前位置是不是.再看下一个位置是不是数字。只有两个条件都满足才当成浮点数。这就是最长匹配原则的具体落地我们要保证切出来的Token既能覆盖1.5又不会把1.后跟x的情况误吃。第二个科学计数法的处理用了试探 回退。遇到e先假设它是指数标志往后看有没有数字如果发现e后面根本不是数字比如x 1e;这种就forward save回退到e之前让e单独作为一个标识符被后面的逻辑处理。这个保存位置、试探、失败回退的套路是手写扫描器里处理分支的通用手法务必掌握。提示1.5.6这种输入不会崩溃扫描器会先吐出1.5作为浮点数然后状态重置从第二个.开始识别成 DOT Token再吐出6。这正是一个健壮的词法分析器该有的行为——不惊慌继续切。3.5 字符串和字符字面量的转义处理字符串识别相对简单但转义符处理是个容易漏的细节点。看代码private Token scanString() { advance(); // 吃掉起始的 StringBuilder sb new StringBuilder(); while (forward input.length peek() ! ) { char c advance(); if (c \\) { if (forward input.length) break; char escaped advance(); switch (escaped) { case n: sb.append(\n); break; case t: sb.append(\t); break; case r: sb.append(\r); break; case \\: sb.append(\\); break; case : sb.append(); break; case \: sb.append(\); break; default: sb.append(escaped); break; } } else { sb.append(c); } } if (forward input.length) { errors.add(第 line 行字符串未闭合); return new Token(TokenType.ERROR, sb.toString(), line); } advance(); // 吃掉结尾的 return new Token(TokenType.STRING_LIT, sb.toString(), line); }两个关键点。一是反斜杠转义如果遇到\就直接append那\n会被存成两个字符反斜杠和n后面如果要做代码生成或者求值就错了。这里把转义序列还原成真正的字符存进值的部分。二是未闭合检测一路读到文件末尾都没遇到闭合引号就报错并产出 ERROR Token。这个错误恢复很关键否则你的程序会因为一个漏掉的引号处理不了后面的所有代码。字符字面量a的处理和字符串几乎一样只是结束符是且通常只允许一个字符允许转义序列如\n。这里不再贴完整代码照着字符串那套改一下结束符即可。值得提醒的是别把字符字面量和字符串字面量混淆它们的Token类型不同、在语义分析里的处理也不同。3.6 运算符和界符最长匹配 前瞻这是词法分析里最能体现前瞻价值的地方。看看这些棘手的运算符对 / 、 / / 、 / / 、! / ! / !、 / / 。识别它们的统一套路是先吃第一个字符再看下一个字符能组成更长运算符就继续。private Token scanOperatorOrDelimiter() { char c advance(); switch (c) { case : if (peek() ) { advance(); return tok(TokenType.INC, ); } if (peek() ) { advance(); return tok(TokenType.PLUS_ASSIGN, ); } return tok(TokenType.PLUS, ); case -: if (peek() -) { advance(); return tok(TokenType.DEC, --); } if (peek() ) { advance(); return tok(TokenType.MINUS_ASSIGN, -); } return tok(TokenType.MINUS, -); case : if (peek() ) { advance(); return tok(TokenType.EQ, ); } return tok(TokenType.ASSIGN, ); case : if (peek() ) { advance(); return tok(TokenType.LE, ); } if (peek() ) { advance(); return tok(TokenType.SHL, ); } return tok(TokenType.LT, ); case : if (peek() ) { advance(); return tok(TokenType.GE, ); } if (peek() ) { advance(); return tok(TokenType.SHR, ); } return tok(TokenType.GT, ); case !: if (peek() ) { advance(); return tok(TokenType.NEQ, !); } return tok(TokenType.NOT, !); case (: return tok(TokenType.LPAREN, (); case ): return tok(TokenType.RPAREN, )); case {: return tok(TokenType.LBRACE, {); case }: return tok(TokenType.RBRACE, }); case [: return tok(TokenType.LBRACKET, [); case ]: return tok(TokenType.RBRACKET, ]); case ;: return tok(TokenType.SEMICOLON, ;); case ,: return tok(TokenType.COMMA, ,); case .: return tok(TokenType.DOT, .); default: errors.add(第 line 行非法字符 c ); return new Token(TokenType.ERROR, String.valueOf(c), line); } } private Token tok(TokenType t, String lexeme) { return new Token(t, lexeme, line); }这套写法的可读性非常好每读一个字符就进入一个 casecase 内部做前瞻判断。唯一要注意的是顺序如果一个字符可以开始多种不同的长运算符比如、、、你要按照最长优先的原则往下判先看两位的再看三位的。这里没有如果语言支持就得判到三位。4. 实操踩坑记录与疑难排查手册4.1 那些年我写崩过的典型场景讲完实现来说点真正值钱的——实操过程中翻过的车。这些坑在课本上基本不会提但做实验十有八九会遇到。坑一和的优先级判断写反。我第一版写scanOperatorOrDelimiter在的 case 里先判断了忘了看下一个字符是不是结果a b被切成了两个ANDToken。语法分析器一看两个逻辑与连着出现直接报语法错误我还以为是语法分析器写错了查了好久才发现是词法的锅。坑二浮点数的 lookahead 只看了一位。写过if (peek() .)就进浮点分支的版本遇到arr[1].length这种代码.后面跟的是字母l结果被当成浮点数开头后面l又被当标识符。你很难想象当时多崩溃。正确做法永远是peek() . isDigit(input[forward 1])。坑三currentChar用全局单变量回退的时候状态乱了。早期版本我用一个全局currentChar变量存当前字符advance时更新它。结果处理1..5那种需要回退的场景时回退只改了forward指针没同步currentChar导致后面判断基于一个幽灵字符。后来全部改成用peek()和advance()函数不在外部存中间状态这类问题就绝迹了。这是一个很值得记住的原则扫描器的状态尽量收敛到指针和输入数组上不要散落成多个全局变量。坑四行号在块注释里没维护。因为块注释多行我在跳过注释时直接forward没走advance()函数结果注释里每多一行后面报错的行号就错一行。这个词法分析本身没bug但报错信息全是假的调试时被误导惨了。教训任何移动指针的地方都要经过统一的advance()让行号维护只有一处逻辑。4.2 常见问题速查表我把调试词法分析器时最常遇到的问题整理成一张表遇到问题先查这张表能省你一半时间现象可能原因排查方向程序卡死或无限循环某条分支的forward没有推进检查所有while循环里是否都调用了advance()Token数突然变少注释/字符串未闭合吞掉了后面代码检查skipWhitespaceAndComments和scanString的终止条件报错行号永远差几行某处指针移动没走advance()搜索所有forward的裸操作浮点数识别不对lookahead 判断不完整确认是peek(). isDigit(forward1)两个条件被识别成两个运算符扫描没做前瞻检查对应 case 里peek()的判断字符串内容丢了反斜杠转义没还原检查scanString里的转义分支最后的Token丢了循环边界写错确认while(forward input.length)而不是中文标识符被吞isLetter语义过宽明确字符集需要的话改成显式范围判断4.3 调试词法分析器的几个实用技巧调试这类程序光靠print效率太低。分享几个我用得很顺手的技巧写一个Token流打印工具每处理完一个Token就打印它的类型、值、位置。这样即使有问题你也能直接看到是哪个Token开始出错的一步定位。构造最小失败用例。当你发现某个大文件分析错误不要在大文件里找而是抽出触发问题的最小片段。比如怀疑浮点数有问题就写个只含1.5的文件单独跑把问题逼到墙角。用状态日志。在advance()里面加一行日志打印当前字符和位置然后跑一个很小的输入。输出的字符序列和指针变化过程一眼就能看出回退逻辑对不对。边界用例优先。空文件、只有一个字符的文件、全是空格的文件、最后一个Token紧贴文件末尾的文件这些边界一定单独测。很多bug就藏在这些地方。5. 测试验证与进一步扩展5.1 测试用例该怎么设计词法分析器写完了怎么确认它没毛病我这里有一份实测有效的测试清单你可以直接拿去用。正常的用例要覆盖纯标识符、关键字、各种进制的整数、带指数的浮点数、正常字符串、含各种转义的字符串、每个运算符和界符、带注释的代码、混合代码。边界用例要覆盖空输入、只有空白、以注释开头、以注释结尾、注释在Token中间、字符串在文件末尾没闭合、运算符在最末尾。非法输入要覆盖单独的、#、$、中文标点、非法转义序列。我给你一段可以直接跑的测试代码public static void main(String[] args) { String source int a 1.5e2;\n // 这是一行注释\n if (a 100 a ! 0) {\n String s \hello\\nworld\;\n }\n /* 多行\n 注释 */\n a a 2 1;\n; Lexer lexer new Lexer(source); ListToken tokens lexer.tokenize(); for (Token t : tokens) { System.out.println(t); } if (!lexer.errors.isEmpty()) { System.out.println(--- 错误列表 ---); lexer.errors.forEach(System.out::println); } }跑出来你会看到一串Token包括KEYWORD int、IDENTIFIER a、ASSIGN 、FLOAT_LIT 1.5e2、KEYWORD if、LPAREN (等等注释被正确跳过字符串里的\n被还原成真正的换行字符。如果每个Token都对得上恭喜你的词法分析器基本功已经过关了。5.2 和课程实验要求的对应关系很多人做这个实验时会问课本上讲的都是DFA、状态转移图我直接写代码是不是不合规其实两者不矛盾。你前面画的DFA是设计代码是实现。实验报告里应该把状态图用表格或文字描述不一定非要画图写出来说明你的代码怎么对应状态图的每个状态然后附上代码和测试结果。这样既符合课程要求又练到了实打实的实现能力。有些实验还要求输出符号表。符号表其实是词法分析和语法分析的中间产物严格说它是具体实现时的产物不是词法分析的必修内容。如果你要做可以在Token流生成后遍历一遍把IDENTIFIER类型的Token收进符号表。这一步要注意同一个标识符在符号表里只存一份但要记录它出现的所有位置这样后面报错和优化都用得上。5.3 后来的话词法分析器还能往哪扩把基础版本写完之后如果你还想再深入一点有几个方向特别值得练手而且对理解真实编译器很有帮助。第一个是性能优化。你现在每生成一个Token就new String对短文件无所谓但如果要处理几MB的源文件这个开销不小。可以引入字符串驻留interning或者对标识符值做缓存避免重复创建。再进一步可以把 DFA 做成表驱动用状态转移表代替 switch 分支这是很多真实编译器的做法。第二个是Unicode支持。真实语言往往要支持各种字符集和转义形式比如\u4e2d这块涉及字符编码的很多细节练一遍对理解字符到底怎么在计算机里表示很有帮助。第三个是错误恢复的精细化。现在的错误恢复就是跳过非法字符继续。更高级的做法是记录错误位置并尝试推断用户意图比如打成可以在语法阶段报一个更友好的你是不是想写 。这就是词法分析向IDE智能提示延伸的方向。第四个是把词法分析器分离成独立模块。真实场景里词法分析器常常被单独拿出来做语法高亮和代码格式化——不依赖完整的编译器只做词法扫描就能给编辑器提供着色信息。如果你写的是带IDE支持的语言工具这个模块的接口设计就很重要得输出位置区间start/end offset而不只是行号。把这些都做下来你对编译器前端这套东西的理解就已经超过绝大多数只交了实验报告的同学了。