资讯动态

手写语义分析器:基于AST遍历与符号表的静态检查实现

发布时间:2026/10/18 5:10:24 来源:尧图企业网站定制
简介本资源是面向计算机专业本科生与编译原理初学者的语义分析实践项目聚焦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实为序列化HashMapString, Symbol的二进制文件缓存作用域快照。它回避了现代编译器框架的抽象层选择用最直白的方式暴露语义分析的核心契约语法树必须可遍历符号必须可查表类型必须可比较。下面拆解它的物理结构和设计意图。2.1 文件清单与职责映射.class不是黑匣子是模块切片文件名类型核心职责关键依赖Main.class入口类加载源文件 → 调用Lexer分词 → 构建 AST → 启动SemanticAnalyzer遍历Lexer,Parser,SemanticAnalyzerLexer.class词法分析器按字符流扫描识别IDENTIFIER/INT_LITERAL/ASSIGN等 Token跳过空白与注释Token.class,Word.classToken.classToken 封装存type枚举、lexeme原始字符串、lineNo行号是 AST 节点的叶子Word.class关键字字典Parser.class递归下降语法分析器实现parseProgram()→parseBlock()→parseStatement()返回ASTNode树根ASTNode.class隐含在Parser内部未单独编译SemanticAnalyzer.class语义分析核心持有SymbolTable遍历 AST 时① 遇VarDecl插入符号② 遇Identifier查表③ 遇BinaryExpr调用TypeCheckerSymbolTable.class,TypeChecker.classSymbolTable.class符号表实现基于StackHashMapString, Symbol实现嵌套作用域enterScope()/exitScope()控制生命周期Symbol.class字段name,type,isInitialized,declLinevariablesAndContainers.dat初始符号数据序列化后的HashMapString, 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.cint 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.HashMap1b6d3586, [1] java.util.HashMap6a024a29 }[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原因该文件是HashMapString, 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_LITERALlexeme为123浮点字面量TokenType.FLOAT_LITERALlexeme为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.cint 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()中用MapString, UsageFlags记录每个变量的使用状态。步骤 1定义使用标志枚举// 在 SemanticAnalyzer 内部类 private static class UsageFlags { boolean declared false; boolean assigned false; // 被赋值左值 boolean referenced false; // 被读取右值 }步骤 2在遍历中更新标志private MapString, 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.EntryString, 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% 的“逻辑写了却没执行”的玄学问题。编译器开发没有捷径只有把每一步的输入输出钉死才能让黑匣子变成透明管道。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑