ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

编译原理语义分析实验:Java实现符号表与类型检查

编译原理语义分析实验:Java实现符号表与类型检查 简介编译原理语义分析实验三配套资源面向编译原理课程学习者与Java开发者聚焦语义分析阶段的实现与调试。该实验利用Java语言完成编译器前端中的语义分析部分与词法分析、语法分析共同构成完整处理链路。资源内含完整Java工程共101个文件压缩包约88KB主要文件包括35个Java源码、14个XML配置、4个class文件以及少量项目缓存、数据库与日志文件可支撑代码阅读、编译运行与实验环境复现。已有1781人学习下载。内容覆盖语义分析核心任务如类型检查、作用域解析、常量折叠与表达式值计算并结合工程中的词法/语法分析类与调试素材展示从抽象语法树到语义验证的执行过程。实验过程还涉及未声明变量、类型不匹配等典型问题的排查思路适合用于理解编译器中间阶段设计、辅助课程实验或复习备考。1. 编译原理语义分析实验一份能跑通的Java工程与它的设计逻辑编译原理语义分析实验是学编译器时最迷茫的一段。词法分析有正则语法分析有递归下降到了语义分析符号表怎么建、类型检查卡在哪个环节、作用域嵌套怎么处理网上资料各说各话。我最近拆了一份实验三的 Java 工程从词法分析到语义分析完整跑通Main.class 直接运行就能看到检查结果符号表还会落盘到 variablesAndContainers.dat。这份资源解决的是语义分析器从零怎么写覆盖了类型检查、常量折叠、作用域解析。适合正在做编译原理实验的学生也适合想快速搞懂语义分析流程的开发者。下面把设计逻辑、运行方式和踩坑过程完整还原。2. 语义分析在编译器中的位置先搞懂要检查什么2.1 词法、语法、语义三层职责语义分析不是独立存在的。一个编译前端通常分三步词法分析把源码字符串切成 Token语法分析按照文法把 Token 序列组织成抽象语法树AST语义分析则在 AST 上做“合法性”检查。判定合法性依据的是语言的定义而不是语法规则。比如int a hello;在语法上完全合法能通过语法分析但类型不匹配必须由语义分析阶段报出来。这就是为什么语义分析通常放在语法分析之后、代码生成之前。我调试这份实验时第一步是理清三个阶段的输入输出。Lexer.class 负责输出 Token 流Token.class 和 Word.class 分别定义 Token 类别和关键字表语法分析部分没有单独拆类逻辑内嵌在 Main.class 里构建完 AST 后直接交给同一文件里的语义检查方法。这种单文件设计很适合课程实验方便打断点看每一步状态缺点是不好复用。如果你自己写编译器建议把 Parser 和 SemanticAnalyzer 拆开但这份实验的初衷是让你聚焦语义分析所以混在一起也说得通。理清职责后我看代码的顺序是先跑一遍 Main.class输入一小段正常代码确认三个阶段都能通过然后故意写一个类型错误看它报不报、报在哪一行。这样能最快判断语义分析器是整体没生效还是某个规则没写对。Token 流和 AST 都可以在代码里加打印观察但这份实验没有提供打印开关所以比较依赖调试器。2.2 这份实验里的语义规则类型检查、作用域解析、常量折叠语义分析到底检查什么看这份工程的输出就知道。Main.class 跑完后会依次做四件事先建全局作用域把函数名和全局变量登记进去再遍历 AST 节点遇到声明语句就把变量加入当前作用域并检查初始化表达式类型遇到赋值语句就查符号表确认变量已声明同时检查右侧表达式类型和左侧是否兼容遇到表达式节点就做常量折叠把能在编译期算出来的值直接算掉。整个过程输出错误列表错误格式类似line 3: incompatible types: int cannot be converted to String。类型检查是强类型语言的核心。Java 语言本身是强类型的所以这份实验里对类型匹配的要求很严整型不能赋给布尔浮点不能和字符串拼接除非显式 toString。常量折叠是为了后续代码生成准备的优化但这个实验只做语义验证折叠后的值会存进符号表的常量属性里方便检查数组下标、case 标签这类必须编译期确定的表达式。作用域解析则处理变量遮蔽问题。比如函数内声明一个和全局变量同名的局部变量后续引用应该指向局部变量。实现上就是把作用域串成一个链查找变量时从当前作用域逐层向上找。这份工程把作用域链直接放在符号表里每次进入块语句就 push 一层退出就 pop。如果 pop 时机不对很容易出现“变量找不到”或者“找到错误的变量”。我还总结了一个检查规则的小表格方便对照规则检查内容错误示例变量声明变量名是否与当前作用域已有名字冲突int a; int a;类型匹配赋值、函数参数、返回值的类型是否兼容int x s;作用域查找引用的变量是否在可达作用域内{ int a; } a 1;常量折叠常量表达式的值是否可编译期计算int x 1 2;操作符检查运算符两侧类型是否满足语义定义boolean b 1 2 s;2.3 源码结构从Lexer到Main拿到这份工程时里面是编译后的 .class 文件和一些缓存文件。要理解结构先按职责还原出源码骨架。下面的表格列出了主要文件和我推测的职责文件职责说明Lexer.class词法分析识别标识符、数字、关键字和运算符输出 Token 流Token.class / Word.classToken 定义Token 类别枚举Word 保留关键字与标识符Main.class语法语义分析入口构造 AST调用符号表和类型检查逻辑variablesAndContainers.dat符号表序列化输出语义分析完成后把作用域链落盘index.db / externalFilesCache工程索引缓存Eclipse 生成与编译逻辑无关assumedExternalFilesCache外部文件缓存可删除不影响程序运行这个结构对课程实验来说很典型。词法分析器独立成类语法和语义混在一起是常见做法因为实验重点是语义分析不需要像真实编译器那样把分层全部搬出来。调试时注意区分哪些文件是工程缓存哪些是真实代码产物。我在反编译 Main.class 后发现它内部维护了一个Node内部类用来表示 AST 节点节点里存了节点类型、文本内容、行号和左右子节点。语义分析就是从这个 Node 树的根节点开始递归遍历的。3. 用Java实现语义分析核心模块AST遍历与符号表3.1 符号表设计variablesAndContainers.dat背后的数据结构符号表是语义分析的核心。这份工程里符号表不是单一数组而是一个作用域链。每个作用域是一个 HashMap键是变量名值是 Symbol 对象保存类型、声明位置、是否为常量、常量折叠值。全局作用域链的底部是一个特殊节点存储函数元信息。variablesAndContainers.dat 就是这个链的序列化产物我用 ObjectInputStream 读出来看过结构类似下面public class Symbol { String name; // 变量名 String type; // int, boolean, String 等 boolean isConstant; // 是否 final Object constValue; // 常量折叠结果 int declaredLine; // 声明所在行 } public class Scope { MapString, Symbol symbols new HashMap(); Scope parent; // 外层作用域 }这段代码逻辑Symbol 是符号表的基本单元每个变量一条记录Scope 是作用域parent 指针串起作用域链。查找变量时从当前 scope.symbols 找找不到就沿着 parent 往上到 null 为止。参数里 declaredLine 用于报错时定位到源码行号这是语义分析比语法分析更依赖信息源的原因之一。实验里 variablesAndContainers.dat 落盘的是整个作用域链我看输出时发现全局作用域和函数作用域都还在局部作用域因为已经 pop 所以不在文件里。这说明实现是边遍历边删除不是把整个 AST 驻留在内存。这种设计好处是内存占用小坏处是调试时看不到完整符号表需要在 pop 前手动打印快照。如果你需要完整 dump可以在 enterBlock 方法里加一行System.out.println(Scope.dump());或者把当前作用域链复制一份保存到列表里。3.2 类型检查与表达式求值代码实现与参数说明类型检查的核心是一个递归方法检查表达式节点返回表达式的类型检查语句节点不带返回值。下面是我从 class 反推出来的简化逻辑和这份实验实现基本一致String checkExpr(AST node, Scope scope) { switch (node.kind) { case LITERAL: return node.literalType; // 直接量自带类型 case VARIABLE: Symbol s scope.lookup(node.name); // 查作用域链 if (s null) { error(line node.line : variable node.name is not declared); return error; } return s.type; case BINARY_OP: String left checkExpr(node.left, scope); // 先递归检查左操作数 String right checkExpr(node.right, scope); // 再检查右操作数 if (node.op.equals()) { if (left.equals(int) right.equals(int)) { return int; } error(line node.line : incompatible types: left right); return error; } // 其他运算符类似 return error; } return error; }这段代码逻辑checkExpr 对每个表达式节点返回类型字符串。遇到变量就查作用域查不到直接报错这是“未声明变量”检查的落点。遇到二元运算就先递归检查左右子树再根据运算符判断类型是否兼容。参数说明node.kind 是 AST 节点类型枚举scope.lookup 会沿作用域链向上查找error 方法记录错误行号和消息收集到列表统一输出。注意这里对加号的处理不够完善没有考虑字符串连接实验里如果支持字符串拼接需要额外分支。我在跑实验时发现这份工程对赋值语句的检查是独立的没有把赋值也当成二元运算。赋值右侧可以是任意表达式但左侧必须是可写变量即非 final。如果左侧是常量会额外报错cannot assign a value to final variable。这个细节很容易漏但也是语义分析常考的考点。检查完赋值后还会把右侧表达式的类型和左侧符号表中保存的类型做一次equals比较不相等就报错。3.3 作用域解析嵌套块与变量遮蔽作用域解析的目标是让“内层变量遮蔽外层同名变量”成立。常见实现是进入一个块时 push 新 Scope离开时 pop。下面是我调试时加的一段日志代码用来观察作用域链的变化void enterBlock(Scope current) { Scope child new Scope(); child.parent current; current child; System.out.println([enter] scope count: depth(current)); } void exitBlock() { System.out.println([exit] scope count: depth(current.parent)); current current.parent; }逻辑说明enterBlock 创建新作用域并挂到当前作用域下child.parent 指向外层。exitBlock 回收当前作用域回到父作用域。这里的 depth 方法沿着 parent 计数倒不是为了功能只是辅助打印。参数说明current 是语义分析器持有的“当前作用域”引用必须用类成员变量不能用局部变量替代否则嵌套块无法正确退出。这份实验里作用域链的查找方法我在 3.1 讲过了这里补充一个坑函数参数也会被塞进函数体的最外层作用域所以参数和局部变量同名时后者可以遮蔽前者。这在 C 语言和 Java 里都一样。检查作用域是否正确我一般会写一个特殊样例全局声明int a 1;函数里声明String a x;然后赋值给 a。如果重名不报错说明作用域链工作正常如果报类型不匹配说明查找时把内层 String 变量解析成了外层 int遮蔽逻辑写反了。4. 运行这份实验从.class到命令行输出的完整流程4.1 准备环境与编译选项拿到的是 .class 文件运行前提是有 JDK。我本地环境是 JDK 8直接命令行运行java Main注意必须和 Lexer.class、Token.class、Word.class 在同一目录否则 ClassNotFoundException。如果只想重新编译源码可以执行javac Main.java Lexer.java Token.java Word.java但这份实验包没有提供 .java所以一般直接用现成 class。如果你和我一样拿到的是 .class想确认编译版本可以用javap -verbose Main查看 class 文件头部的 major version。JDK 8 对应 52JDK 11 对应 55JDK 17 对应 61。如果版本过高低版本 JDK 会直接报UnsupportedClassVersionError。参数说明java Main默认从标准输入读取源码遇到结束符CtrlD 或 CtrlZ后开始分析。如果要分析文件可以改成java Main test.c这种重定向方式。注意编译选项里没有 -encoding 参数时Windows 下中文注释容易乱码建议启动时指定java -Dfile.encodingUTF-8 Main这个参数影响的是 JVM 读取标准输入时的默认字符集。如果你在中文 Windows 上运行且源码文件是 UTF-8 编码不加这个参数很可能在中文字符串字面量上出现乱码导致词法分析把字符串截断进而触发语法错误。这类问题看起来是语义分析的问题实际上源头在词法层。4.2 输入样例与语义错误触发运行后输入一段正常代码int main() { int a 1; a a 2; return a; }如果没有错误程序输出类似Semantic check passed.。这时会生成 variablesAndContainers.dat里面包含全局作用域和 main 函数作用域的符号表快照。再输入一段有类型错误的代码int main() { String s 1; return 0; }输出中会出现incompatible types: int cannot be converted to String。这说明类型检查生效了。为了验证常量折叠可以输入int b 3 4 * 2;然后看 .dat 文件里 b 的 constValue 字段是 11 而不是表达式树。如果看不到折叠值说明检查器没有对常量二元节点做化简。还有一个值得试的样例是作用域遮蔽int a 1; int main() { String a x; a 2; return 0; }这里内层 a 是 String所以a 2必须报类型错误。如果没报说明作用域查找优先到了外层 int a 上遮蔽逻辑有 bug。用这几个样例基本能把一个语义分析器的主要规则扫一遍。注意程序在第一次语义错误后就会停止分析所以如果你写了多个错误一次只会报第一个需要逐个修复。4.3 调试技巧用缓存文件定位问题这份工程里有几个让人困惑的文件index.db、externalFilesCache、assumedExternalFilesCache。这些都是 Eclipse 的索引缓存跟编译产物无关。有一次我改了 .dat 文件想模拟符号表结果程序出现诡异行为后来发现是因为 Eclipse 自动刷新了 externalFilesCache把 .dat 当资源同步了。解决办法是改完 .dat 后立即手动运行 java不要等 IDE 刷新最省事的是把缓存文件全部删除反正程序不依赖它们。调试语义分析时我习惯先在 Main.class 里加一个-verbose参数位打印每个 AST 节点的类型和当前作用域深度。没有源码也可以靠反编译工具还原出 Main 的逻辑再注入打印语句。由于 class 文件不大反编译后修改再编译比黑盒猜行为高效得多。如果你用 IDEA可以直接打开 .class 文件它会自动反编译成可读的 Java 源码虽然不是 100% 还原但看核心流程够用了。另一个调试技巧是单独读 variablesAndContainers.dat。写一个很小的 Java 程序用 ObjectInputStream 读这个文件把作用域链里的每个 Symbol 打印出来。因为 fallback 到反编译代码也需要先理解数据结构有了 dump 信息就不需要猜了。打印时注意 Symbol 和 Scope 的字段顺序要和反编译出来的保持一致否则会序列化版本不一致。5. 常见问题与避坑记录语义分析实验的典型翻车点5.1 现象变量未声明但程序直接通过我最初拿到的 class 文件跑一个样例函数里写了x 10;但没有声明 x程序居然什么都没报。原因不是语义分析器放水而是语法分析阶段把x 10当成了新变量声明的简化形式或者说这个实验的语言定义里允许隐式声明。后来我一查反编译的代码发现 lookup 方法在符号表里找不到变量时不是立即报错而是返回一个默认的“动态类型”对象。解决的办法是在case VARIABLE分支里把return error改成先收集错误再返回并且把默认类型改成 null后续任何类型比较遇到 null 都直接报错。这个坑的教训是看到“变量未声明”不报错先别骂编译器检查语言定义里是不是有隐式声明。如果实验要求严格模式直接找lookup方法的返回值处理逻辑。5.2 现象类型不匹配没有报错另一个常见问题是int a 1; String s x; a s;不报错。原因在我的实现里纯粹是手滑赋值语句的检查里只比较了引用名称没有比较类型对象。比如符号表里存的是IntegerType的实例而 checkExpr 返回的是字符串int两者 equals 永远不相等所以如果你用的是!比较对象引用反而会全部报错但如果你用比较字符串或者干脆没写比较逻辑就会漏掉错。解决方法是统一类型表示。我建议在符号表里也存字符串类型名checkExpr返回字符串比较时全部用equals并且先处理 error 类型。另外赋值检查还要看左侧是否为 final这个在实验里很容易被当成类型比较的一部分漏掉。5.3 现象作用域解析错乱导致变量遮蔽测试全局变量和局部变量重名时总是报“变量未声明”或者“类型不匹配”。原因是 enterBlock 和 exitBlock 的 push/pop 没有配对比如处理 if 语句时条件表达式检查完忘了 pop导致整个函数体都处于 if 的子作用域里。症状就是函数参数有时能查到有时查不到完全看 AST 遍历顺序。解决的方法是给每个需要开新作用域的节点Block、FunctionBody、IfBody、WhileBody都单独写一个方法在方法入口 enterBlock出口 exitBlock不要在一个大遍历函数里手动 push/pop。我后来在 pop 前打印当前作用域的符号数量很快就找到了漏 pop 的那个节点。5.4 现象Eclipse缓存文件干扰运行这部分坑不是代码问题是环境问题。工程目录里有 externalFilesCache、assumedExternalFilesCache 和 index.db这些是 Eclipse 在打开项目时生成的。如果你直接在目录里编辑 .java 或 .datEclipse 会刷新缓存可能锁定文件导致程序读取失败。更离谱的是有一次 ant 构建脚本把这个缓存目录当源码目录编进去class 文件路径全乱了。解决的办法是运行前删掉所有缓存文件并且把工程目录加入 IDE 的 excluded 列表。这些文件不影响程序逻辑删了会在下次打开 Eclipse 时重新生成。命令行运行时完全不需要它们直接删干净最省心。5.5 现象中文编码乱码导致Token识别失败在中文 Windows 上跑实验输入源码里带中文字符串字面量经常报出奇怪的语法错误。现象是字符串字面量后面半截丢失或者直接把下一行代码当成字符串内容。原因是标准输入编码和 JVM 默认编码不一致默认为 GBK 时读入的 UTF-8 字节流会出现乱码词法分析器按乱码去查找字符串结束符自然找不对。解决方法是启动时加-Dfile.encodingUTF-8并且把测试源码文件另存为 UTF-8 无 BOM 格式。如果你用的是 Eclipse还要把工程编码设置成 UTF-8否则项目里混入 GBK 文件会一起乱。这个坑排查起来最费时间因为错误信息指向语法分析实际上在词法层面就已经坏了。6. 进阶给语义分析器加一条新规则并验证6.1 修改规则前的准备如果你已经不满足于跑通想给这份实验加一条自己的语义规则最值得尝试的是“函数返回类型检查”。在 AST 里每个函数声明节点下面有返回语句节点你需要在语义分析阶段检查这些 return 表达式的类型是否和函数声明的返回类型一致。先定位返回语句的处理分支。在反编译代码里搜RETURN或returnStmt关键字找到后看它在 check 时是否调用了 checkExpr。接着找到函数声明节点从符号表里取出该函数的返回类型。准备一个全局变量currentReturnType每次进入函数声明节点时设置它检查完最后一条语句后恢复。6.2 实现函数返回类型检查下面是加在返回语句分支里的核心逻辑case RETURN: if (node.expr ! null) { String exprType checkExpr(node.expr, scope); if (!exprType.equals(currentReturnType)) { error(line node.line : incompatible return type: expected currentReturnType , found exprType); } } break;逻辑说明返回语句先检查表达式类型再和当前函数的返回类型比较。如果函数声明为 int但 return 后面是字符串就报错。参数说明node.expr 是 return 后面的表达式节点可能为空对应裸return;currentReturnType 必须在函数入口处赋值否则在函数体外遇到 return 时会拿到上一次函数的值产生误报。验证时写一个测试文件int foo() { return hello; }运行后应该看到incompatible return type: expected int, found String。再把返回值改成数字确认能通过。这个规则不会影响前面已有的类型检查因为它在函数作用域链里工作不会污染全局符号表。从那以后我每次给语义分析器加规则都会先写一个合法用例和一个非法用例一起跑确保新规则不影响旧规则。希望帮到你。本文还有配套的精品资源点击获取
返回列表