尧图网络 高端网站定制 · 原创设计
免费咨询热线
400-888-6620
免费获取方案
用Java手写编译器:词法分析、语法分析与AST设计全解析
简介Java编译器实现是理解JVM生态与开发工具链的重要切入点。这份资源包以精简的代码示例和说明文档面向对编译原理感兴趣、想了解IDE如何集成编辑与编译功能的开发者也适合准备自制小型编译器的学生或工程师。包内包含Java源码与Markdown说明文档两个文件压缩包仅2KB却点出了词法分析、语法分析、语义分析、中间代码生成等关键阶段并配合说明文档梳理了从源码文本到字节码的整体流程。目前已有59人学习浏览读者可借助说明文件快速掌握编译器模块的职责划分、错误检测与报告思路并在此基础上扩展词法或语法规则尝试生成更高效的字节码进而为深入研读javac源码或参与编译工具开发打下基础。1. 拿到Java编译器的实现源码先定目标再拆模块做编译原理课程设计时最怕的是拿到一份Java编译器的实现源码却没有入手点一头扎进代码就开跑结果连入口在哪都找不到。这份资源把编译器从源码到可执行单元的全链路串起来了词法分析、语法分析、符号表与作用域、中间表示、目标代码生成都以模块化Java源码呈现。适合三类人准备编译原理课程答辩的学生、想复现经典编译流程的开发者、给现有编译器加语法特性的进阶玩家。文章按一条主线走——先看目录与模块边界再亲手跑通词法分析和语法分析接着处理符号表和语义最后落在代码生成与验证上。2. 词法分析器怎么读Token定义与有限状态机的落地词法分析是整个编译器里最容易先跑通、也最容易埋雷的模块。它负责把源码字符串切成Token流后续Parser全部工作在Token流上所以Token类型划分和扫描状态机的质量直接决定后面能走多远。2.1 先从目录结构看编译器分层测试用例就是最好的文档拿到一个Java编译器实现的zip包别先急着找main方法先把目录结构过一遍。典型的实现会按职责拆成词法、语法、语义、生成几个包目的大概是这样目录职责典型入口lexer/源码字符串转Token流识别关键字、操作符、字面量Lexer.javaparser/Token流转AST递归下降解析表达式和语句Parser.javaast/AST节点定义与Visitor接口Expr.java, Stmt.javasemantic/符号表、作用域链、类型检查SymbolTable.javacodegen/目标代码生成或AST解释器实现CodeGenerator.javabuild.gradle构建脚本依赖与编译入口无我拆这种项目有个习惯先打开测试目录。一个合格的编译器实现里单元测试会覆盖表达式优先级、变量遮蔽、字符串转义、错误恢复等典型场景。测试用例是最直观的文档比任何README都可靠。如果你拿到的资源里只有源码没有测试建议动手补一个——后面第5章会讲最小测试集怎么设计。提示如果压缩包里只有一个巨大的单文件先按上述目录拆开再读否则状态机、AST、符号表混在一起翻车概率翻倍。2.2 TokenType与Token数据类先给词法单元分类再谈识别词法分析的核心产物是Token流而TokenType枚举决定了这个编译器能识别哪些基本语言元素。一个覆盖教学编译器常用特征的TokenType大致长这样public enum TokenType { // 字面量与标识符 IDENTIFIER, INT_LITERAL, STRING_LITERAL, // 关键字单独用KEYWORD标记 KEYWORD, // 单字符运算符 PLUS, MINUS, STAR, SLASH, LPAREN, RPAREN, LBRACE, RBRACE, SEMICOLON, // 双字符运算符 EQ, EQEQ, BANG, BANGEQ, LT, GT, LE, GE, // 文件结束标记 EOF }这里的选型理由是关键字不单独建IF、ELSE、WHILE等枚举常量而是先把字母序列识别为IDENTIFIER再查关键字表判断它是不是关键字最后把Token的type改成KEYWORD。这么做的好处是新增关键字不用改枚举词法扫描器的主流程也不用动。坏处是Parser在匹配关键字时要比较token.lexeme而不是枚举常量所以要保证lexeme存的是原始源码文本。Token数据类要携带位置信息否则语义分析阶段报错时没法定位到行列public class Token { public final TokenType type; public final String lexeme; public final int line; public final 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 type ( lexeme ) line : column; } }lexeme保存的是源码里的原始字符串比如就存不要在这里做任何语义转换line从1开始column从1开始初始化时每读一个字符column换行时line并重置column为1。这个设计直接决定后面报错信息是否可读。2.3 手写扫描状态机读字符、判类别、回退一步词法扫描器最常见的实现是用PushbackReader做一个带回退的单字符流。核心的nextToken方法按当前字符类别分流public Token nextToken() throws IOException { // 跳过空白、换行、块注释、行注释 skipWhitespaceAndComments(); int c reader.read(); if (c -1) { return new Token(TokenType.EOF, , line, column); } char ch (char) c; if (isDigit(ch)) { return readNumber(ch); } if (isAlpha(ch)) { return readIdentifier(ch); } if (ch ) { return readStringLiteral(); } return readOperator(ch); }逻辑说明每次先跳过不可见字符和注释再读一个有效字符。数字走readNumber字母和下划线走readIdentifier双引号走字符串其余走运算符。注意不要用Character.isLetterOrDigit直接判断它在某些locale下会把中文也判定为字母导致标识符规则不可控。readNumber要一次吞够整个数字字面量private Token readNumber(char first) throws IOException { StringBuilder sb new StringBuilder(); sb.append(first); while (isDigit(peek())) { sb.append((char) reader.read()); } // 支持带小数点的浮点字面量 if (peek() .) { sb.append((char) reader.read()); while (isDigit(peek())) { sb.append((char) reader.read()); } return new Token(TokenType.DOUBLE_LITERAL, sb.toString(), line, column); } return new Token(TokenType.INT_LITERAL, sb.toString(), line, column); }参数说明peek()是PushbackReader的常见封装返回下一个字符但并不消费相当于往前看了一眼。之所以要peek而不是直接read是因为遇到123.这种输入时如果小数点后面不是数字就得把小数点回退给下一轮处理。最大吞读原则在这里的体现是在当前字符能构成合法Token的前提下吞得越长越优先但吞到临界点该停就停。字符串字面量的扫描最容易翻车见下面的实现private Token readStringLiteral() throws IOException { StringBuilder sb new StringBuilder(); while (true) { int c reader.read(); if (c -1 || c \n) { throw new LexException(未终止的字符串字面量, line, column); } char ch (char) c; if (ch \\) { int next reader.read(); if (next -1) { throw new LexException(字符串内不能以反斜杠结尾, line, column); } // 把反斜杠和转义字符原样保存 sb.append(ch).append((char) next); continue; } if (ch ) { return new Token(TokenType.STRING_LITERAL, sb.toString(), line, column); } sb.append(ch); } }这里的关键选择是反斜杠只触发对下一个字符的消费但转义序列原样并入lexeme不在这里解释成真正的换行或制表符。原因很简单——词法阶段不知道当前上下文是赋值给字符串还是作为方法参数转义展开是语义分析阶段的事。如果你在词法器里就把\n变成真正的换行符后面代码生成阶段就丢失了“源码里到底写得是什么”这个信息调试时会非常痛苦。3. Parser与SymbolTable递归下降语法分析和作用域链的具体实现语法分析阶段的任务是把Token流变成AST。手工编写的递归下降Parser是最适合教学和课程设计的方案代码直观、优先级靠嵌套函数表达、错误定位准确。相比用ANTLR或JavaCC这类生成器手写Parser虽然样板代码多但每一步逻辑都是自己可控的。3.1 表达式优先级的实现靠递归层级而不是老师傅经验递归下降的核心技巧是每一层函数只处理自己这层运算符优先级越低的运算符越在外层。一个实用的层级顺序从低到高是assignment → equality → comparison → term → factor → unary → primary。private Expr expression() { return assignment(); } private Expr assignment() { Expr expr equality(); if (match(TokenType.EQ)) { Token equals previous(); Expr value assignment(); return new AssignExpr(equals, expr, value); } return expr; }逻辑说明assignment是表达式层级的最高层所以它能接收整个等式右边的值。match(TokenType.EQ)检查当前Token是否是赋值号是就继续递归调用assignment()解析右操作数。这里的右递归是有意的为了支持a b 3这样的链式赋值。加减和乘除分别处理private Expr term() { Expr expr factor(); while (match(TokenType.PLUS, TokenType.MINUS)) { Token op previous(); Expr right factor(); expr new BinaryExpr(expr, op, right); } return expr; } private Expr factor() { Expr expr unary(); while (match(TokenType.STAR, TokenType.SLASH)) { Token op previous(); Expr right unary(); expr new BinaryExpr(expr, op, right); } return expr; }这里的while循环是左结合的唯一正确解法。如果这里不用循环而用递归1 - 2 - 3会被解析成1 - (2 - 3)得到2而不是-4。左递归在递归下降Parser里无法直接处理但可以改写成迭代循环每次看到同层运算符就继续消费右操作数并叠加到左树上。参数说明match(TokenType.PLUS, TokenType.MINUS)是变参方法它先检查current位置的Token类型是否匹配参数列表中的任意一个匹配则推进指针并返回trueprevious()返回上一个Token也就是本轮消费的运算符。这套玩法是所有递归下降Parser共通的模式。3.2 AST节点体系表达式和语句分开设计方便写VisitorAST节点分成两大类表达式有值语句没有值但会改变程序状态或控制流。把两者分开定义语义分析和代码生成的Visitor就不需要混在一起处理。public abstract class Expr { public abstract T T accept(ExprVisitorT visitor); } public abstract class Stmt { public abstract T T accept(StmtVisitorT visitor); }public class BinaryExpr extends Expr { public final Expr left; public final Token op; public final Expr right; public BinaryExpr(Expr left, Token op, Expr right) { this.left left; this.op op; this.right right; } Override public T T accept(ExprVisitorT visitor) { return visitor.visitBinaryExpr(this); } } public class VarDeclStmt extends Stmt { public final Token type; public final Token name; public final Expr initializer; public VarDeclStmt(Token type, Token name, Expr initializer) { this.type type; this.name name; this.initializer initializer; } Override public T T accept(StmtVisitorT visitor) { return visitor.visitVarDeclStmt(this); } }这里的选择理由是通过Visitor模式把“遍历AST”和“对AST做什么”解耦。语义分析器实现一套ExprVisitor和StmtVisitor代码生成器再实现一套两套逻辑互不干扰。如果你拿到的资源里只有AST节点没有Visitor接口建议自己补上后面写代码生成和AST打印调试都会省很多事。调试AST最实用的技巧是写一个toString递归方法打印树结构。我一般会在Expr和Stmt里加一个void print(int indent)方法输出类似BinaryExpr(, Literal(1), BinaryExpr(*, Literal(2), Literal(3)))的文本语义分析跑不通时先看AST长得对不对排除Parser的问题再往下查。3.3 符号表的作用域链define写当前层resolve逐层向上找语义分析阶段最重要的数据结构就是符号表。一个只装MapString, VarSymbol的表处理不了嵌套作用域必须设计成链式结构每个作用域指向它外层的作用域形成一条链。public class SymbolTable { private final SymbolTable parent; private final MapString, VarSymbol symbols new HashMap(); public SymbolTable(SymbolTable parent) { this.parent parent; } public void define(String name, VarSymbol symbol) { symbols.put(name, symbol); } public VarSymbol resolve(String name) { VarSymbol symbol symbols.get(name); if (symbol ! null) { return symbol; } if (parent ! null) { return parent.resolve(name); } return null; } }逻辑说明define永远只在当前作用域写入不碰外层。resolve先查当前层找不到就沿parent链逐层向上查询查到最外层仍没有则返回null。这个设计的精妙之处在于同名遮蔽自动生效——内层函数参数或局部变量与外层变量同名时resolve返回内层定义当Parser退出内层块作用域时只需要把当前符号表丢弃外层表仍然保留遮蔽关系自然解除。我在实际使用中会在define时做一次重复声明检查如果当前层已经存在同名符号直接报告“变量重定义”这是避免变量遮蔽逻辑变得混乱最简单的防线。另一个容易被忽略的点是函数体、循环体、普通代码块都必须新建子符号表哪怕这个块里没有声明任何变量也要建。否则后续在这个块里解析到变量时作用域链就会漏掉该有的嵌套关系。4. 编译器实现避坑5条高频故障与排查路径这个章节是血泪经验汇总。以下五条故障我几乎在每一个编译器实现项目里都会遇到按踩中频率排序每条都给出排查路径和修复方案。4.1 字符串字面量里的转义被提前消费现象输入源码a\\nb词法器输出字符串文本变成anb反斜杠和转义字符n被合并成了普通字符。原因readStringLiteral在词法阶段遇到反斜杠就直接跳过了反斜杠本身把下一个字符n当作普通字符追加导致\n变成n。解决词法阶段必须把反斜杠和下一个字符一起原样存入lexeme。正确做法见2.3节的实现——遇到\时先读下一个字符把两者拼接后存入StringBuilder等到语义分析或代码生成阶段再做真正的转义展开。4.2 表达式优先级翻车1 2 * 3算出9现象输入1 2 * 3语法分析后AST是(1 2) * 3解释器求值结果是9而不是7。原因Parser里只有一个parseExpression函数没有按优先级分层。所有运算符都在同一层处理乘法和加法没有区分先后。解决按优先级拆成assignment → equality → comparison → term → factor至少五层。term负责加减、factor负责乘除factor内的操作数由unary提供。修完这层后如果出现1 - 2 - 3变成1 - (2 - 3)说明运算符用了右递归而没用while循环继续检查3.1节的写法。4.3 注释识别和字符串识别互相污染现象源码里写String url http://example.com;词法器把//当成注释开头整个字符串后面内容全部被吃掉。原因skipWhitespaceAndComments在检测//时没有判断当前是否处于字符串扫描状态。词法器把所有场景都当成普通代码区处理。解决注释扫描和字符串扫描不能共用同一个状态。一种干净的做法是nextToken主流程里遇到/时先peek下一个字符只有当下一个字符也是/时才走注释逻辑否则直接按除号运算符处理字符串内的//由readStringLiteral单独消费不会回流到注释识别逻辑。4.4 变量遮蔽失效循环体修改了外层同名变量现象函数开头声明int i 0;循环体内再声明int i 100;循环结束后外层i的值变成了100遮蔽没有生效。原因解析循环体时没有创建新的SymbolTable整个函数体只用一个符号表。内层define直接覆盖了外层同名变量。解决语法解析器遇到左大括号进入块语句时创建一个new SymbolTable(currentTable)块内所有声明都写进新表遇到右大括号时把currentTable还原为parent。排查时在语义分析器里打印每次define前后的符号表深度很容易定位是哪个块没有建新作用域。4.5 报错定位偏移所有错误都指向第0行第1列现象语义错误信息永远显示0:1或者首行代码报错在对不上的行列位置。原因行号从0开始计数或者读取源码文件时没有跳过UTF-8 BOM头导致第一个Token的column计算错位。解决统一约定行号和列号都从1开始词法器里换行时line并重置column为1读文件时用Reader包装并显式指定UTF-8遇到BOM头手动跳过。排查编译器的所有报错路径确保错误信息统一从Token的line和column字段取值不自己另外算位置。注意如果你在改的编译器是用Java 8以下版本写的Token类不要用Java 16的record语法按2.2节的普通类写否则CI构建阶段就过不了。5. 验证编译器最快路径AST解释器加四类最小测试在动手写代码生成器或字节码发射器之前先花一小时写一个AST解释器。解释器实现起来比代码生成简单得多它直接把AST节点求值成结果不需要考虑寄存器分配、栈帧布局或指令编码。它的价值在于验证每个语法特性的语义是否正确时解释器是语义的参考实现——如果解释器计算结果都不对生成代码肯定也是错的如果解释器对了而生成代码不对问题一定出在代码生成器而不是语义分析。public class EvalExprVisitor implements ExprVisitorInteger { private final SymbolTable symbols; public EvalExprVisitor(SymbolTable symbols) { this.symbols symbols; } Override public Integer visitLiteralExpr(LiteralExpr expr) { return Integer.parseInt(expr.value); } Override public Integer visitBinaryExpr(BinaryExpr expr) { int left expr.left.accept(this); int right expr.right.accept(this); switch (expr.op.type) { case PLUS: return left right; case MINUS: return left - right; case STAR: return left * right; case SLASH: return left / right; default: throw new RuntimeException(不支持的二元运算符: expr.op.lexeme); } } Override public Integer visitVarExpr(VarExpr expr) { VarSymbol symbol symbols.resolve(expr.name.lexeme); if (symbol null) { throw new RuntimeException(未定义的变量: expr.name.lexeme); } return (Integer) symbol.value; } }参数说明visitLiteralExpr处理数字字面量visitBinaryExpr按运算符分别求值visitVarExpr通过符号表resolve找出变量当前值。这里把符号表塞进Visitor里作为状态持有者语义分析和代码生成阶段都沿用同一个SymbolTable实例保证变量解析路径一致。配套的最小测试集我固定为四类每一类都对应一种最常见故障测试类别典型输入能暴露的问题常量表达式1 2 * 3;优先级、二元运算符求值变量声明与引用int a 5; int b a 1;符号表写入与查找嵌套块作用域int x 1; { int x 2; } return x;遮蔽与作用域出栈错误输入(1 2;或abc错误报错位置与恢复跑通这四类用例后再开始写代码生成器不管是生成JVM字节码还是生成Java源码都先用这四类用例做回归。从那以后我每次拆编译器源码都强制先用这个最小测试集过一遍再动手改词法器。遇到玄学问题先怀疑状态机没复位其次怀疑符号表作用域没有正确出栈——这两个位置占了九成故障。这份资源把编译器的各个模块都摆在明处照着这个路径拆一遍比自己从零敲一个Lexer加Parser省太多时间希望帮到你。本文还有配套的精品资源点击获取
RELATED

相关推荐

为AI应用构建可扩展、智能体友好的API:从设计到落地

为AI应用构建可扩展、智能体友好的API:从设计到落地

1. 从单体接口到智能体协作:为什么传统API撑不住AI应用了 过去两年我经手过不少AI应用的后端改造项目,从智能客服到自动化数据分析流水线,几乎每一个都踩过同一个坑: 用传统RESTful接口去对接大模型和智能体,前期跑得…

📅 2026/10/12 13:54:01
C语言变量、常量与指针深度解析:理解内存底层逻辑与编程实践

C语言变量、常量与指针深度解析:理解内存底层逻辑与编程实践

看到这个标题,估计不少刚入门的朋友会觉得“这不就是基础语法嘛”,也有工作几年的老哥们心里犯嘀咕:变量、常量谁不会?但指针这东西,真的是C语言里绕不过去的坎,也是面试时最喜欢深挖的点。我见过太多人能把…

📅 2026/10/12 13:54:01
游戏引擎基础系统搭建:内存管理与容器设计实战

游戏引擎基础系统搭建:内存管理与容器设计实战

1. 引擎基础系统到底在搭什么架子很多人第一次翻引擎源码,看到一堆Core、Foundation、Base之类的目录就头大,觉得这些代码跟“把画面渲染出来”这件事八竿子打不着。但真做过项目的人都知道,一个引擎能不能扛住长期迭代,八成取决于…

📅 2026/10/12 13:48:59
MORE NEWS

更多资讯

📰

计算机网络实验全链路:从Boson Netsim拓扑到SOCKET编程实战

简介:这份华中科技大学计算机学院《计算机网络》实验报告,面向高校计算机专业学生及网络工程初学者,帮助读者系统掌握网络组建与协议配置的实践方法。报告围绕IP协议、网络层与数据链路层协议、子网划分、RIP与OSPF路由协议、路由器及二/三层…

📰

基于Python的微博舆情分析可视化系统:爬虫、情感分析与展示全流程

简介:基于Python的微博舆情分析可视化系统,是一套面向毕业设计、课程设计与期末大作业的完整项目源码。整合爬虫采集、情感分析与可视化展示三大模块,适合计算机专业学生作为高分毕设参考。代码带有详细注释,新手也能较快上手&…

📰

近红外光谱回归建模:物理约束驱动的深度学习方法

简介:本资源是一套面向计算机专业本科生的毕业设计与课程作业级深度学习实践项目,聚焦近红外光谱(NIR)数据的回归建模任务,适用于化学分析、食品检测、生物医学等交叉领域中的成分定量预测场景。压缩包共9个文件&#…

📰

开题报告写作新思路:千笔与灵感风暴AI的分工协作指南

开题这件事,我见过太多种“卡法”。有人卡在“这个题值不值得做”,坐了一下午连题目方向都定不下来;有人卡在文献综述,读了二三十篇摘要,落到Word里还是写不出三句人话;还有人卡得最冤——初稿都交了&#…

📰

网红罗盘时钟:物理+代码+光影的时间装置入门指南

1. 这个“网红时钟罗盘”到底在火什么?——从零理解它的底层逻辑与真实价值你刷短视频时,有没有被那种悬浮在空中的金属圆盘吸引过?它缓缓旋转,指针不是指向数字,而是嵌着细小的指南针、经纬度刻线,甚至微缩…

📰

Advanced Installer 20.7.1:打造干净可复现的MSI安装包与升级链路

简介:Advanced Installer 20.7.1 是一款面向软件开发者与打包工程师的 Windows 安装包制作工具,专注于生成符合 MS Windows 认证的 MSI 安装文件,同时支持自定义欢迎界面与安装过程界面,适合需要规范化发布桌面应用的团队或个人使…

TODAY

今日更新

THIS WEEK

本周精选

THIS MONTH

本月热门

读完文章,想聊聊您的网站?

告诉我们您的行业与需求,资深顾问一对一梳理方案与报价,全程免费。

📞 💬