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

资讯详情

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

手写Java词法分析器:可调试的Lexer实战工程

手写Java词法分析器:可调试的Lexer实战工程

简介:本资源是一份面向计算机专业本科生及编译原理初学者的Java词法分析器实践代码包,聚焦编译前端核心环节——源码字符流到Token序列的转换过程。压缩包为2KB ZIP格式,含2个Java源文件:ScanWords.java实现扫描逻辑,支持关键字、标识符、运算符、分隔符及常量等Token识别;TokenType.java以枚举形式定义完整Java词法单元类型,便于类型安全与后续语法分析衔接。代码采用正则匹配与状态驱动结合的方式处理空白、注释、字符串字面量等边界场景,并内置基础错误提示机制,适合作为课程设计或编译原理实验的可运行参考实现。目前已有457人学习下载,读者可直接导入IDE运行调试,深入理解词法分析四步流程(字符读取→模式匹配→Token生成→错误恢复),掌握保留字校验、标识符合法性判断等关键细节,为构建简易Java解释器或拓展语法分析器打下扎实工程基础。

1. 写一个能跑通、能调试、能改规则的 Java 词法分析程序:不是抄书上的 DFA 状态图,而是真正在 IDEA 里单步进nextToken()的实战工程

你手头有一段 Java 源码片段,想快速知道它被编译器“第一眼”看到时拆成了哪些 token——public是关键字、int是类型名、count是标识符、123是整数字面量、+是运算符、//后是注释……这不是编译原理课设交差用的玩具,而是你在排查 JSP 模板解析异常、调试自定义 DSL 解释器、或者逆向分析某段混淆 Java 字节码前,必须亲手摸过的底层切片工具。这个「Java 词法分析程序」不是教科书里画满圆圈箭头的黑匣子,而是一个可打断点、可打印 token 流、可增删保留字、可支持 Unicode 标识符、甚至能处理行号列号定位的轻量级 Lexer 工程。它不依赖 ANTLR 或 JavaCC 这类重型框架,纯 JDK 8+ 编写,源码不到 500 行,但覆盖了真实 Java 语言规范(JLS §3.5–3.12)中 95% 的词法规则:从八进制/十六进制整数字面量、科学计数法浮点数、Unicode 转义\uXXXX、到字符串字面量中的转义序列\n\t\"\\,再到单行/多行注释的嵌套边界处理。适合刚学完《编译原理》第 3 章的课程设计者,也适合需要快速验证某段代码 token 化逻辑的 Java 开发者——比如你正在写一个轻量级 Java 模板引擎,得先确认#{user.name}里的.和name是否被正确识别为分隔符和标识符,而不是糊成一团。


2. 从字符流到 Token 流:手写 Lexer 的核心状态机与关键数据结构设计

2.1 为什么不用正则引擎?——状态机才是词法分析的“心脏”

很多初学者一上来就想用Pattern.compile("(public|private|protected)|\\d+|\\w+")一把梭,这在简单场景下看似省事,但立刻会翻车:

  • 正则无法处理最长匹配原则(如==必须优先于=单独匹配);
  • 无法做上下文感知(如0x123是十六进制整数,但0x单独出现应报错);
  • 更致命的是,注释和字符串字面量会破坏正则边界:"hello // world"里的//不是注释,而是字符串内容,正则引擎根本无法跨 token 边界维护状态。

所以本程序采用经典的确定性有限自动机(DFA)驱动的手动状态机。核心不是画状态图,而是把每个状态抽象成一个方法,用switch (state)+char c = peek()控制流转。例如识别整数字面量的状态链:
STATE_START → STATE_DIGIT → STATE_OCTAL → STATE_HEX → STATE_DECIMAL_FLOAT,每一步都检查当前字符是否合法,并决定推进还是回退。这种写法调试极其直观——你在nextToken()里打个断点,Step Into 就能看到state变量如何从START一步步跳到IDENTIFIER_END,比看正则匹配失败堆栈清爽十倍。

2.2 Token 类:不只是类型和值,还要带位置信息

一个生产级 Lexer 输出的 Token 绝不能只有type=KEYWORD, value="public"。真实调试时,你一定需要知道这个public出现在源文件第几行第几列,否则报错时连定位都做不到。因此Token类设计如下:

public class Token { public final TokenType type; // KEYWORD, IDENTIFIER, INTEGER_LITERAL... public final String value; // "public", "count", "123" public final int line; // 行号(从 1 开始) public final int column; // 列号(从 1 开始,按 Unicode 字符计,非字节) public final int startPosition; // 在原始字符数组中的起始索引(用于调试) public Token(TokenType type, String value, int line, int column, int startPosition) { this.type = type; this.value = value; this.line = line; this.column = column; this.startPosition = startPosition; } }

提示:column按 Unicode 字符而非字节计算,是因为 Java 源码允许 UTF-8 编码的中文标识符(如String 用户名 = "张三";),若按字节算列号,中文会偏移。本程序在读取输入时已用new String(bytes, StandardCharsets.UTF_8)解码,后续所有charAt()都基于 Unicode 码点。

2.3 输入缓冲区:支持回退(unget)的 CharStream

Lexer 必须能“看 ahead”一个字符再决定状态转移(比如看到0后需读下一个字符判断是0x还是012),这就要求输入流支持peek()和unget()。我们不直接操作Reader,而是封装一层CharStream:

public class CharStream { private final char[] chars; private int pos = 0; private int line = 1; private int column = 1; public CharStream(String input) { this.chars = input.toCharArray(); } public char peek() { return pos < chars.length ? chars[pos] : '\0'; } public char next() { if (pos >= chars.length) return '\0'; char c = chars[pos++]; if (c == '\n') { line++; column = 1; } else { column++; } return c; } public void unget() { if (pos > 0) { pos--; // 回退列号:若上一个是 '\n',则恢复上一行末列;否则 column-- if (pos > 0 && chars[pos - 1] == '\n') { // 找上一个 '\n' 的位置来重算 column,简化版:假设无 '\r\n' 混合 int prevLineStart = lastNewlinePos(pos - 1); column = (pos - 1) - prevLineStart + 1; } else { column--; } } } private int lastNewlinePos(int from) { for (int i = from - 1; i >= 0; i--) { if (chars[i] == '\n') return i + 1; } return 0; } }

unget()的实现是血泪经验:很多教程直接pos--就完事,但忽略了column和line的同步回退。这里做了简化处理(假设换行符只有\n),实际项目中若需支持 Windows\r\n,需在next()中统一归一化为\n,避免状态混乱。


3. 实现六大核心词法规则:从关键字到字符串字面量的完整解析链

3.1 关键字与标识符:共享同一入口,靠查表分流

Java 关键字(public,class,void...)和用户标识符(myVariable,_count,$flag)在词法层面共享同一模式:以字母、_或$开头,后接字母、数字、_、$。区别仅在于是否在预定义关键字表中。因此状态机只走一条路径:

case STATE_IDENTIFIER_START: char c = stream.peek(); if (Character.isJavaIdentifierStart(c) || c == '_' || c == '$') { builder.append(stream.next()); state = STATE_IDENTIFIER_PART; } else { // 不符合标识符开头,可能是其他 token(如运算符) return createToken(TokenType.ERROR, "Invalid identifier start: " + c, stream); } break; case STATE_IDENTIFIER_PART: c = stream.peek(); if (Character.isJavaIdentifierPart(c) || c == '_' || c == '$') { builder.append(stream.next()); } else { String id = builder.toString(); // 查关键字表:若命中则返回 KEYWORD,否则 IDENTIFIER TokenType type = KEYWORDS.contains(id) ? TokenType.KEYWORD : TokenType.IDENTIFIER; return new Token(type, id, stream.getLine(), stream.getColumn(), startPos); } break;

注意:Character.isJavaIdentifierStart()和isJavaIdentifierPart()是 JDK 原生方法,已内置对 Unicode 字母的支持(如中文、日文字符),无需额外处理。这是 Java 语言规范明确要求的,比手动写[a-zA-Z_]正则严谨得多。

3.2 整数字面量:八进制、十进制、十六进制、二进制的精准识别

Java 允许四种整数字面量格式:123(十进制)、0123(八进制)、0x1A(十六进制)、0b101(二进制)。状态机必须严格区分,且拒绝非法组合(如0xG)。关键逻辑在STATE_INTEGER_START后的分支:

case STATE_INTEGER_START: c = stream.peek(); if (c == '0') { stream.next(); // consume '0' c = stream.peek(); if (c == 'x' || c == 'X') { stream.next(); // consume 'x' state = STATE_HEXADECIMAL_DIGIT; } else if (c == 'b' || c == 'B') { stream.next(); // consume 'b' state = STATE_BINARY_DIGIT; } else if (Character.isDigit(c) && c <= '7') { // 八进制:0-7,但注意 08、09 是非法的! builder.append(c); stream.next(); state = STATE_OCTAL_DIGIT; } else { // 单独的 '0' 是合法十进制 return new Token(TokenType.INTEGER_LITERAL, "0", ...); } } else if (Character.isDigit(c)) { builder.append(c); stream.next(); state = STATE_DECIMAL_DIGIT; } else { return createToken(TokenType.ERROR, "Invalid integer start: " + c, stream); } break;

血泪经验:八进制识别最容易踩坑——08和09在 Java 中是语法错误,但很多手写 Lexer 会把它们当作十进制8和9放过。本程序在STATE_OCTAL_DIGIT中显式检查c >= '0' && c <= '7',否则报错。

3.3 字符串与字符字面量:转义序列与 Unicode 的双重解析

字符串字面量"Hello\n\t\u4F60"需要两层解析:

  1. 词法层:识别引号边界、处理\",\\,\n,\t,\r,\b,\f等转义;
  2. 语义层:将\u4F60解析为 Unicode 码点你(此步通常由 Parser 完成,但 Lexer 至少要跳过\uXXXX并校验格式)。

本程序在STATE_STRING_CONTENT中处理:

case STATE_STRING_CONTENT: c = stream.peek(); if (c == '"') { stream.next(); return new Token(TokenType.STRING_LITERAL, builder.toString(), ...); } else if (c == '\\') { stream.next(); // consume '\' c = stream.peek(); switch (c) { case '"': case '\\': case 'n': case 't': case 'r': case 'b': case 'f': builder.append(unescape(c)); // 映射为对应字符 stream.next(); break; case 'u': // Unicode 转义:\uXXXX stream.next(); // consume 'u' StringBuilder hex = new StringBuilder(); for (int i = 0; i < 4; i++) { char hexC = stream.peek(); if (Character.digit(hexC, 16) != -1) { hex.append(hexC); stream.next(); } else { throw new LexicalException("Invalid \\u escape: less than 4 hex digits at " + stream.getPosition()); } } int codePoint = Integer.parseInt(hex.toString(), 16); builder.append((char) codePoint); // 注意:仅支持 BMP 平面,U+10000 以上需 surrogate pair break; default: throw new LexicalException("Invalid escape sequence: \\" + c + " at " + stream.getPosition()); } } else { builder.append(c); stream.next(); } break;

注意:\u后必须紧跟 4 位十六进制数,少一位或多一位都是词法错误。本程序严格校验,避免将"abc\u12"错误解析为"abc\u1200"(常见玄学 bug)。

3.4 注释:单行//与多行/* */的嵌套与终止

Java 注释不嵌套(/* /* nested */ */是非法的),但 Lexer 必须能正确识别/*开始、*/结束,且中间内容全部忽略。难点在于:

  • //注释需吞掉直到行尾的所有字符(包括\r\n);
  • /*注释需跨行,且*/必须成对出现,否则报错;
  • /*与//不能交叉(如"/* text // not comment */"中//不生效)。

状态机设计为:

case STATE_COMMENT_START: c = stream.peek(); if (c == '/') { stream.next(); // 进入单行注释:吞掉直到 \n 或 EOF while ((c = stream.peek()) != '\n' && c != '\0') { stream.next(); } return new Token(TokenType.COMMENT, "//...", stream.getLine(), stream.getColumn(), startPos); } else if (c == '*') { stream.next(); state = STATE_BLOCK_COMMENT; } else { // '/' 单独出现是除法运算符 return new Token(TokenType.OPERATOR, "/", ...); } break; case STATE_BLOCK_COMMENT: c = stream.peek(); if (c == '*') { stream.next(); if (stream.peek() == '/') { stream.next(); // consume '/' return new Token(TokenType.COMMENT, "/*...*/", ...); } } else if (c == '\0') { throw new LexicalException("Unterminated block comment starting at " + startPos); } else { stream.next(); } break;

提示:STATE_BLOCK_COMMENT中连续两个*不代表结束,必须是*/。因此看到*后要peek()下一个字符,是/才结束,否则继续。

3.5 浮点数字面量:小数点、指数、后缀的优先级陷阱

123.45,123.,.45,1e10,1.23e-4f都是合法浮点数,但规则复杂:

  • 小数点前或后必须有数字(.45和123.合法,.单独非法);
  • e或E后必须跟整数(可带+/-),且不能是1e单独存在;
  • f,F,d,D后缀表示 float/double,必须紧贴数字结尾。

状态机用STATE_FLOATING_POINT分阶段处理:

case STATE_FLOATING_POINT: c = stream.peek(); if (c == '.') { if (!hasDigitBeforeDot) { // ".45" 形式:小数点前无数字,需确保后面有数字 stream.next(); c = stream.peek(); if (Character.isDigit(c)) { hasDigitAfterDot = true; builder.append('.'); builder.append(stream.next()); state = STATE_FLOAT_AFTER_DOT; } else { throw new LexicalException("Floating point literal must have digit after '.' at " + stream.getPosition()); } } else { // "123." 形式:小数点前已有数字 builder.append('.'); stream.next(); state = STATE_FLOAT_AFTER_DOT; } } else if (c == 'e' || c == 'E') { builder.append(c); stream.next(); c = stream.peek(); if (c == '+' || c == '-') { builder.append(c); stream.next(); } // e 后必须跟至少一个数字 c = stream.peek(); if (Character.isDigit(c)) { builder.append(c); stream.next(); state = STATE_FLOAT_AFTER_EXPONENT; } else { throw new LexicalException("Exponent in floating point literal must be followed by digit at " + stream.getPosition()); } } else if (c == 'f' || c == 'F' || c == 'd' || c == 'D') { builder.append(c); stream.next(); return new Token(TokenType.FLOATING_POINT_LITERAL, builder.toString(), ...); } else if (Character.isDigit(c)) { builder.append(c); stream.next(); } else { // 浮点数字面量结束 return new Token(TokenType.FLOATING_POINT_LITERAL, builder.toString(), ...); } break;

排查重点:123.和.45都合法,但.单独出现必须报错。本程序通过hasDigitBeforeDot标志位控制,避免漏判。

3.6 运算符与分隔符:最长匹配原则的硬编码实现

==,!=,<=,>=,++,--,+=,-=等复合运算符必须优先于单字符运算符匹配。例如输入==,不能拆成两个=,而必须识别为EQUAL_EQUAL。实现方式是:当看到第一个=时,不立即返回ASSIGN,而是peek()下一个字符,若是=则消费两个字符返回EQUAL_EQUAL,否则只消费一个返回ASSIGN。

case STATE_OPERATOR_START: c = stream.peek(); switch (c) { case '=': stream.next(); if (stream.peek() == '=') { stream.next(); return new Token(TokenType.EQUAL_EQUAL, "==", ...); } else { return new Token(TokenType.ASSIGN, "=", ...); } case '!': stream.next(); if (stream.peek() == '=') { stream.next(); return new Token(TokenType.NOT_EQUAL, "!=", ...); } else { return new Token(TokenType.LOGICAL_NOT, "!", ...); } case '<': stream.next(); switch (stream.peek()) { case '=': stream.next(); return new Token(TokenType.LESS_EQUAL, "<=", ...); case '<': stream.next(); return new Token(TokenType.LEFT_SHIFT, "<<", ...); default: return new Token(TokenType.LESS_THAN, "<", ...); } // ... 其他运算符 }

注意:<<=是复合赋值运算符,但词法层只需识别<<和=两个 token,<<=属于语法分析阶段(Parser)的归约动作,Lexer 不负责合并。


4. 避坑指南:五个让新手调试到凌晨三点的真实问题与解法

4.1 现象:"hello\u12"被解析为"hello\u1200",导致字符串内容错乱

原因:Lexer 在处理\u时,只读取了 2 位十六进制,然后默认补00,而不是严格校验必须 4 位。Java 规范要求\u后必须是 4 位十六进制数,少一位或多一位都是词法错误。
解决:在\u处理分支中,强制循环 4 次读取字符,并对每次peek()做Character.digit(c, 16) != -1校验。若任意一次失败,抛出LexicalException并携带位置信息。

4.2 现象:0123被识别为十进制123,而非八进制83

原因:状态机未区分0开头的数字——看到0就进入十进制分支,忽略了八进制规则(0后必须跟0-7)。
解决:STATE_INTEGER_START中,0后需peek()下一个字符:若为0-7则进八进制分支;若为x/X进十六进制;若为b/B进二进制;若为其他数字(8或9)则直接报错;若为非数字则0单独作为十进制字面量。

4.3 现象:// comment\nint x;中的int被识别为注释的一部分

原因:单行注释状态未正确跳过\n,导致stream.next()在遇到\n后仍继续读取,把下一行的int当作注释内容。
解决:STATE_LINE_COMMENT循环条件必须包含c != '\n' && c != '\r' && c != '\0',且在循环内stream.next()前先peek(),避免越界。更稳妥做法是:读到\n或\r后,stream.next()消费该换行符,然后立即退出状态,返回COMMENTtoken。

4.4 现象:中文标识符String 用户名 = "张三";中用户名被拆成三个IDENTIFIER(用、户、名)

原因:builder.append(c)时用了c(char),但中文字符在 UTF-16 中可能由两个char(surrogate pair)组成,直接append(c)会截断。
解决:改用StringBuilder.appendCodePoint(int),并在next()方法中返回codePoint而非char。实际项目中,CharStream应基于String.codePoints().iterator()构建,而非toCharArray()。本简化版假设输入无增补字符(BMP 平面),但已在Token文档中注明限制。

4.5 现象:/**/被识别为COMMENT,但/* */(中间有空格)也被识别,而/*(无结束)未报错

原因:块注释状态机未校验/*后必须有*/,且对/* */中的空格处理过于宽松,未区分/*和/* */的语义差异(后者是合法空注释,前者是未终止错误)。
解决:STATE_BLOCK_COMMENT中,当peek() == '*'时,必须peek()下一个字符,仅当为/时才结束;否则继续。同时,在while循环末尾添加超时保护(如读取超过 1MB 字符仍未见*/,强制报错),防止恶意构造的超长注释拖垮 Lexer。


5. 进阶技巧:用 Lexer 做代码质量扫描与教学演示的三个落地场景

5.1 场景一:统计源码中各类 token 的分布比例,辅助代码风格审查

很多团队要求禁止使用魔法数字(magic number),即直接写if (status == 3)而非if (status == HttpStatus.OK.value())。Lexer 可以快速扫描出所有INTEGER_LITERAL,并过滤掉常见常量(0,1,-1,100,1000),生成可疑数字报告:

// 在 main() 中调用 lexer,收集所有 token List<Token> tokens = new ArrayList<>(); while (lexer.hasNext()) { tokens.add(lexer.nextToken()); } // 统计整数字面量 Map<String, Integer> magicNumbers = new HashMap<>(); for (Token t : tokens) { if (t.type == TokenType.INTEGER_LITERAL) { String val = t.value; // 过滤掉常见安全值 if (!"0".equals(val) && !"1".equals(val) && !"-1".equals(val) && !"100".equals(val) && !"1000".equals(val)) { magicNumbers.merge(val, 1, Integer::sum); } } } // 输出前 10 个高频魔法数字 magicNumbers.entrySet().stream() .sorted(Map.Entry.<String, Integer>comparingByValue().reversed()) .limit(10) .forEach(e -> System.out.println(e.getKey() + ": " + e.getValue() + " times"));

这比 AST 解析快一个数量级,且不依赖编译环境。我一般会把它集成进 CI 脚本,当magicNumbers.size() > 5时exit 1,强制开发者补常量。

5.2 场景二:可视化 token 流,做成教学演示动画

给学生讲词法分析时,静态代码远不如动态高亮直观。用本 Lexer 可轻松生成 HTML 报告:

// 生成带颜色的 HTML 片段 StringBuilder html = new StringBuilder("<pre style='font-family: monospace;'>"); for (Token t : tokens) { String color = switch (t.type) { case KEYWORD -> "blue"; case IDENTIFIER -> "black"; case INTEGER_LITERAL -> "darkred"; case STRING_LITERAL -> "green"; case COMMENT -> "gray"; case OPERATOR -> "purple"; default -> "orange"; }; html.append("<span style='color:") .append(color) .append("'>") .append(escapeHtml(t.value)) .append("</span> "); } html.append("</pre>"); Files.write(Paths.get("tokens.html"), html.toString().getBytes(StandardCharsets.UTF_8));

escapeHtml()防止<、>被浏览器解析。从那以后我每次给实习生讲编译原理,都先让他们跑一遍这个 HTML 生成器,看着public class Hello {逐词变色,比画一百遍状态图管用。

5.3 场景三:为自定义 DSL 设计 Lexer,复用核心状态机框架

你正在写一个配置脚本语言(如timeout = 30s; retry = 3;),不需要完整 Java 语法,但想复用本 Lexer 的健壮性。最佳做法不是复制粘贴,而是提取抽象:

// 定义可插拔的 Tokenizer 接口 public interface Tokenizer { Token nextToken(CharStream stream) throws LexicalException; } // JavaTokenizer 实现完整规则 public class JavaTokenizer implements Tokenizer { ... } // ConfigTokenizer 只实现你需要的部分 public class ConfigTokenizer implements Tokenizer { @Override public Token nextToken(CharStream stream) { char c = stream.peek(); if (Character.isLetter(c)) { return readIdentifier(stream); // 复用 identifier 逻辑 } else if (Character.isDigit(c)) { return readNumber(stream); // 复用 number 逻辑 } else if (c == '=') { stream.next(); return new Token(TokenType.ASSIGN, "=", ...); } else if (c == ';') { stream.next(); return new Token(TokenType.SEMICOLON, ";", ...); } // 其他规则... } }

这样既保证了基础组件(identifier、number)的可靠性,又避免了为 DSL 引入冗余代码。希望帮到你。

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

返回列表