
简介这份资源是编译原理课程实验三的语义分析实现包面向正在学习编译原理、需要动手完成编译器前端实验的高校学生与自学者。它聚焦词法分析与语法分析之后的语义检查环节帮助理解类型检查、作用域解析、常量折叠等核心概念在Java中的落地方式。压缩包共101个文件约88KB以35个java源码为主体辅以14个xml配置、若干class字节码与prefs、lock等IDE工程文件另有zip、segments_1、cfs、db等索引与缓存数据整体是一套可直接导入运行的Eclipse或IDEA工程。资源已有1781人学习下载说明其在同类实验资料中具备一定参考价值。读者可从中获得词法分析器、Token与Word类、语法树构建到语义分析器的完整代码骨架并借助Main入口串联各阶段对照调试类型不匹配、变量未声明、作用域冲突等典型问题从而加深对编译器工作流程与Java类型系统的理解为后续代码生成与静态分析工具开发打下基础。1. 语义分析实验到底在做什么从语法树到符号表的这一步很多人做编译原理实验语法分析跑通、AST 打印出来那一刻觉得稳了结果一进语义分析就翻车——变量未声明不报错、类型不匹配照样过、作用域嵌套直接乱套。实验三的核心任务就是把语法分析产出的抽象语法树拿过来做一遍带符号表和作用域链的遍历把「语法上合法但语义上说不通」的代码揪出来。标题里的 sectionnef 是这类实验里常见的语义分析入口或测试用例标识通常对应一段需要做符号表填充、类型检查、作用域校验的源码片段。这一章先把语义分析在整条编译流水线里的位置、它和语法分析的本质区别、以及一个能跑通的实验三最小闭环讲清楚适合正在被实验报告卡住、或者想自己从零补一遍语义分析实现的人。语义分析不是「再解析一遍语法」。语法分析回答的是「这串 token 能不能构成合法句子」语义分析回答的是「这个合法句子在程序里讲不讲得通」。举个最直接的例子int a; a hello;语法上完全合法声明语句、赋值语句都符合文法但语义上类型不匹配。再比如{ int x; } x 1;语法没问题但 x 出了作用域就不该可见。语义分析要做的就是维护一张符号表记录每个标识符的类型、种类、作用域层级在遍历 AST 的过程中不断查表、填表、比对类型发现不一致就报错。实验三通常要求你实现三件事符号表的建立与管理、类型检查、作用域处理。符号表可以用哈希表或有序表实现关键是支持插入、查找、删除或作用域退出时的批量清理。类型检查要覆盖基本类型int、float、char、bool之间的赋值兼容、运算类型推导、函数调用参数匹配。作用域处理要区分全局作用域、函数作用域、块作用域进入块时压栈、退出块时弹栈。这三件事做扎实实验三基本就稳了。sectionnef 在这个语境下一般是你实验里某个测试用例的名字或者语义分析模块的入口函数标识。不同学校的实验框架不一样有的给的是 Java 的 AST 类库有的给的是 C 的手写递归下降 parser 输出但语义分析的核心逻辑是通的遍历、查表、比对、报错。下面几章我会按「先建符号表 → 再做类型检查 → 再处理作用域 → 最后排错」的顺序把每一步的实现细节和踩坑点讲透。2. 符号表怎么建才不返工数据结构选型与插入查找实现符号表是语义分析的命根子。建得好后面类型检查、作用域处理都是顺水推舟建得烂写到一半发现查不到外层变量、或者同名变量覆盖了不该覆盖的就得推倒重来。这一章先把符号表的数据结构选型和核心操作讲清楚再给一份能直接抄的 Java 实现。2.1 为什么用「栈式作用域链 哈希表」而不是单一全局表最常见的翻车做法是全局开一个HashMapString, Symbol遇到变量声明就 put遇到变量引用就 get。单层作用域下没问题一旦出现嵌套块就完蛋。比如int x 1; { int x 2; print(x); // 应该输出 2 } print(x); // 应该输出 1用单一全局表第二个int x 2直接把外层的 x 覆盖了退出块之后外层 x 也回不来。正确做法是每个作用域一张表作用域嵌套形成栈式结构。进入新作用域时压入一张新表退出时弹出。查找变量时从栈顶往下逐层找找到第一个匹配就返回。这样内层同名变量自然遮蔽外层退出后外层变量自动恢复可见。数据结构上我一般用ArrayListHashMapString, Symbol表示作用域栈栈顶是当前作用域。每个 Symbol 至少存名字、类型、种类变量/函数/参数、声明行号、是否已初始化。种类字段很多人省掉后面做函数调用检查时又得补不如一开始就加上。2.2 符号表插入与查找的 Java 实现下面这份代码可以直接放进你的实验项目里核心是enterScope、exitScope、insert、lookup四个方法import java.util.*; class Symbol { String name; String type; // int, float, char, bool, function String kind; // variable, function, parameter int line; boolean initialized; Symbol(String name, String type, String kind, int line) { this.name name; this.type type; this.kind kind; this.line line; this.initialized false; } } public class SymbolTable { // 作用域栈栈顶是当前作用域 private DequeMapString, Symbol scopeStack new ArrayDeque(); public SymbolTable() { enterScope(); // 全局作用域 } // 进入新作用域压入一张空表 public void enterScope() { scopeStack.push(new HashMap()); } // 退出作用域弹出栈顶表 public void exitScope() { if (scopeStack.size() 1) { throw new RuntimeException(不能退出全局作用域); } scopeStack.pop(); } // 插入符号只查当前作用域是否重复 public boolean insert(Symbol sym) { MapString, Symbol current scopeStack.peek(); if (current.containsKey(sym.name)) { return false; // 当前作用域已有同名符号 } current.put(sym.name, sym); return true; } // 查找符号从栈顶往下逐层找 public Symbol lookup(String name) { for (MapString, Symbol scope : scopeStack) { Symbol s scope.get(name); if (s ! null) return s; } return null; // 未声明 } // 只查当前作用域用于重复声明检查 public Symbol lookupCurrent(String name) { return scopeStack.peek().get(name); } }逻辑说明scopeStack用ArrayDeque当栈使push压栈、pop弹栈、迭代时从栈顶开始。insert只检查当前作用域是否重复允许内层遮蔽外层——这是 C、Java 等语言的正确行为。lookup从栈顶往下遍历保证内层优先。lookupCurrent单独暴露出来是因为有些检查比如同一作用域重复声明只需要看当前层。参数说明Symbol的type字段建议用字符串而不是枚举实验阶段改起来方便后面做类型兼容判断时写个isCompatible(String t1, String t2)方法就行。line字段一定要存报错时能直接定位到源码行实验报告里也好写。initialized字段用于「变量未初始化就使用」的检查虽然很多实验不要求但加上不亏。提示如果你的实验框架要求符号表支持删除单个符号比如函数参数在函数体结束后失效不要真的去删用作用域弹栈批量清理更安全也不会漏。2.3 插入时机与遍历顺序的配合符号表建好了什么时候插、什么时候查取决于你 AST 的遍历顺序。常见做法是遇到变量声明节点先查当前作用域是否重复不重复就插入遇到标识符引用节点调lookup查查不到就报「未声明」。遇到块语句节点进入时enterScope退出时exitScope。遇到函数定义节点先把函数名和返回类型插入外层作用域再enterScope处理参数和函数体。这里有个容易忽略的点函数名应该在处理函数体之前就插入外层作用域否则递归调用自己时会报「未声明」。我见过不少人在这里翻车递归函数一调就报错查半天才发现是插入顺序反了。遍历顺序建议用后序遍历做类型检查先处理子节点再处理父节点但符号表的插入要用前序先声明再使用。实际实现时不用严格区分在遍历到声明节点时立即插入、遍历到引用节点时立即查找即可AST 的递归结构天然保证了声明在引用之前被访问——前提是你的遍历是从上到下、从左到右的。3. 类型检查怎么做才不漏从基本类型兼容到函数调用匹配符号表建好之后语义分析的第二大块就是类型检查。这一章把类型检查拆成三个层次基本类型兼容判断、表达式类型推导、函数调用参数匹配。每一层都给可执行的判断逻辑和常见错误案例。3.1 基本类型兼容规则与实现类型兼容判断是类型检查的地基。不同语言的兼容规则不一样实验里通常简化成同类型兼容、int 可以隐式转 float、char 可以转 int、bool 不跟其他类型混。下面是一个典型的兼容判断实现public class TypeChecker { // 判断 from 类型能否隐式转换为 to 类型 public static boolean isCompatible(String to, String from) { if (to.equals(from)) return true; // int - float 允许 if (to.equals(float) from.equals(int)) return true; // char - int 允许 if (to.equals(int) from.equals(char)) return true; // 其他一律不兼容 return false; } // 二元运算结果类型推导 public static String binaryResultType(String left, String right, String op) { // 算术运算 if (op.equals() || op.equals(-) || op.equals(*) || op.equals(/)) { if (left.equals(float) || right.equals(float)) return float; if (left.equals(int) right.equals(int)) return int; throw new RuntimeException(算术运算类型不匹配: left op right); } // 比较运算返回 bool if (op.equals() || op.equals() || op.equals() || op.equals(!)) { if (!isCompatible(left, right) !isCompatible(right, left)) { throw new RuntimeException(比较运算类型不匹配: left op right); } return bool; } throw new RuntimeException(未知运算符: op); } }逻辑说明isCompatible(to, from)的参数顺序很重要第一个是目标类型第二个是源类型。int - float允许但float - int不允许写反了就会放过不该放过的赋值。binaryResultType处理二元运算的结果类型算术运算里只要有一个 float 结果就是 float比较运算统一返回 bool。参数说明运算符集合按你实验文法里实际有的来不要多写。如果实验支持%取模记得%只对 int 有效float 参与要报错。字符串类型如果实验里有单独处理不要混进数值兼容规则。3.2 表达式类型推导的递归实现表达式类型推导要跟着 AST 递归走。每个表达式节点返回自己的类型父节点根据子节点类型和运算符算出自己的类型。核心代码如下// 假设 AST 节点类有 getType() 和 getChildren() public String checkExpr(ASTNode node) { switch (node.getKind()) { case INT_LITERAL: return int; case FLOAT_LITERAL: return float; case CHAR_LITERAL: return char; case BOOL_LITERAL: return bool; case IDENTIFIER: { Symbol s symbolTable.lookup(node.getValue()); if (s null) { error(node.getLine(), 未声明的标识符: node.getValue()); return error; } if (!s.initialized s.kind.equals(variable)) { error(node.getLine(), 变量未初始化就使用: node.getValue()); } return s.type; } case BINARY_OP: { String lt checkExpr(node.getLeft()); String rt checkExpr(node.getRight()); return TypeChecker.binaryResultType(lt, rt, node.getOp()); } case ASSIGN: { String lt checkExpr(node.getLeft()); String rt checkExpr(node.getRight()); if (!TypeChecker.isCompatible(lt, rt)) { error(node.getLine(), 赋值类型不匹配: lt rt); } // 标记左值为已初始化 if (node.getLeft().getKind().equals(IDENTIFIER)) { Symbol s symbolTable.lookup(node.getLeft().getValue()); if (s ! null) s.initialized true; } return lt; } default: return error; } }逻辑说明每个 case 对应一种表达式节点。字面量直接返回类型。标识符查符号表查不到报错查到但未初始化也报错如果实验要求。二元运算递归求左右类型再推导结果。赋值语句检查右值能否赋给左值同时把左值标记为已初始化。参数说明error方法建议收集错误而不是遇到第一个就抛异常这样一次编译能报出所有语义错误实验报告里也好看。initialized标记只在赋值语句里设置声明时带初始化的如int x 1;要在声明处理里就设好。3.3 函数调用参数匹配的检查点函数调用是类型检查里最容易漏的地方。要检查三件事函数是否已声明、参数个数是否匹配、每个参数类型是否兼容。实现思路是遇到函数调用节点先lookup函数名拿到函数的参数类型列表再逐个比对实参类型。case FUNCTION_CALL: { Symbol func symbolTable.lookup(node.getFuncName()); if (func null || !func.kind.equals(function)) { error(node.getLine(), 未声明的函数: node.getFuncName()); return error; } ListString paramTypes func.getParamTypes(); // 函数符号里存参数类型列表 ListASTNode args node.getArgs(); if (args.size() ! paramTypes.size()) { error(node.getLine(), 参数个数不匹配: 期望 paramTypes.size() 个实际 args.size() 个); return func.type; } for (int i 0; i args.size(); i) { String argType checkExpr(args.get(i)); if (!TypeChecker.isCompatible(paramTypes.get(i), argType)) { error(node.getLine(), 第 (i 1) 个参数类型不匹配: 期望 paramTypes.get(i) 实际 argType); } } return func.type; }逻辑说明函数符号里需要额外存一个paramTypes列表在函数声明处理时填充。参数个数检查要在类型检查之前个数不对直接返回不用再逐个比类型。类型比对用isCompatible(形参类型, 实参类型)顺序不能反。参数说明如果实验支持默认参数或可变参数这里要额外处理但大多数编译原理实验不要求别给自己加戏。返回值类型直接用函数符号的type字段。注意函数调用检查里最容易翻车的是「函数名和变量名同名」的情况。如果你的符号表 lookup 返回了变量符号而不是函数符号后面取 paramTypes 会直接空指针。所以 lookup 之后一定要判断 kind不是 function 就报错。4. 作用域处理与遍历顺序块、函数、循环的边界怎么划作用域处理是语义分析里最考验细节的部分。块作用域、函数作用域、循环作用域各有各的边界规则遍历时 enterScope 和 exitScope 的配对一旦出错符号表就会错乱。这一章把常见作用域场景的处理方式和遍历顺序讲清楚。4.1 块作用域与函数作用域的 enter/exit 配对块语句{ ... }进入时 enterScope退出时 exitScope这个最直观。函数定义稍微复杂函数名和返回类型插入外层作用域然后 enterScope 处理参数和函数体函数体处理完 exitScope。参数作为变量插入函数作用域这样函数体内能查到参数。// 处理块语句 case BLOCK: { symbolTable.enterScope(); for (ASTNode stmt : node.getStatements()) { checkStmt(stmt); } symbolTable.exitScope(); break; } // 处理函数定义 case FUNCTION_DEF: { // 函数名插入外层 Symbol funcSym new Symbol(node.getName(), node.getReturnType(), function, node.getLine()); funcSym.setParamTypes(node.getParamTypes()); if (!symbolTable.insert(funcSym)) { error(node.getLine(), 函数重复定义: node.getName()); } // 进入函数作用域 symbolTable.enterScope(); // 参数插入函数作用域 for (Param p : node.getParams()) { Symbol paramSym new Symbol(p.getName(), p.getType(), parameter, p.getLine()); paramSym.initialized true; // 参数默认已初始化 if (!symbolTable.insert(paramSym)) { error(p.getLine(), 参数重复: p.getName()); } } // 处理函数体 checkStmt(node.getBody()); symbolTable.exitScope(); break; }逻辑说明块语句的 enter/exit 严格配对中间处理所有子语句。函数定义先插函数名再进作用域参数插入函数作用域并标记已初始化。函数体处理完必须 exitScope否则后续代码会看到函数内部的局部变量。参数说明paramSym.initialized true这行别漏参数在函数体内天然可用不标记的话会误报「未初始化」。函数重复定义检查用insert的返回值返回 false 说明当前作用域已有同名符号。4.2 循环与条件语句里的作用域陷阱循环和条件语句的作用域处理有个经典陷阱循环变量在循环体结束后是否可见。C 语言里for (int i 0; ...)的 i 只在循环内可见但很多实验文法简化处理把 i 放在外层作用域。这个要看你实验的具体要求不能想当然。// for 循环循环变量在循环作用域内 case FOR_STMT: { symbolTable.enterScope(); // 初始化语句 if (node.getInit() ! null) checkStmt(node.getInit()); // 条件 if (node.getCond() ! null) checkExpr(node.getCond()); // 更新 if (node.getUpdate() ! null) checkExpr(node.getUpdate()); // 循环体 checkStmt(node.getBody()); symbolTable.exitScope(); break; }逻辑说明for 循环整体包一个作用域初始化、条件、更新、循环体都在这个作用域内。这样循环变量 i 在循环外不可见符合 C/Java 语义。如果实验要求 i 在外层可见有些简化文法这样就把 enterScope 去掉但要在实验报告里说明。参数说明while 循环不需要额外作用域循环体如果是块语句块自己会 enterScope。if 语句同理条件表达式在外层作用域检查分支体如果是块语句自己管作用域。4.3 遍历顺序对符号表状态的影响遍历顺序决定了符号表在某一时刻的状态。前序遍历先处理父节点再处理子节点适合声明收集后序遍历先处理子节点再处理父节点适合类型检查。实际实现时不用严格区分但要注意声明节点必须在引用节点之前被访问。一个常见的错误是先遍历整个 AST 收集所有声明再做类型检查。这样做的问题是内层作用域的声明会被提前收集到外层作用域信息丢失。正确做法是一边遍历一边处理遇到声明就插入当前作用域遇到引用就查找作用域的 enter/exit 跟着 AST 结构走。提示如果你的实验要求先输出符号表再输出错误信息可以在遍历过程中把符号表快照存下来遍历结束后统一打印。不要在遍历中途打印输出顺序会乱。4.4 作用域退出时的清理策略作用域退出时栈顶的符号表直接弹出即可不需要逐个删除符号。这是栈式作用域链的最大优势退出即清理O(1) 复杂度。但要注意如果有跨作用域的引用比如闭包弹出后符号就查不到了。编译原理实验一般不涉及闭包不用考虑。如果实验要求支持「变量在作用域外被引用时报错」弹出后 lookup 自然查不到报「未声明」即可。如果要求报「超出作用域」那需要在符号被弹出前记录它的作用域层级查找失败时判断是否曾经存在过。这个需求少见遇到了再处理。5. 语义分析避坑清单5 个让实验反复返工的典型问题这一章集中讲踩坑。语义分析的 bug 往往不是逻辑不会写而是细节没考虑到导致实验反复返工。下面 5 条是我自己和身边人真实翻车过的记录每条按「现象 → 原因 → 解决」写。5.1 递归函数报「未声明」现象函数体内调用自己语义分析报「未声明的函数」。原因函数名插入外层作用域的时机晚于函数体处理。先 enterScope 处理函数体再插入函数名导致函数体里 lookup 自己时查不到。解决调整顺序先把函数名和返回类型插入外层作用域再 enterScope 处理参数和函数体。这样函数体内 lookup 自己时能查到。5.2 内层变量遮蔽外层后退出块外层变量丢失现象嵌套块里声明同名变量退出内层块后外层变量查不到。原因用了单一全局符号表内层声明直接覆盖外层退出时没有恢复机制。解决改用栈式作用域链每个作用域一张表进入压栈、退出弹栈。查找时从栈顶往下找内层优先退出后外层自然恢复。5.3 赋值语句左值类型检查顺序错误现象float f; int i; f i;报类型不匹配。原因isCompatible(to, from)参数写反了写成了isCompatible(from, to)导致float int被判断为不兼容。解决统一约定isCompatible(目标类型, 源类型)赋值检查时用isCompatible(左值类型, 右值类型)。写完后用int float和float int两个用例验证前者应报错后者应通过。5.4 函数调用参数个数不匹配时数组越界现象函数调用参数个数少于形参个数时程序抛数组越界异常。原因参数类型检查循环用了paramTypes.size()作为上界但实参个数更少访问args.get(i)时越界。解决先检查个数是否相等不相等直接报错返回不要进入逐个比对的循环。个数检查放在类型检查之前。5.5 错误信息只报第一个就退出现象源码里有多个语义错误但只报了第一个后面的错误看不到。原因遇到错误就抛异常或 return没有继续遍历。解决用错误列表收集所有错误遇到错误时记录并继续遍历最后统一输出。这样一次编译能报出所有问题实验报告里也显得完整。注意有些错误会导致后续检查无法进行比如符号表查不到这种情况下可以跳过当前子树但不要终止整个遍历。注意避坑清单里的每一条都建议在实验里写一个对应的测试用例跑通之后再提交。语义分析的 bug 往往在边界用例上才暴露正常代码反而看不出问题。6. 用 sectionnef 做回归验证一套可复用的语义分析测试方法实验三做完怎么验证自己的语义分析是对的靠手工跑几个用例不够边界情况很容易漏。这一章给一套可复用的测试方法用 sectionnef 这类测试用例做回归验证确保每次改代码不会引入新问题。6.1 测试用例的分类设计语义分析的测试用例分四类正常用例、类型错误用例、作用域错误用例、声明错误用例。每类至少准备 3 个覆盖边界情况。类别用例示例期望结果正常int a; a 1; int b; b a 2;无错误类型错误int a; a hello;赋值类型不匹配作用域错误{ int x; } x 1;未声明的标识符声明错误int a; int a;重复定义函数错误int f(int x) { return x; } f(1, 2);参数个数不匹配递归int fact(int n) { if (n 1) return 1; return n * fact(n - 1); }无错误sectionnef 如果是你实验里的测试用例标识把它对应的源码片段跑一遍看输出是否符合预期。如果 sectionnef 是语义分析入口确保它接收 AST 后能正确输出错误列表。6.2 自动化回归脚本手工跑用例太慢写个脚本批量跑。假设你的语义分析器有一个analyze(String source)方法返回错误列表可以这样写public class SemanticTestRunner { public static void main(String[] args) { MapString, String cases new LinkedHashMap(); cases.put(int a; a 1;, ); // 期望无错误 cases.put(int a; a \hello\;, 赋值类型不匹配); // 期望报错 cases.put({ int x; } x 1;, 未声明的标识符); // 期望报错 cases.put(int a; int a;, 重复定义); // 期望报错 int passed 0; for (Map.EntryString, String entry : cases.entrySet()) { ListString errors SemanticAnalyzer.analyze(entry.getKey()); String expected entry.getValue(); boolean ok; if (expected.isEmpty()) { ok errors.isEmpty(); } else { ok errors.stream().anyMatch(e - e.contains(expected)); } System.out.println((ok ? PASS : FAIL) | entry.getKey()); if (!ok) { System.out.println( 期望: (expected.isEmpty() ? 无错误 : expected)); System.out.println( 实际: errors); } else { passed; } } System.out.println(通过 passed / cases.size()); } }逻辑说明用LinkedHashMap保持用例顺序key 是源码value 是期望的错误关键字空字符串表示期望无错误。analyze返回错误列表逐个比对。期望无错误时检查列表为空期望有错误时检查列表中是否有包含关键字的项。参数说明错误关键字用contains匹配而不是equals因为实际错误信息可能带行号等额外内容。如果你的错误信息格式固定可以改成更精确的匹配。用例集合按你的实验文法调整确保每个语法特性都有覆盖。6.3 我自己的验证习惯我做完语义分析后习惯先跑一遍正常用例确认不误报再跑错误用例确认不漏报最后跑一遍递归和嵌套作用域的复杂用例确认边界没问题。每次改完代码不管改多小都把这套用例重跑一遍。血泪经验是语义分析的 bug 往往在你改了一个看似无关的地方之后冒出来回归测试是唯一的后悔药。sectionnef 这类测试用例的价值在于它通常覆盖了实验要求的所有语义检查点跑通它基本就覆盖了评分点。但别只跑它一个自己补几个边界用例比如空块、多层嵌套、函数嵌套调用这些才是真正拉开差距的地方。希望帮到你。本文还有配套的精品资源点击获取