☰
Java手工词法分析器实现:DFA状态机与Token边界处理
2026/10/10 20:05:05 网站建设 项目流程

简介:这是一份面向编译原理课程设计的Java词法分析程序源码包,适合高校学生及希望理解编译器前端原理的开发者使用。压缩包仅2KB,包含2个Java源文件,规模精简非常适合逐行研读:ScanWords.java实现扫描器主逻辑,读取Java源代码字符流,通过正则表达式匹配标识符、数字常量、操作符、分隔符等,并生成对应的token序列;TokenType.java定义枚举类型,覆盖if、else、while等关键字,以及变量名、运算符、括号分号等全部基本token类别。源码还重点处理了空白字符跳过、注释识别、字符串与字符字面量解析,并具备基础错误报告与恢复能力,遇到非法字符时能给出提示并继续扫描。通过学习这两个文件,可以完整掌握词法分析的分词步骤、规则配置与扫描机制,为后续语法分析、编译器编写或文本解析工具开发奠定扎实基础。该资源已有459人在线学习,适合做课程设计参考或编译原理配套实践。

1. 手工词法分析器难在哪:先写一个能跑的最小 Lexer

说到 Java 词法分析,很多人第一反应是编译原理课本里的 DFA、正则表达式、自动机理论。但真正上手写一个能运行的词法分析程序,你会发现难点从来不在理论,而在那些说不清的边界条件:注释里的关键字要不要跳过、1.2.3算不算合法数字、文件末尾最后一个 Token 会不会丢。我见过不少面试候选人在白板上写出完整的状态转移图,一追问++和+的区分方式就卡住了。

这篇文章要拆的是一份用 Java 实现的手工词法分析程序资源,核心思路是用确定性有限自动机(DFA)+ 状态变量做字符级扫描,不依赖 JFlex 等生成器,适合 Java 基础扎实但没做过编译器的从业者、准备面试的 Java 工程师,以及做小型 DSL 解析器的开发者。读完你能掌握 Token 类型划分、标识符与关键字冲突处理、数字和注释的边界条件,以及错误恢复策略。这份资源是一整套可编译的 Java 工程,包含 Lexer 类、Token 定义、测试用例和常见坑位说明,不是理论讲义。

2. 从字符流到 Token:DFA 状态机与五个 token 类型的划分

2.1 token 定义与 TokenType 枚举:先定语言规则,再写代码

词法分析的本质是把一段字符串拆成有意义的词素(Lexeme),并为每个词素打上类型标记(Token Type)。做这一步之前,必须先定义你要支持的语言子集。比如这份资源里支持的是简化版 Java 子集,token 类型就包括关键字、标识符、整数、浮点数、运算符、分隔符、字符串、注释、EOF。

Token 和 TokenType 是词法器的基础数据结构,一般这样定义:

public enum TokenType { KEYWORD, // 关键字:if, else, while... IDENTIFIER, // 标识符:变量名、函数名 INTEGER, // 整数:123 FLOAT, // 浮点数:1.23 OPERATOR, // 运算符:+ - * / == != <= >= SEPARATOR, // 分隔符:() {} ; , STRING, // 字符串:"hello" COMMENT, // 注释:// ... 或 /* ... */ EOF // 文件结束标记 } public class Token { public TokenType type; public String lexeme; // 原始文本 public int line; // 起始行号 public int column; // 起始列号 public Token(TokenType type, String lexeme, int line, int column) { this.type = type; this.lexeme = lexeme; this.line = line; this.column = column; } @Override public String toString() { return String.format("(%-10s, %-12s, line=%d, col=%d)", type.name(), "\"" + lexeme + "\"", line, column); } }

枚举里分出COMMENT和EOF是很多简化词法器容易漏掉的点。COMMENT类型方便语法分析阶段直接过滤注释,EOF则用来标记输入结束,避免每次取字符都做空判断。这里的line和column字段非常重要,后续做语法错误报告、定位编译错误全靠它,别省。

定义 TokenType 时有一个设计取舍:关键字要不要单独建成枚举?我一般不建议单独建一个KEYWORD_IF、KEYWORD_WHILE这样的枚举,而是统一用KEYWORD类型,再通过lexeme区分具体是哪个关键字。这样语法分析器拿到 Token 后,用lexeme.equals("if")判断即可,枚举数量不会膨胀,状态转移也不用手工维护几十种类型。

2.2 状态转移与缓冲区:手工 DFA 的基本模型

手工词法分析器的核心是一个字符级循环。伪代码流程如下:

  1. 读取一个字符ch。
  2. 根据当前状态和ch决定转移到哪个状态。
  3. 如果到达可接受状态,尝试读取更长的词素。
  4. 如果无法继续转移,回退一个字符,并输出已积累的 token。

这里涉及两个关键概念:最长匹配和回退(pushback)。比如识别>=,扫描到>不能立刻输出,必须再窥探下一个字符是不是=,如果是就构成复合运算符,不是就要把=放回输入流。Java 里可以用pushbackReader或者自己维护一个currentChar和nextChar的预读缓冲区。

最简单可控的方式是把输入整体读成字符数组,维护一个index指针,需要回退就index--。代码简洁,缺点是内存占用略高。对于一般的源码文件,几百 KB 级别完全没问题。资源里的实现就是这种方案:

private char[] input; // 整个输入内容 private int index = 0; // 当前扫描位置 private int line = 1; private int column = 1; private char peek() { if (index >= input.length) return '\0'; return input[index]; } private char advance() { char c = peek(); if (c == '\n') { line++; column = 1; } else { column++; } index++; return c; } private void pushback() { if (index > 0) { index--; // 回退时需要恢复行号和列号 column--; if (input[index] == '\n') { line--; column = 1; } } }

这里最容易被忽视的是pushback()里对line和column的回退。如果只回退index不修正行列号,后面报错时行号会越偏越远,这种错误极其隐蔽。维护行列号有两种常见做法:一种是在advance()里更新,在pushback()里还原;另一种是每次需要行列号时重新从 token 起点计算。我推荐前者,性能好且直观。

缓冲区设置也有讲究。用char[]一次性读入时,要处理文件编码问题,最稳妥的是用Files.readAllBytes再按 UTF-8 解码。资源里提供了一个loadSource(String path)方法:

public static char[] loadSource(String path) throws IOException { byte[] bytes = Files.readAllBytes(Paths.get(path)); String content = new String(bytes, StandardCharsets.UTF_8); // 预处理:移除 BOM 头(如果有) if (!content.isEmpty() && content.charAt(0) == '\uFEFF') { content = content.substring(1); } return content.toCharArray(); }

有的队友直接把文件当字符串读,不做 BOM 处理,Windows 下用带 BOM 的 UTF-8 文件就会在第一个 token 前多出一个不可见字符,后续所有位置偏移都错了。预处理的substring(1)就是解决这个痛点。

2.3 五类 token 的识别路径与优先顺序

明确 token 类型之后,主扫描逻辑就是一个多分支判断。资源里的主循环大概是这样:

public Token nextToken() { skipWhitespaceAndComments(); // 跳过空白和注释 if (index >= input.length) { return new Token(TokenType.EOF, "", line, column); } char ch = peek(); if (isLetter(ch) || ch == '_') { return readIdentifierOrKeyword(); } if (isDigit(ch)) { return readNumber(); } if (ch == '"') { return readString(); } if (isOperatorStart(ch)) { return readOperator(); } if (isSeparator(ch)) { advance(); return new Token(TokenType.SEPARATOR, String.valueOf(ch), line, column); } // 无法识别的字符 throw new LexException("非法字符: " + ch, line, column); }

这个分支顺序是精心设计的:先跳过空白和注释——这部分被提前消费掉,不产生 token;然后依次判断标识符、数字、字符串、运算符、分隔符。注意必须把isDigit(ch)放在readNumber()前,但readNumber()内部可能会遇到字母,比如123abc,这属于非法词素,要单独处理。很多初学者会把数字后面的字母直接拼进 token,导致把123abc识别成一个整体,这是错的。

识别路径的优先顺序可以记口诀:注释 > 标识符 > 数字 > 字符串 > 运算符 > 分隔符 > 报错。为什么注释优先级最高?因为注释的出现位置不确定,可能在两个 token 之间,也可能在行尾,如果不先跳过,//会被当成除法运算符处理。后面的章节我会具体拆解每种识别的实现细节和参数。

3. 用 Java 实现词法分析器:核心代码拆解与边界参数

3.1 标识符与关键字:哈希表解决冲突,大小写敏感是默认

标识符是词法器里最频繁遇到的 token,也是最容易出问题的。Java 语言规范里标识符以字母、_、$开头,后续字符可以是字母、数字、_、$。这个规则可以直接落到一个isLetter判断里:

private Token readIdentifierOrKeyword() { int startLine = line; int startCol = column; StringBuilder sb = new StringBuilder(); while (isLetter(peek()) || isDigit(peek()) || peek() == '_' || peek() == '$') { sb.append(advance()); } String word = sb.toString(); if (keywords.contains(word)) { return new Token(TokenType.KEYWORD, word, startLine, startCol); } return new Token(TokenType.IDENTIFIER, word, startLine, startCol); } private static final Set<String> keywords = new HashSet<>(Arrays.asList( "if", "else", "while", "for", "return", "int", "float", "string", "boolean", "void", "class", "new", "null", "true", "false" ));

这里的关键设计是先按标识符规则读完整词素,再查哈希表判断是不是关键字。顺序不能反过来——如果先判断if,那么ifx就被错误截断成关键字if加标识符x。keywords集合用HashSet而不是List,查找是 O(1),而且声明为static final,避免每次创建词法器都重新初始化。

大小写问题也要考虑。Java 本身大小写敏感,所以这里的keywords集合里全部是小写。如果你们的 DSL 支持大小写不敏感关键字,可以在readIdentifierOrKeyword结束处用word.toLowerCase()去查表,但注意 token 的lexeme必须保留原始大小写,别把用户写的IF改成if存进去,否则后续符号表对不上。

3.2 数字识别的边界:整数、浮点数、非法状况三种情况

数字识别比看起来复杂,因为要区分整数和浮点数,还要处理1.2.3、1.、.5这类边界。通常的做法是:先扫描整数部分,如果遇到小数点,再看小数点后是否跟数字。严格来说,1.在 Java 里是合法浮点数,但很多静态分析工具会警告。资源里做的是接近 Java 规范的识别:

private Token readNumber() { int startLine = line; int startCol = column; StringBuilder sb = new StringBuilder(); while (isDigit(peek())) { sb.append(advance()); } boolean isFloat = false; // 处理小数点:只有后跟数字才当作浮点数,避免 1.2.3 if (peek() == '.' && isDigit(peek(1))) { isFloat = true; sb.append(advance()); // '.' while (isDigit(peek())) { sb.append(advance()); } } // 数字后面直接跟字母 => 非法词素 if (isLetter(peek()) || peek() == '_') { while (isLetterOrDigit(peek()) || peek() == '_' || peek() == '$') { sb.append(advance()); } throw new LexException("非法词素: " + sb, startLine, startCol); } TokenType type = isFloat ? TokenType.FLOAT : TokenType.INTEGER; return new Token(type, sb.toString(), startLine, startCol); }

注意peek(1)表示看当前字符的下一个字符,需要额外实现一个带偏移的peek(int offset)。这样当扫描到1.2时,peek()是'.',peek(1)是'2',满足条件才会消费小数点。如果是1.后面直接跟空格,那1.会被分成整数1和运算符.(或者报错)。如果你希望支持1.这种写法,可以修改条件为peek() == '.' && !isDelimiter(peek(1)),但要注意别把1.func()这种方法调用误判。我的建议是严格一点,按后跟数字来判定。

数字后接字母的情况必须单独报错。比如123abc,按最自然的理解是某个变量名,但变量名不能以数字开头,所以这一定是源码错误。这里用throw new LexException会让整个词法分析中断,但错误信息里带了位置,语法分析阶段可以恢复。如果想做错误恢复,可以把异常改成记录错误列表后跳过这一段,继续往后扫描。

3.3 运算符:最长匹配与复合运算符的状态处理

运算符是词法器里最能体现 DFA 思想的部分。=,==,=>看起来像,但含义完全不同;+,++,+=必须在读取+后立即窥探下一个字符。实现了最长匹配才能正确处理。

资源把运算符分成了几组,每一组对应一个状态转移:

private Token readOperator() { int startLine = line; int startCol = column; char first = advance(); String op = String.valueOf(first); switch (first) { case '=': if (peek() == '=') { Advance(); op += '='; } break; case '!': if (peek() == '=') { advance(); op += '='; } else throw new LexException("单独的 ! 不是合法 Java 运算符", startLine, startCol); break; case '+': if (peek() == '+') { advance(); op += '+'; } else if (peek() == '=') { advance(); op += '='; } break; case '-': if (peek() == '-') { advance(); op += '-'; } else if (peek() == '=') { advance(); op += '='; } break; case '*': if (peek() == '=') { advance(); op += '='; } break; case '/': if (peek() == '=') { advance(); op += '='; } break; case '<': if (peek() == '=') { advance(); op += '='; } else if (peek() == '<') { advance(); op += '<'; } break; case '>': if (peek() == '=') { advance(); op += '='; } else if (peek() == '>') { advance(); op += '>'; } break; default: // 单个运算符直接返回 break; } return new Token(TokenType.OPERATOR, op, startLine, startCol); }

这里的advance()是消费字符并更新行列号,peek()是仅查看不消费。核心思想是读入一个字符进状态 S,看下一个字符是否满足转移条件,满足就消费并进入下一个状态,否则停在当前状态输出 token。这种手写 switch 要比查表式 DFA 好读很多,也容易扩展。

要特别注意除法/和注释的冲突。在readOperator()里只用/开头进入,但如果前一步skipWhitespaceAndComments()已经把注释跳过了,这里就不会再遇到/后面跟/的情况。所以一定要保证注释扫描在运算符扫描之前调用,否则a // comment中的/ /会被识别成除号。很多翻车现场就是这么来的。

3.4 字符串与注释:两个最容易写崩的识别器

字符串识别要处理转义字符和多行字符串。这里实现的是简化版 Java 字符串,支持\"和\\两种转义:

private Token readString() { int startLine = line; int startCol = column; advance(); // 吃掉开头的 " StringBuilder sb = new StringBuilder(); while (true) { if (index >= input.length) { throw new LexException("字符串未闭合", startLine, startCol); } char c = advance(); if (c == '"') { break; } else if (c == '\\') { char next = advance(); if (next == '"' || next == '\\' || next == 'n' || next == 't') { sb.append('\\').append(next); } else { throw new LexException("非法转义字符 \\" + next, line, column); } } else { sb.append(c); } } return new Token(TokenType.STRING, sb.toString(), startLine, startCol); }

这里有个取舍:sb里保留的是转义后的原始形式还是转义前的字符?资源里保留的是\"这样的两个字符序列,这样语法分析阶段可以把字符串原样传给后续处理,或是自己再做一层反转义。如果你希望词法器直接把\"变成",要注意\n和\\的顺序,先替换\\再替换\n才不会把\\n错误变成换行。这个坑我在真实项目里踩过。

注释识别相对简单,但要注意块注释的闭合。一个安全的实现是这样:

private void skipComment() { if (peek() == '/') { advance(); // 第一个 / if (peek() == '/') { advance(); // 第二个 / while (peek() != '\n' && index < input.length) { advance(); } } else if (peek() == '*') { advance(); // * boolean closed = false; while (index < input.length) { char c = advance(); if (c == '*' && peek() == '/') { advance(); // 吃掉 / closed = true; break; } } if (!closed) { throw new LexException("块注释未闭合", line, column); } } } }

这个实现的缺陷是块注释不嵌套,但 Java 本身块注释也不嵌套,所以没问题。但要注意/*和*/之间的//不应该被当成行注释,这里因为整个skipComment是在读到/*之后一次性跳过的,循环内不会再触发注释判断,所以安全。

4. 避坑与常见问题:词法分析最容易翻车的五个场景

4.1 现象:关键字总是被识别成标识符,语法分析拿不到 KEYWORD

原因:关键字判断放在了标识符读取之前,也就是先看当前字符,试图在前缀匹配时就命中关键字。比如先判断peek() == 'i'然后尝试读if,导致iffy被拆成if和fy,关键字识别成功了,但标识符全乱。

解决:严格坚持「读完整标识符,再查表确认关键字」。顺序必须是isLetter分支进入,读完所有字母和数字,得到一个完整字符串,再问keywords.contains(word)。这样if和iffy都能正确归类。

4.2 现象:1.2.3被当成了两个浮点数1.2和.3,或者直接不报错

原因:数字识别时,看到小数点就无条件消费,然后继续读数字,没有判断小数点前是否已经有了小数点。这样第一个小数点后读到2,第二个小数点又触发一次浮点数识别。

解决:用布尔变量isFloat记录已经遇到过小数点,进到小数部分后如果再次遇到小数点,应该停止并把当前 token 定为1.2,第二个.留给下一次nextToken()。如果1.2.3整体是非法词素,可以在遇到第二个小数点时抛异常。资源里采用的是后者,因为更早暴露错误。

4.3 现象:注释里的关键字被当成真关键字,字符串里的"if"也变成了 KEYWORD

原因:没有提前跳过空白和注释,也没有在readString()里独立扫描,导致主循环进入字符串内部去识别 token。

解决:nextToken()第一行必须调skipWhitespaceAndComments(),把所有空白、行注释、块注释一次性消费掉,保证每个 token 从有效字符开始。字符串必须由独立的readString()完整读完,从"到",期间不对外输出 token。这样注释和字符串里的内容永远不会进入 token 流。

4.4 现象:源码最后一个 token 丢失,读出来只有 EOF 没有实际 token

原因:主循环条件写成while (index < input.length),在index == input.length时直接返回 EOF,但最后一个 token 可能正好在index == input.length - 1处被读取,读取后 index 已经是 length,主循环无法继续执行。

解决:把主循环条件改成while (true),在nextToken()内部判断 EOF。更规范的做法是每次nextToken()先检查是否还有字符,没有就返回 EOF;有就读一个 token,不论读完后 index 是否到尾,都先返回这个 token。测试时务必在输入文件末尾放一个运算符如+,看是否正确输出。

4.5 现象:非法字符导致整个解析中断后,后续错误全报在同一个位置

原因:词法异常直接抛出,没有记录错误并恢复扫描。比如遇到一个@,抛异常就直接结束了,后续所有 token 都扫不到。

解决:在 Lexer 中维护List<LexError> errors,遇到非法字符时,记录当前 line/column,然后advance()跳过这个字符,继续识别下一个。语法分析器可以通过hasErrors()判断是否要进入错误处理流程。这样做的好处是能在一轮扫描中收集所有词法错误,而不是只报第一个就停了。这在实际编译器里更重要。

5. 从最小实现到可用工具:扩展浮点数、字符串、注释的验证技巧

5.1 用测试用例反推词法规则

写词法器最怕的是规则没定清楚就动手。我通常会在资源基础上先写一份测试输入,把每条规则都逼到边界上。下面这份是我在复现时惯用的测试样例,每一行都对应一种边界:

int main() { int a = 1; float b = 1.23; string s = "hello \"world\""; // this is comment if (a >= 1 && b != 2.0) { return 0; } a++; b += 2; }

把这串代码输入 Lexer,依次打印 token,重点检查下面这几条:

  • int是 KEYWORD,main是 IDENTIFIER
  • 1.23是 FLOAT,1是 INTEGER
  • "hello \"world\""是 STRING,内部转义引号不能导致字符串提前结束
  • // this is comment不产生任何 token
  • >=,&&,!=,++,+=都是单个 OPERATOR token,而不是拆开

我在项目里就是靠这一套用例把所有边界框住。如果这些都能通过,词法器基本就立住了。然后我再补非法输入用例,比如123abc、1.2.3、"unterminated,确保它们返回错误而不是静默通过。

5.2 工程化扩展:行号跟踪、错误恢复和性能优化

最小实现能跑之后,如果你想把它用到真实项目里,建议做三件事。

行号跟踪要独立成Position对象。我现在写词法器必定维护一个Position内部类,包含line和column,每次advance()时更新副本,这样回退时只需要恢复引用,不会污染全局状态。资源里的Token直接存了行列值,够用,但更复杂的场景建议封装。

错误恢复要优先于抛异常。我在做 DSL 解析器时,把LexException改成了收集模式,非法字符跳过并记录,unterminated string则在当前行末尾补一个引号继续扫。这样语法分析阶段能拿到尽可能多的 token,错误信息也更完整。

性能上有一个立竿见影的优化:不要用StringBuilder.append逐字符拼接,而是记录每个 token 的起始位置和结束位置,直接new String(input, start, length)。这样省去大量字符复制。对一个几千行的源文件体感不明显,但对 MB 级文件差异很大。另一个优化是把peek()改成对char[]的直接下标访问,避免每次方法调用。

从那以后,我每次写完词法分析器都会强制自己跑一遍边界测试,至少覆盖注释、字符串、复合运算符、非法词素这四类,然后把行列号打印出来核对。说实话,词法分析不像算法题那样有标准答案,真正的价值在于你亲手踩过那些边界坑之后形成的直觉。希望这份 Java 词法分析程序资源和上面的踩坑记录能帮到你,让你少走我走过的弯路。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询