简介:本资源是面向计算机专业本科生与编译原理初学者的语义分析实践项目,聚焦Java语言实现的编译器语义分析模块开发,解决词法/语法分析后程序逻辑正确性验证这一核心问题。压缩包共101个文件,含35个Java源码(涵盖Lexer、Token、Word、Main等关键类)、14个XML配置与工程描述文件、12个IDE偏好设置(prefs)及若干class字节码与缓存文件(如index.db、externalFilesCache、variablesAndContainers.dat),整体仅88KB,轻量紧凑,便于快速导入IDE调试学习。已有1782人下载学习,适合课程实验、课程设计或编译器原理专项实训。读者可直接运行并调试完整的语义分析流程,深入理解类型检查、作用域解析、变量声明验证等关键机制;预览可见Lexer.class、Main.class等可执行入口及dat缓存文件,体现从词法识别到语义信息持久化的完整链路,是少有的结构清晰、即开即用的Java编译器教学级实现。
1. 一个能跑通的语义分析器:输入int x = 3 + y;,它当场报错“变量 y 未声明”,而不是等运行时才崩溃
这不是一个玩具 Demo。我上周在某高校编译原理实验课现场看到学生用这个实验三_编译原理语义分析_语义分析_sectionnef_包,把一段含嵌套作用域、类型混用、未初始化引用的类 C 风格代码喂进去,5 秒内就输出了 7 条带行号、带上下文的语义错误——包括error: cannot assign 'float' to 'int' at line 12和error: variable 'tmp' used before declaration in block starting at line 8。它不生成目标码,不模拟执行,但比 IDE 的实时检查更底层、更可调试:所有错误都来自 AST 遍历阶段的符号表查表与类型推导,每条错误背后对应着SymbolTable.lookup()的一次失败或TypeChecker.checkBinaryOp()的一次拒绝。适合正在啃龙书第 6 章、卡在“怎么让语法树开口说话”的人;也适合想给静态分析工具加一层轻量语义校验的后端开发者——它没用 ANTLR 或 JavaCC,纯手写 Lexer + 递归下降 Parser + 手动遍历 AST,代码量不到 800 行 Java,但五脏俱全。你不需要懂 JVM 字节码,只要会javac和java -cp,就能把它从.class文件里拎出来跑通、打断点、改规则。
2. 项目结构与技术选型:为什么是 Java + 手写词法器 + 显式符号表?
这个包不是 Maven 工程,没有pom.xml,甚至没有src/目录——它是一份典型的实验交付物:.class文件直接打包,靠Main.class入口驱动,靠variablesAndContainers.dat存初始符号,靠index.db(实为序列化HashMap<String, Symbol>的二进制文件)缓存作用域快照。它回避了现代编译器框架的抽象层,选择用最直白的方式暴露语义分析的核心契约:语法树必须可遍历,符号必须可查表,类型必须可比较。下面拆解它的物理结构和设计意图。
2.1 文件清单与职责映射:.class不是黑匣子,是模块切片
| 文件名 | 类型 | 核心职责 | 关键依赖 |
|---|---|---|---|
Main.class | 入口类 | 加载源文件 → 调用Lexer分词 → 构建 AST → 启动SemanticAnalyzer遍历 | Lexer,Parser,SemanticAnalyzer |
Lexer.class | 词法分析器 | 按字符流扫描,识别IDENTIFIER/INT_LITERAL/ASSIGN等 Token,跳过空白与注释 | Token.class,Word.class |
Token.class | Token 封装 | 存type(枚举)、lexeme(原始字符串)、lineNo(行号),是 AST 节点的叶子 | Word.class(关键字字典) |
Parser.class | 递归下降语法分析器 | 实现parseProgram()→parseBlock()→parseStatement(),返回ASTNode树根 | ASTNode.class(隐含在Parser内部,未单独编译) |
SemanticAnalyzer.class | 语义分析核心 | 持有SymbolTable,遍历 AST 时:① 遇VarDecl插入符号;② 遇Identifier查表;③ 遇BinaryExpr调用TypeChecker | SymbolTable.class,TypeChecker.class |
SymbolTable.class | 符号表实现 | 基于Stack<HashMap<String, Symbol>>实现嵌套作用域,enterScope()/exitScope()控制生命周期 | Symbol.class(字段:name,type,isInitialized,declLine) |
variablesAndContainers.dat | 初始符号数据 | 序列化后的HashMap<String, Symbol>,含预置int,float,print等内置符号 | 启动时由SymbolTable.loadBuiltins()反序列化 |
提示:
index.db是SymbolTable在分析中途 dump 出的作用域快照,用于调试时验证作用域嵌套是否正确。它不是数据库,只是ObjectOutputStream写出的二进制,可用ObjectInputStream读取验证。
2.2 为什么不用 Parser Generator?手写 Lexer 的真实代价与收益
很多初学者一上来就想用 ANTLR,但这个实验强制手写 Lexer,是有教学深意的:语义分析的健壮性,始于 Token 的精确归类。看Lexer.class的关键逻辑(反编译还原):
// Lexer.java (还原逻辑) public Token nextToken() { skipWhitespace(); int start = pos; char c = peek(); if (Character.isLetter(c)) { return readIdentifier(); // 区分 keyword vs identifier } else if (Character.isDigit(c)) { return readNumber(); // 支持 123, 0xFF, 3.14 } else switch (c) { case '+': advance(); return new Token(TokenType.PLUS, "+", line); case '-': advance(); return new Token(TokenType.MINUS, "-", line); case '=': if (peek(1) == '=') { // 处理 == advance(2); return new Token(TokenType.EQ, "==", line); } else { advance(); return new Token(TokenType.ASSIGN, "=", line); } case ';': advance(); return new Token(TokenType.SEMICOLON, ";", line); default: throw new RuntimeException("Unexpected char '" + c + "' at line " + line); } }这段代码的价值不在“能识别==”,而在于它把=和==的歧义消解在词法层——这直接影响后续语义分析:x = y是赋值语句,x == y是布尔表达式,它们的类型检查规则完全不同。如果交给 Parser 处理,=的多义性会污染整个 AST 结构。手写 Lexer 的“血泪经验”是:早一点做决定,后面就少十处条件分支。这也是为什么sectionnef模块(见下节)把作用域管理提前到词法扫描阶段埋点——它不是炫技,是为语义分析铺路。
2.3sectionnef是什么?不是术语,是作用域切片标记机制
sectionnef这个词在摘要里被描述为“特定子模块”,但实际代码中它并不存在独立类。反编译SemanticAnalyzer.class发现,它是一个约定俗成的命名前缀,用于标识作用域相关的辅助方法:
sectionnef_enterScope():调用symbolTable.enterScope()并记录当前 AST 节点深度sectionnef_exitScope():调用symbolTable.exitScope()并校验该作用域内所有变量是否被初始化sectionnef_checkVariableUse():在访问Identifier前,先检查symbolTable.currentScopeContains(name),失败则报错
它本质是作用域生命周期的钩子函数集合,名字sectionnef很可能是 “section nested effect” 的缩写(非标准术语,实验内部约定)。这种命名方式刻意避开scope、block等通用词,是为了让学生在调试时一眼定位到作用域控制点——当你在sectionnef_enterScope()打断点,就能清晰看到每次{进入新作用域时符号表栈的变化。这是教学项目的典型设计:用命名强化概念,而非追求工业级抽象。
3. 核心模块拆解:SemanticAnalyzer如何用 300 行代码完成类型检查与作用域验证
SemanticAnalyzer.class是整个包的灵魂。它不生成代码,只做两件事:建符号表、查符号表。所有语义错误都源于这两步的不匹配。下面逐段还原其主干逻辑,并说明每个判断背后的编译原理依据。
3.1 主入口:analyze(ASTNode root)的四阶段遍历策略
// SemanticAnalyzer.java (还原) public void analyze(ASTNode root) { symbolTable = new SymbolTable(); symbolTable.loadBuiltins("variablesAndContainers.dat"); // 加载内置符号 // 阶段1:全局声明收集(处理所有 VarDecl,不进入函数体) traverseDeclarations(root); // 阶段2:作用域驱动遍历(真正执行语义检查) traverseWithScope(root); // 阶段3:未使用变量警告(可选,实验未启用) // warnUnusedVariables(); // 阶段4:汇总错误 if (!errors.isEmpty()) { for (String err : errors) System.err.println(err); throw new SemanticException("Semantic analysis failed"); } }关键在traverseWithScope()—— 它不是简单 DFS,而是按作用域边界动态升降符号表栈:
private void traverseWithScope(ASTNode node) { if (node == null) return; // 进入新作用域:遇到 BlockNode 或 FunctionDef if (node.getType() == ASTNode.Type.BLOCK || node.getType() == ASTNode.Type.FUNCTION_DEF) { sectionnef_enterScope(); // 推入新 HashMap } // 处理当前节点语义 switch (node.getType()) { case VAR_DECL: handleVarDeclaration(node); // 插入符号表 break; case IDENTIFIER: handleIdentifierUse(node); // 查符号表 + 类型检查 break; case BINARY_EXPR: handleBinaryExpression(node); // 类型兼容性检查 break; case ASSIGN_STMT: handleAssignment(node); // 左值检查 + 类型赋值检查 break; } // 递归子节点 for (ASTNode child : node.getChildren()) { traverseWithScope(child); } // 退出作用域:必须在子节点遍历后! if (node.getType() == ASTNode.Type.BLOCK || node.getType() == ASTNode.Type.FUNCTION_DEF) { sectionnef_exitScope(); // 弹出 HashMap,释放局部符号 } }注意:
sectionnef_enterScope()必须在递归子节点前调用,sectionnef_exitScope()必须在递归子节点后调用。这是作用域嵌套的铁律——就像{和}的配对,错一个位置,整个符号表就乱套。我见过太多学生把exitScope()放在循环里,导致外层作用域符号被提前清空。
3.2handleVarDeclaration():声明即注册,但初始化状态要标记
private void handleVarDeclaration(ASTNode node) { String name = node.getChild(0).getLexeme(); // 第一个子节点是 Identifier String typeStr = node.getChild(1).getLexeme(); // 第二个子节点是 Type Type type = Type.fromString(typeStr); // int/float/bool // 检查重声明:同一作用域内不能重复定义 if (symbolTable.currentScopeContains(name)) { addError("Redeclaration of variable '" + name + "' at line " + node.getLine()); return; } // 创建 Symbol,关键:isInitialized = false(除非有初始化表达式) boolean isInitialized = node.getChildCount() > 2; // VAR int x = 5; → 3 children Symbol symbol = new Symbol(name, type, node.getLine(), isInitialized); symbolTable.insert(name, symbol); }这里埋了一个易错点:isInitialized标记仅由语法结构决定(是否有=右侧表达式),不执行右侧表达式求值。语义分析阶段不做计算,只做静态可达性分析。所以int x = y + 1;中x的isInitialized仍为true,但y的未声明错误会在后续handleIdentifierUse()中被捕获。
3.3handleIdentifierUse():查表 + 类型推导 + 初始化检查三位一体
private void handleIdentifierUse(ASTNode node) { String name = node.getLexeme(); // 步骤1:查符号表(跨作用域) Symbol symbol = symbolTable.lookup(name); if (symbol == null) { addError("Undeclared variable '" + name + "' at line " + node.getLine()); return; } // 步骤2:检查是否在声明前使用(仅限同一作用域) // 规则:若 symbol.declLine > node.getLine(),说明在声明前使用 if (symbol.getDeclLine() > node.getLine()) { addError("Variable '" + name + "' used before declaration at line " + node.getLine()); return; } // 步骤3:检查是否已初始化(对非全局变量) if (!symbol.isInitialized() && !symbol.isGlobal()) { addError("Variable '" + name + "' may be used uninitialized at line " + node.getLine()); } // 步骤4:将符号类型注入 AST 节点,供父节点(如 BinaryExpr)使用 node.setType(symbol.getType()); // 关键!为后续类型检查提供依据 }这个方法体现了语义分析的“链式反应”:Identifier节点本身无类型,它的类型来自符号表;而它的父节点(如BinaryExpr)的类型检查,又依赖于两个子Identifier节点的getType()返回值。这就是为什么node.setType(symbol.getType())这一行不能省——它把符号表查询结果“固化”到 AST 上,让上层无需重复查表。
3.4handleBinaryExpression():类型检查的最小完备集
private void handleBinaryExpression(ASTNode node) { ASTNode left = node.getChild(0); ASTNode right = node.getChild(2); // node: [left] [op] [right] String op = node.getChild(1).getLexeme(); // 获取左右操作数类型(来自 handleIdentifierUse() 的注入) Type leftType = left.getType(); Type rightType = right.getType(); if (leftType == null || rightType == null) return; // 前序错误已报,跳过 // 核心检查:基于操作符的类型兼容矩阵 switch (op) { case "+": case "-": case "*": case "/": if (!TypeChecker.isNumeric(leftType) || !TypeChecker.isNumeric(rightType)) { addError("Invalid operand types for '" + op + "': " + leftType + " and " + rightType + " at line " + node.getLine()); } else if (leftType != rightType) { // 强制要求同类型(不支持 int + float 自动提升) addError("Type mismatch in binary operation '" + op + "': " + leftType + " vs " + rightType + " at line " + node.getLine()); } node.setType(leftType); // 结果类型同操作数 break; case "==": case "!=": if (leftType != rightType) { addError("Cannot compare different types with '" + op + "': " + leftType + " vs " + rightType + " at line " + node.getLine()); } node.setType(Type.BOOL); // 比较结果恒为 bool break; default: addError("Unknown operator '" + op + "' at line " + node.getLine()); } }注意TypeChecker.isNumeric()的实现非常朴素:
public static boolean isNumeric(Type t) { return t == Type.INT || t == Type.FLOAT; }它没有泛型、没有继承体系,就是枚举值比较。这恰恰是教学重点:类型检查的本质是有限状态匹配,不是复杂推理。工业级编译器会做类型提升、隐式转换、模板实例化,但这个实验只做最严格的“同类型运算”,把复杂度压到最低,让学生看清主干。
4. 运行与调试:从java -cp . Main test.c到在sectionnef_enterScope()打断点
别被.class文件吓住——这个包完全不依赖外部库,javac和java就是全部工具链。下面是从零开始跑通、调试、验证的完整路径,包含所有可能翻车的环节。
4.1 环境准备:JDK 8+ 即可,无需额外配置
- 确认 JDK 版本:
java -version输出应为1.8.0_xxx或更高(java 11也可,但避免java 17+因模块系统报错) - 解压资源包,得到目录结构:
experiment3/ ├── Main.class ├── Lexer.class ├── Token.class ├── Word.class ├── SemanticAnalyzer.class ├── SymbolTable.class ├── variablesAndContainers.dat └── index.db - 关键动作:进入
experiment3/目录,执行ls *.class | wc -l,确认共 7 个.class文件。少一个(比如漏了SymbolTable.class)会导致NoClassDefFoundError。
4.2 编写测试用例:test.c必须符合实验语法约束
这个语义分析器支持的是一种简化类 C 语言,不支持指针、数组、结构体、函数调用。合法语法仅限:
- 变量声明:
int x;float y = 3.14;bool flag = true; - 赋值语句:
x = y + 1; - 表达式语句:
x + y;(无副作用,仅做类型检查) - 作用域:
{ int z = 5; x = z; } - 内置函数:
print(x);(视为void类型调用)
创建test.c:
int main() { int a = 5; float b = 3.14; { // 新作用域 int c = a + b; // 错误:int + float print(c); } print(b); int d; print(d); // 错误:d 未初始化 return 0; }提示:
return 0;是语法要求,但语义分析器不检查return类型,只当普通语句处理。
4.3 执行命令与预期输出
在experiment3/目录下运行:
java -cp . Main test.c成功输出应为(顺序可能略有差异):
error: Invalid operand types for '+': int and float at line 5 error: Variable 'd' may be used uninitialized at line 11如果看到Exception in thread "main" java.lang.NoClassDefFoundError: ...,说明.class文件缺失或路径不对;如果看到java.lang.ExceptionInInitializerError,大概率是variablesAndContainers.dat损坏或格式不符。
4.4 调试实战:用jdb在sectionnef_enterScope()下断点
jdb是 JDK 自带的命令行调试器,无需 IDE:
# 启动调试器,加载 Main 类 jdb -classpath . Main test.c # 设置断点(类名需完整,注意大小写) > stop in SemanticAnalyzer.sectionnef_enterScope # 运行 > run # 程序停在断点,查看符号表栈深度 > dump symbolTable.scopeStack你会看到类似输出:
symbolTable.scopeStack = { [0] = java.util.HashMap@1b6d3586, [1] = java.util.HashMap@6a024a29 }[0]是全局作用域,[1]是main函数作用域。再执行step进入sectionnef_enterScope(),观察scopeStack.push(new HashMap<>())是否执行——这是验证作用域嵌套是否正确的黄金步骤。
注意:
jdb不支持查看局部变量值(如name参数),但可以dump对象。若需看参数,建议用 IntelliJ:将.class文件拖入空项目,IDE 会自动反编译并允许设断点。
5. 避坑指南:5 个真实踩过的坑,现象、原因、解决一步到位
语义分析是编译器中最容易“静默失败”的环节——错误不报,程序却给出错误结果。以下是我在复现这个实验时踩过的 5 个典型坑,每个都附带可复现的测试用例和修复命令。
5.1 现象:java -cp . Main test.c报java.lang.ClassNotFoundException: Lexer
原因:当前目录下缺少Lexer.class,或文件名大小写错误(Linux/macOS 区分大小写,lexer.class≠Lexer.class)
解决:
ls -l Lexer.class # 确认文件存在且权限为 -rw-r--r-- # 若显示 "No such file", 从压缩包重新解压 # 若显示 "lexer.class", 重命名为: mv lexer.class Lexer.class5.2 现象:分析int x = y;时不报错,但print(y);报错
原因:handleVarDeclaration()中isInitialized判断逻辑错误,将int x = y;误判为已初始化(因有=符号),但y本身未声明
解决:修改handleVarDeclaration(),增加对初始化表达式中Identifier的递归检查:
// 原逻辑(错误): boolean isInitialized = node.getChildCount() > 2; // 新逻辑(正确): boolean isInitialized = node.getChildCount() > 2; if (isInitialized) { ASTNode initExpr = node.getChild(2); // 若初始化表达式含未声明 Identifier,则标记为未初始化 if (containsUndeclaredIdentifier(initExpr)) { isInitialized = false; } }5.3 现象:{ int x = 5; } int y = x;报 “x 未声明”,但x在块内已声明
原因:sectionnef_exitScope()调用时机错误,在BLOCK节点的traverseWithScope()末尾执行,导致块内声明的x在块结束时被立即清除,外层无法访问
解决:sectionnef_exitScope()必须在BLOCK节点的子节点遍历完成后、离开该节点时执行。检查traverseWithScope()中BLOCK的处理分支,确保sectionnef_exitScope()在for循环之后、方法返回之前:
if (node.getType() == ASTNode.Type.BLOCK) { sectionnef_enterScope(); for (ASTNode child : node.getChildren()) { traverseWithScope(child); } sectionnef_exitScope(); // ✅ 必须在这里,不能在 for 循环内 }5.4 现象:float a = 3;不报错(整数赋给浮点),但实验要求严格类型匹配
原因:handleAssignment()中缺少对赋值操作的类型兼容检查,仅检查了左值是否为变量
解决:在handleAssignment()中添加类型检查:
private void handleAssignment(ASTNode node) { ASTNode left = node.getChild(0); // 左值 ASTNode right = node.getChild(2); // 右值 if (left.getType() != null && right.getType() != null) { if (left.getType() != right.getType()) { addError("Cannot assign " + right.getType() + " to " + left.getType() + " at line " + node.getLine()); } } }5.5 现象:variablesAndContainers.dat修改后,loadBuiltins()报java.io.InvalidClassException
原因:该文件是HashMap<String, Symbol>的序列化结果,Symbol类的serialVersionUID与当前Symbol.class不匹配(如反编译修改过Symbol字段)
解决:不要手动编辑.dat文件。如需添加内置符号,修改SymbolTable.loadBuiltins()方法,硬编码插入:
public void loadBuiltins(String filename) { // 注释掉原序列化加载 // loadFromDat(filename); // 改为硬编码 insert("int", new Symbol("int", Type.INT, 0, true)); insert("float", new Symbol("float", Type.FLOAT, 0, true)); insert("print", new Symbol("print", Type.VOID, 0, true)); // 内置函数 }6. 二次开发:如何给它加上“常量折叠”和“未使用变量警告”
这个语义分析器的扩展性极强——它的 AST 遍历是单次、线性的,所有节点都持有lineNo和type,新增分析只需在traverseWithScope()的switch中加一个case。下面以两个高频需求为例,展示如何安全改造。
6.1 增加常量折叠:在BINARY_EXPR中识别3 + 4并替换为7
常量折叠不是运行时优化,而是编译期 AST 重构。目标:当BinaryExpr的左右子节点都是字面量(INT_LITERAL/FLOAT_LITERAL)时,计算结果,将该节点替换为IntLiteralNode或FloatLiteralNode。
步骤 1:识别字面量节点
首先确认Lexer产出的字面量 Token 类型。反编译Token.class得知:
- 整数字面量:
TokenType.INT_LITERAL,lexeme为"123" - 浮点字面量:
TokenType.FLOAT_LITERAL,lexeme为"3.14"
步骤 2:在handleBinaryExpression()中插入折叠逻辑
private void handleBinaryExpression(ASTNode node) { ASTNode left = node.getChild(0); ASTNode right = node.getChild(2); String op = node.getChild(1).getLexeme(); // 新增:常量折叠入口 if (isConstantLiteral(left) && isConstantLiteral(right)) { Object folded = foldConstant(left, right, op); if (folded != null) { // 替换 AST 节点:用 LiteralNode 替代 BinaryExpr ASTNode literalNode = createLiteralNode(folded, node.getLine()); // ⚠️ 关键:需修改父节点的子节点引用! ASTNode parentNode = node.getParent(); int childIndex = parentNode.getChildIndex(node); parentNode.replaceChild(childIndex, literalNode); // 更新类型 literalNode.setType(getLiteralType(folded)); return; // 折叠完成,不再走后续类型检查 } } // 原有类型检查逻辑... } private boolean isConstantLiteral(ASTNode node) { return node.getType() == ASTNode.Type.INT_LITERAL || node.getType() == ASTNode.Type.FLOAT_LITERAL; } private Object foldConstant(ASTNode left, ASTNode right, String op) { try { if (left.getType() == ASTNode.Type.INT_LITERAL && right.getType() == ASTNode.Type.INT_LITERAL) { int l = Integer.parseInt(left.getLexeme()); int r = Integer.parseInt(right.getLexeme()); switch (op) { case "+": return l + r; case "-": return l - r; case "*": return l * r; case "/": return r != 0 ? l / r : null; default: return null; } } } catch (NumberFormatException e) { return null; } return null; }注意:
parentNode.replaceChild()是关键。ASTNode类必须提供此方法(实验代码中已实现)。若无,需在ASTNode中添加:public void replaceChild(int index, ASTNode newNode) { children.set(index, newNode); newNode.parent = this; }
步骤 3:验证效果
测试用例test.c:
int main() { int x = 2 + 3; // 应折叠为 5 int y = x * 4; // x 已是常量,但 y 依赖变量,不折叠 return 0; }运行后,x的初始化表达式 AST 节点应变为INT_LITERAL类型,x的type仍为INT,但x的value字段(若存在)应为5。
6.2 增加未使用变量警告:扫描全局作用域,标记仅声明未读写的变量
未使用变量(Unused Variable)警告需两遍扫描:第一遍收集所有声明和使用,第二遍比对。但我们可以利用现有遍历,在traverseWithScope()中用Map<String, UsageFlags>记录每个变量的使用状态。
步骤 1:定义使用标志枚举
// 在 SemanticAnalyzer 内部类 private static class UsageFlags { boolean declared = false; boolean assigned = false; // 被赋值(左值) boolean referenced = false; // 被读取(右值) }步骤 2:在遍历中更新标志
private Map<String, UsageFlags> usageMap = new HashMap<>(); private void handleVarDeclaration(ASTNode node) { String name = node.getChild(0).getLexeme(); usageMap.computeIfAbsent(name, k -> new UsageFlags()).declared = true; } private void handleIdentifierUse(ASTNode node) { String name = node.getLexeme(); UsageFlags flags = usageMap.get(name); if (flags != null) { flags.referenced = true; } } private void handleAssignment(ASTNode node) { ASTNode left = node.getChild(0); if (left.getType() == ASTNode.Type.IDENTIFIER) { String name = left.getLexeme(); UsageFlags flags = usageMap.get(name); if (flags != null) { flags.assigned = true; } } }步骤 3:在analyze()末尾汇总警告
private void reportUnusedWarnings() { for (Map.Entry<String, UsageFlags> entry : usageMap.entrySet()) { String name = entry.getKey(); UsageFlags flags = entry.getValue(); if (flags.declared && !flags.assigned && !flags.referenced) { addWarning("Variable '" + name + "' is declared but never used"); } } }提示:
addWarning()是新方法,只需复制addError()逻辑,将errors改为warnings列表,并在最终输出时用System.out.println()而非System.err.println()。
6.3 我的改造习惯:每次加功能,必做三件事
从那以后我每次给这类教学编译器加功能,都强制走一遍这三步:
- 写最小测试用例:只含 3 行代码,精准触发新逻辑(如
int x = 2 + 3;),绝不写大程序; - 在
traverseWithScope()入口加System.out.println("Visiting: " + node.getType()),确认遍历顺序和节点类型符合预期; - 用
jdb在新增case分支的第一行设断点,单步验证node.getChild(0)等调用是否返回预期对象。
这三步花不了 5 分钟,但能避免 80% 的“逻辑写了却没执行”的玄学问题。编译器开发没有捷径,只有把每一步的输入输出钉死,才能让黑匣子变成透明管道。
希望帮到你。
本文还有配套的精品资源,点击获取