编译原理实验三:语义分析中符号表、作用域与类型检查的Java实现
简介这份资源是编译原理课程实验三的语义分析实现包面向正在学习编译原理、需要动手完成编译器前端实验的高校学生与自学者。它聚焦词法分析与语法分析之后的语义检查环节帮助理解类型匹配、作用域解析、未声明变量检测等核心问题适合作为课程作业参考或编译器入门练手项目。压缩包共101个文件约88KB以35个java源码为主体配合14个xml配置、若干class字节码、prefs与lock等IDE工程文件以及zip、txt、json等辅助内容完整保留了Java工程的目录结构与构建痕迹。目前已有1781人学习下载热度较高。通过阅读源码读者可以梳理从词法分析器、语法解析到语义验证的完整链路掌握AST构建与语义规则校验的实现思路并借鉴其中的排错与调试经验为后续代码生成或静态分析工具开发打下基础。1. 语义分析到底在查什么从sectionnef这个实验代号说起编译原理实验三通常卡在语法分析和代码生成之间标题里的sectionnef大概率是实验框架里某个节区section的标识符也可能是符号表里用来标记嵌套作用域的一个内部名字。不管它具体指什么语义分析要干的事就一件语法树已经建好了但树上那些名字到底指谁、类型对不对、作用域有没有越界语法阶段一概不管全靠语义分析来兜底。很多同学做实验三时觉得“能跑就行”结果一到代码生成就发现变量引用错了行号、类型对不上、作用域串了层回头改语义分析比从头写还痛苦。这篇笔记面向正在做编译原理实验三、被sectionnef这类符号表和作用域问题卡住的人把语义分析从设计到落地的路径拆开讲清楚顺带把 Java 实现里最容易翻车的几个点标出来。2. 语义分析的核心机制符号表、作用域与类型检查怎么串起来2.1 符号表不是一张表而是一组按作用域嵌套的栈式结构语义分析最核心的数据结构就是符号表。很多实验指导书只给一个SymbolTable类里面塞一个HashMap这在单层作用域下能跑一旦遇到函数嵌套、块嵌套就立刻崩。正确的做法是把符号表设计成栈式结构每进入一个作用域函数体、if块、while块、for循环体就压入一层新的HashMap离开时弹出。查找变量时从栈顶往下逐层找找到第一个匹配就停这就是最朴素的作用域规则。用 Java 写的话核心结构大概长这样public class ScopeStack { // 用 Deque 当栈栈顶是当前最内层作用域 private DequeMapString, Symbol scopes new ArrayDeque(); // 进入新作用域压入一个空 map public void enterScope() { scopes.push(new HashMap()); } // 离开当前作用域弹出栈顶 public void exitScope() { if (scopes.isEmpty()) { throw new IllegalStateException(作用域栈已空exitScope 调用次数过多); } scopes.pop(); } // 声明符号只往当前最内层作用域放 public boolean declare(Symbol sym) { MapString, Symbol current scopes.peek(); if (current.containsKey(sym.getName())) { return false; // 同一作用域内重复声明 } current.put(sym.getName(), sym); return true; } // 查找符号从栈顶往下找实现内层遮蔽外层 public Symbol lookup(String name) { for (MapString, Symbol scope : scopes) { Symbol s scope.get(name); if (s ! null) return s; } return null; // 未声明就使用 } }这段代码里enterScope和exitScope必须严格配对declare只写当前层lookup从栈顶往下遍历。参数上唯一要注意的是Deque的迭代顺序ArrayDeque作为栈使用时push是压栈顶迭代时从栈顶开始正好符合“内层优先”的查找逻辑。如果你用ArrayList手动维护记得查找时从size()-1往0走别写反了。2.2 类型检查要在语法树遍历时同步做别等遍历完再补类型检查的时机很关键。常见错误做法是先遍历一遍语法树建符号表再遍历一遍做类型检查结果第二遍遍历时作用域信息已经丢了只能靠节点上挂的临时属性硬撑代码又乱又容易错。正确做法是在同一次递归下降或树遍历中进入节点时处理声明、离开节点时做类型校验符号表栈的状态天然和语法树深度对齐。以表达式类型检查为例假设语法树节点有BinaryExpr、Identifier、Literal遍历时对每个节点返回它的类型public Type checkExpr(ASTNode node) { if (node instanceof Literal) { return ((Literal) node).getType(); // int / float / bool } if (node instanceof Identifier) { Symbol sym scopeStack.lookup(((Identifier) node).getName()); if (sym null) { error(未声明的标识符: ((Identifier) node).getName()); return Type.ERROR; // 返回错误类型避免后续连锁报错 } return sym.getType(); } if (node instanceof BinaryExpr) { BinaryExpr bin (BinaryExpr) node; Type left checkExpr(bin.getLeft()); Type right checkExpr(bin.getRight()); // 算术运算要求两边都是数值类型 if (bin.getOp().isArithmetic()) { if (!left.isNumeric() || !right.isNumeric()) { error(算术运算类型不匹配: left bin.getOp() right); return Type.ERROR; } return left.promote(right); // int float - float } // 比较运算返回 bool if (bin.getOp().isComparison()) { return Type.BOOL; } } return Type.ERROR; }这里的关键设计是Type.ERROR这个哨兵类型。一旦某个子表达式出错返回ERROR而不是抛异常上层继续检查但不再重复报错这样一次遍历能收集多个错误而不是遇到第一个就中断。参数上promote方法决定数值提升规则实验里通常只要求int和float混合时提升为float别自己加太多规则否则测试用例对不上。2.3sectionnef这类节区标识符在符号表里怎么存标题里的sectionnef如果确实是实验框架里的节区名那它在语义分析阶段通常有两种处理方式。一种是作为普通标识符进符号表类型标记为SECTION或LABEL作用域限定在它所属的代码段内另一种是作为命名空间前缀和后面的变量名拼成完整限定名再查表。具体用哪种取决于你的语法定义但不管哪种符号表里都要额外存一个scopeLevel字段记录声明时的作用域深度方便在错误信息里指出“第几层作用域的 sectionnef 未定义”。我一般会在Symbol类里加这几个字段name、type、scopeLevel、line、column。line和column在报错时直接输出比只打印“未定义”有用得多。scopeLevel在离开作用域时可以用来做一致性校验比如检查某个sectionnef是否在正确的层级被引用。3. 用 Java 把语义分析器跑通从 AST 遍历到错误报告的最小实现3.1 定义 AST 节点和访问者接口Java 做编译器实验最顺手的方式是访问者模式。先定义节点基类和accept方法再写一个SemanticAnalyzer实现Visitor接口。节点类型不用多实验三通常覆盖声明、赋值、表达式、控制流就够// AST 节点基类 public abstract class ASTNode { public int line; public int column; public abstract T T accept(VisitorT visitor); } // 访问者接口返回值泛型方便表达式返回 Type public interface VisitorT { T visit(ProgramNode node); T visit(VarDeclNode node); T visit(AssignNode node); T visit(BinaryExprNode node); T visit(IdentifierNode node); T visit(LiteralNode node); T visit(IfNode node); T visit(WhileNode node); T visit(BlockNode node); }accept方法在每个具体节点里实现为visitor.visit(this)这样遍历逻辑全部集中在SemanticAnalyzer里节点类保持干净。泛型T让表达式节点返回Type语句节点返回Void一个接口两用。3.2 实现 SemanticAnalyzer 的遍历骨架遍历骨架决定了作用域进入和退出的时机。BlockNode进入时enterScope遍历完子节点后exitScopeProgramNode在最外层压一个全局作用域函数声明节点在参数和函数体之间切换作用域。骨架写对了后面填类型检查逻辑就是顺水推舟public class SemanticAnalyzer implements VisitorType { private ScopeStack scopeStack new ScopeStack(); private ListSemanticError errors new ArrayList(); Override public Type visit(ProgramNode node) { scopeStack.enterScope(); // 全局作用域 for (ASTNode child : node.getChildren()) { child.accept(this); } scopeStack.exitScope(); return null; } Override public Type visit(BlockNode node) { scopeStack.enterScope(); // 块级作用域 for (ASTNode stmt : node.getStatements()) { stmt.accept(this); } scopeStack.exitScope(); return null; } Override public Type visit(VarDeclNode node) { Type declared node.getDeclaredType(); // 如果有初始化表达式检查类型是否兼容 if (node.getInit() ! null) { Type initType node.getInit().accept(this); if (!declared.isAssignableFrom(initType)) { errors.add(new SemanticError( node.line, node.column, 变量 node.getName() 声明为 declared 但初始化为 initType)); } } Symbol sym new Symbol(node.getName(), declared, scopeStack.depth(), node.line, node.column); if (!scopeStack.declare(sym)) { errors.add(new SemanticError( node.line, node.column, 同一作用域内重复声明: node.getName())); } return null; } }注意visit(VarDeclNode)里先检查初始化表达式再声明符号顺序不能反。如果先声明再检查初始化初始化表达式里引用同名变量会查到刚声明的自己类型检查结果就错了。这个坑我在实验里踩过报错信息看起来完全不合理查了半天才发现是声明顺序问题。3.3 错误报告要带位置和上下文别只打印一句话语义分析的错误报告质量直接决定调试效率。最低要求是行号、列号、错误类型、涉及的名字。好一点的报告还会带上“期望类型 vs 实际类型”和“最近一次声明的位置”。SemanticError类可以设计成public class SemanticError { public final int line; public final int column; public final String message; public final String hint; // 可选修复建议 public SemanticError(int line, int column, String message) { this(line, column, message, null); } public SemanticError(int line, int column, String message, String hint) { this.line line; this.column column; this.message message; this.hint hint; } Override public String toString() { StringBuilder sb new StringBuilder(); sb.append(第 ).append(line).append( 行, 第 ) .append(column).append( 列: ).append(message); if (hint ! null) { sb.append(提示: ).append(hint).append(); } return sb.toString(); } }收集完所有错误后统一输出而不是遇到一个抛一个。这样学生做实验时能一次看到所有问题不用反复编译。hint字段在“未声明标识符”这类错误里特别有用可以提示“是否拼写错误最近声明的相似名字是 xxx”用简单的编辑距离就能实现。4. 避坑与排查语义分析实验里最容易翻车的 5 个点4.1 现象变量在块内声明块外还能查到原因exitScope没有在BlockNode遍历结束后调用或者调用时机放在了子节点遍历之前。符号表栈只进不出作用域越积越深查找时自然能翻到已经离开的层。解决在visit(BlockNode)里严格按“enterScope → 遍历子节点 → exitScope”的顺序写并且用try-finally包住遍历过程防止子节点抛异常导致exitScope被跳过。如果实验框架不允许改访问者模式至少在exitScope里加日志打印当前栈深度跑几个嵌套块就能看出问题。4.2 现象同名变量在内层声明后外层引用也变了原因declare方法写成了全局HashMap的put内层声明直接覆盖了外层的符号。查找时不管在哪一层都返回同一个符号。解决declare只操作scopes.peek()绝对不要遍历整个栈去put。查找时从栈顶往下找找到就返回实现“内层遮蔽外层”。测试用例里专门写一个内外层同名变量的例子验证内层赋值不影响外层。4.3 现象类型检查报错说 int 不能赋给 float原因类型兼容判断写反了或者isAssignableFrom的参数顺序搞错。Java 里A.isAssignableFrom(B)表示“B 能否赋给 A”也就是 B 是子类型或可转换类型。很多人写成initType.isAssignableFrom(declared)逻辑正好反了。解决统一约定declared.isAssignableFrom(initType)表示“声明类型能否接受初始化类型”。在Type类里把isAssignableFrom实现为相同类型返回 truefloat接受int返回 true其他返回 false。写完之后用int x 1.5;和float y 1;两个用例验证前者报错后者通过。4.4 现象sectionnef相关的引用全部报未定义原因sectionnef在语法阶段被解析成了某种特殊节点但语义分析没有对应的visit方法或者visit方法里没有把它注册进符号表。另一种可能是sectionnef的作用域层级和引用点不匹配比如声明在全局但引用在函数内查找时因为作用域栈深度不对而漏掉。解决先确认语法树里sectionnef对应的节点类型然后在SemanticAnalyzer里补上对应的visit方法。如果是作用域问题在lookup里加临时日志打印查找时的栈深度和每一层的键集合一眼就能看出是声明没进表还是查找层数不对。4.5 现象错误信息只报第一个就停了原因类型检查里用了throw new RuntimeException或者return直接中断遍历没有收集错误继续走。解决所有错误都往errors列表里加表达式出错返回Type.ERROR哨兵语句出错返回null继续遍历。只有在符号表栈空这种不可恢复的状态下才抛异常。最后统一输出errors按行号排序方便从上往下修。5. 进阶技巧用错误恢复和类型推导把实验三做出工程味语义分析做到能跑测试用例只是及格线真正拉开差距的是错误恢复和类型推导。错误恢复的核心思想是遇到一个错误后把当前节点标记为ERROR类型继续遍历兄弟节点而不是整棵树放弃。这样一次编译能报出所有问题而不是修一个跑一次。实现上就是在visit方法里对每个子节点检查返回值如果是ERROR就跳过依赖它的检查但继续处理其他子节点。类型推导则是另一个加分项。实验指导书通常只要求显式类型声明但你可以支持var x 1 2;这种隐式推导在visit(VarDeclNode)里如果声明类型为空就用初始化表达式的类型作为声明类型。推导规则不用复杂数值字面量默认int带小数点默认float布尔表达式默认bool足够覆盖实验用例。验证语义分析器是否正确我习惯用三层测试第一层是合法程序必须零错误通过第二层是单点错误程序每个程序只含一个语义错误验证错误位置和类型是否准确第三层是多重错误程序验证错误恢复是否生效、是否一次报全。三层都过了再拿去做代码生成基本不会因为语义问题返工。最后说个血泪经验语义分析阶段一定要把符号表的作用域深度和每个符号的声明位置记全别为了省内存只存名字和类型。等到代码生成阶段发现变量地址分配错了回头查符号表发现没有位置信息那才叫后悔药都没得吃。我现在的习惯是符号表里至少存name、type、scopeLevel、line、column五个字段多占不了多少内存但排查问题时能省几个小时。希望帮到你。本文还有配套的精品资源点击获取