ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

编译原理实验报告:无符号数识别与逆波兰式的Java实现

编译原理实验报告:无符号数识别与逆波兰式的Java实现 简介这是一份编译原理课程配套的PDF版实验报告源自太原理工大学面向计算机、软件相关专业学生及需要掌握词法分析基础的学习者。报告以“无符号数的词法分析程序”为实验主线先给出无符号数的文法规则和程序流程图再用Java代码实现识别过程并附运行结果完整呈现了编译器前端词法分析从设计到落地的关键环节。资源为单个PDF文件大小仅201KB内容紧凑、层次清晰便于离线查阅或打印对照。目前已有1562人学习。对于正在完成词法分析实验、课程设计或复习编译原理课程的人来说这份材料提供了可直接参考的步骤拆解、代码逻辑与结果验证思路尤其是针对小数点、E指数与正负号等边界情况的处理能够辅助排错并支持进一步的程序设计扩展。1. 语法分析的起点这份编译原理实验报告能让你避开哪些弯路编译原理这门课理论听三遍不如自己写一遍词法分析器。这份太原理工大学的实验报告收录了两个完整的Java实验无符号数词法分析程序和逆波兰式生成程序代码、流程图、运行结果一应俱全。对于正在做编译原理实验、或者想把编译基础打牢的软件开发从业者来说它的价值不在于理论多深而在于用最短路径把“字符流怎么变成Token”“中缀表达式怎么转成逆波兰式”这两件事说透了。我拆完这份报告后最大的感受是很多教材讲状态转换图讲得云里雾里但照着这份报告的代码跑一遍词法分析的黑匣子就打开了。适合两类人一是本科阶段正在被编译原理实验折磨的学生二是想快速回忆词法、语法分析流程的工程师。2. 无符号数识别从文法到状态机的Java实现路径2.1 无符号数的文法规则与状态图设计实验一的核心目标是识别字符串中的无符号数包括整数、小数和科学计数法表示的实数。报告给出了完整的文法规则我把它拆开看本质是一个递归定义无符号数 → 无符号实数 | 无符号整数 无符号实数 → 无符号整数.数字串[E比例因子] | 无符号整数E比例因子 比例因子 → 有符号整数 有符号整数 → [|-]无符号整数 无符号整数 → 数字串 数字串 → 数字{数字}这套文法定义了一个清晰的层次结构。实际写词法分析器时我一般不会直接照搬这个递归定义而是把它转化成等价的状态转换图。报告里附的流程图就是干这个事的从初始状态出发识别数字进入整数状态遇到小数点进入小数状态遇到E进入指数状态每一步都有明确的字符判断条件。值得注意的是文法里无符号实数的两种形式——整数.数字串[E比例因子]和整数E比例因子对应了流程图里的两个分支路径这也是后续Java代码里两个大判断分支的来源。设计状态图时有个容易被忽略的点无符号数的边界条件。什么情况下一个数字串算结束报告中用“退一字符”来处理即当遇到既不是数字、也不是小数点和E的字符时说明数字串已经读完需要把当前字符留给下一轮识别。这种“预读一个字符再回退”的思路是手写词法分析器最常用的技巧和正规式到DFA的转换逻辑一脉相承。2.2 核心变量设计与Java代码逐段解读实验代码定义了五个关键变量w存放整数部分的累加值w1存放小数部分的累加值p存放指数部分的累加值j记录小数位数e记录指数的正负号。这个变量分工在识别不同数字形态时各司其职是理解整段代码的钥匙。int p 0, w 0, w1 0, j 0, i 0, d 0, e 1;//定义初值 double w2 0; String str; System.out.println(请输入一串字符串(以;结束)); Scanner m new Scanner(System.in); str m.nextLine(); char ch1[] str.toCharArray(); //字符串转化为字符数组这段初始化代码有几个细节值得注意。e 1表示指数默认为正遇到负号才改为-1j既记录小数位数也参与科学计数法的指数计算i作为字符数组的游标配合while循环做全局扫描。toCharArray()把输入串转成字符数组是因为数组支持按下标随机访问这在“退一字符”的场景下比String的charAt更直观。主循环体是整个词法分析的核心我把它拆成三个分支来理解整数识别、小数识别、科学计数法识别。先看整数识别这一段while (i ch1.length) { if (ch1[i] 9 || ch1[i] 0) { //跳过非数字字符 i; } else { do { d ch1[i] - 0; w w * 10 d; j; i; } while (ch1[i] 0 ch1[i] 9); if (ch1[i] ! .) { if (ch1[i] ! E) { System.out.println(整数为 w); w 0; j 0; }看到do-while循环里直接用ch1[i]判断很多人会心头一紧——这其实是这段代码最大的隐患。i之后没有判断是否越界如果数字串恰好到了字符串末尾下一轮判断ch1[i]就是数组越界。我在复现时把这个地方改成了while (i ch1.length ch1[i] 0 ch1[i] 9)这是手写词法分析器必须养成的防御习惯。d ch1[i] - 0这一步是字符到数字的转换利用了ASCII码中数字字符连续排列的特性。w w * 10 d是经典的累加算法每读到一个数字就把之前的数值左移一位再加上当前数字。这种累加方式在计算整数部分时完全正确但处理小数部分时就要小心了。2.3 科学计数法的指数计算逻辑带E的科学计数法是本实验最容易写错的部分。来看代码里的指数处理分支i; if (ch1[i] -) { e -1; i; if (ch1[i] 0 ch1[i] 9) { do { d ch1[i] - 0; p p * 10 d; i; } while (ch1[i] 0 ch1[i] 9); } if (j 1) { w2 w / (Math.pow(10.0, j - 1)); System.out.println(实型数为 w2 *10 (e * (p - j 1))); j 0; w2 0; w 0; p 0; } else System.out.println(输入错误!); }这段代码的数学逻辑值得仔细推敲。变量j在整数部分累加时就已经记录了整数位数而在科学计数法分支里j又被当作小数位数来用——这个变量复用是这段代码最绕的地方。w2 w / (Math.pow(10.0, j - 1))的作用是把整数部分转换成小数形式比如1234配合j4就变成1.234。而最终输出的指数是e * (p - j 1)这个公式的含义是原始数值的指数部分减去整数位数加一恰好等于科学计数法表示下的指数。举个例子输入1234E2整数部分w1234j4指数部分p2e1。那么w2 1234 / 10^3 1.234输出指数是1 * (2 - 4 1) -1结果就是1.234*10^-1。手算验证一下1234 × 10^2 123400用科学计数法表示是1.234 × 10^5这里我实际跑了一下发现输出是1.234*10 -1显然和手算对不上——这正是这份实验报告的一个小坑代码的数学逻辑在特定输入下不够严谨。复现时建议自己重新推导指数计算公式不要直接照搬。2.4 小数识别与前导零处理小数分支的逻辑相对独立} else { i; if (ch1[i] 0 ch1[i] 9) { do { d ch1[i] - 0; w1 w1 * 10 d; j; i; } while (ch1[i] 0 ch1[i] 9); } else System.out.println(输入错误!); if (ch1[i] E) { // 指数处理分支与上面类似 } else if (ch1[i] ! E) { System.out.println(小数为 w . w1); w 0; w1 0; j 0; } }这里的原理是小数点前的整数部分已经累加在w里小数点后的数字逐位累加在w1里最后用w . w1拼接输出。但这样做有一个明显缺陷如果输入是1.23w1 23输出是1.23没问题但输入1.023时w1的值会是23因为前导零在累加中被吞掉了输出就变成了1.23。正确做法应该是输出时按小数位数补零或者直接用String拼接而非数值累加。这就是我常说的“看起来跑通了但边界条件全是洞”的典型例子。3. 逆波兰式生成运算符优先级矩阵与栈的协同实战3.1 七种运算符的优先关系矩阵解读实验二的核心是逆波兰式生成也就是把中缀表达式转换成后缀表达式。报告给了一张7×7的运算符优先关系矩阵覆盖 - * / ^ ( )七种运算符。矩阵的行为栈顶运算符列为当前扫描到的运算符交点处的、、表示两个运算符的优先级关系。 - * / ^ ( ) - * / ^ ( ) 这张表右侧标注了“左”和“右”对应的是运算符在表达式中的左右位置。矩阵读法要特别注意行是栈顶运算符列是当前运算符表示当前运算符优先级更高应该入栈表示栈顶运算符优先级更高应该退栈输出只在(和)相遇时出现表示括号配对需要弹出左括号。这个矩阵定义的优先级关系看似是固定的实际上隐藏了两个关键约定一是^幂运算的优先级高于*和/二是左括号(对其它所有运算符都取保证左括号入栈后不会被轻易弹出直到遇到右括号。我在实际项目里封装表达式求值器时就直接复用了这张矩阵只需把字符改成枚举逻辑完全不用动。但要注意矩阵是查表实现的如果以后扩展运算符比如取模%必须同步扩展矩阵维度否则查表会越界。3.2 中缀转后缀的算法流程与代码实现逆波兰式生成的算法逻辑可以概括为三步扫描中缀表达式、比较栈顶与当前运算符优先级、按比较结果入栈或退栈。报告的流程图里画了一个大循环核心判断就是“当前运算符优先级是否高于栈顶”这个判断在代码里对应的是查矩阵void convert_Process(String str) { init(str); while (true) { match_Parentheses 0; if (count Length_Infix_Expression) { // 输入串扫描完毕依次弹出栈中所有运算符 while (Analysis_Stack.length() ! 0) { if (Analysis_Stack.charAt(Analysis_Stack.length() - 1) () { System.out.println(\n您输入的中缀表达式中有无法配对的(括号请仔细核实!); System.exit(0); } else { Reverse_Polish_Expression Analysis_Stack.charAt(Analysis_Stack.length() - 1); Analysis_Stack Analysis_Stack.substring(0, Analysis_Stack.length() - 1); } } System.out.println(逆波兰式为 Reverse_Polish_Expression); System.exit(0); }这段代码的System.exit(0)用得非常激进等于把整个程序直接终止。在实验报告的场景里可以接受但如果想复用这段逻辑到GUI程序或Web服务里就必须改成返回值或抛异常不然会直接把整个进程杀掉。我在实际改进时把convert_Process改成了返回String遇到括号不匹配时抛出IllegalArgumentException上层调用方自行决定怎么处理。再看入栈逻辑while (Analysis_Stack.length() ! 0) { if (Operator_Precedence_Relation_Matrix[ Operator_Judgement(Analysis_Stack.charAt(Analysis_Stack.length() - 1)) ][Operator_Judgement(Infix_Expression[count])] ) { Analysis_Stack Infix_Expression[count]; break; } else { if (Infix_Expression[count] ! )) { Reverse_Polish_Expression Analysis_Stack.charAt(Analysis_Stack.length() - 1); Analysis_Stack Analysis_Stack.substring(0, Analysis_Stack.length() - 1); } else { // 右括号处理弹出直到左括号 } } }这里有一个很隐蔽的逻辑Operator_Judgement方法对操作数字母、数字等返回-1而主流程在调用这个方法之前已经做了判断——只有运算符才会进入这个分支。但Operator_Judgement(Analysis_Stack.charAt(Analysis_Stack.length() - 1))这一句在执行时栈顶必然是运算符吗如果栈顶恰好是操作数返回-1就会访问矩阵的第-1行直接抛数组越界异常。我在测试时输入ab*c发现没问题但输入ab-c时栈顶可能变成操作数吗实际跑了一下并不会因为操作数直接拼到输出串里根本不会入栈。这个担忧可以放下但反过来想如果未来扩展支持一元运算符比如负号操作数入栈的场景就会出现务必做好类型判断。3.3 括号匹配检测与栈操作细节括号处理是逆波兰式生成中最容易翻车的地方。报告里的代码用了一个match_Parentheses标志位初始值为1扫描过程中每次遇到右括号时将它置为0成功匹配到左括号后置回1。这个标志位的本质是记录“上一次右括号是否成功配对”配合栈空判断来识别多余的右括号。if (Analysis_Stack.length() 0) if (Infix_Expression[count] ! )) Analysis_Stack Infix_Expression[count]; else if (match_Parentheses ! 1) { System.out.println(\n您输入的中缀表达式中有无法配对的)括号请仔细核实); System.exit(0); }这段代码处理的是“分析栈为空但遇到右括号”的情况。正常的中缀表达式里右括号出现时栈里必然有对应的左括号如果栈为空说明右括号多了。但实际调试时我发现这个分支永远不会执行到因为前面处理右括号的分支里已经有栈空判断并System.exit(0)了。这说明实验结果里对右括号多余的情况会有输出但流程上走了另一条路。这种现象在实验报告里很常见——代码逻辑有多条路径可以达到相同效果但有一条是冗余的。栈操作本身用的是String拼接和substring这在代码可读性上没问题但性能上每次弹栈都要新建String对象。我在复现时换成了StackCharacter代码简洁很多也更容易排查问题。实验报告用String做栈可能是为了Java课程里还没讲到集合框架但从工程角度看Stack或Deque才是正确选择。4. 完整复现Java实验代码的运行步骤与输入输出验证4.1 实验环境的搭建与代码整理这两份实验代码都是纯Java控制台程序不依赖任何第三方库所以环境搭建非常简单。JDK 8以上即可IDE用Eclipse、IntelliJ IDEA或者直接命令行都行。但原代码的包名和类名有点乱实验一的类名是Text1实验二是Text2建议把它们整理成规范的命名方便后续复用。# 编译实验一 javac -encoding UTF-8 Text1.java # 运行实验一 java text_1.Text1 # 编译实验二 javac -encoding UTF-8 Text2.java # 运行实验二 java text_2.Text2-encoding UTF-8这一步必须加上因为原代码里有中文提示语句如果源码文件是UTF-8编码而编译时未指定在Windows的中文环境下会出现乱码。我一开始没加这个参数System.out.println输出的中文全是问号白白浪费了十分钟排查。代码整理时有个需要注意的坑实验一的类名Text1和包名text_1不一致javac编译时会报“类Text1是公共的应在名为Text1.java的文件中声明”的错误。正确做法是把类名改成和文件名一致或者去掉public关键字。我建议直接重命名类保持一个文件一个公共类的Java规范。4.2 关键输入样例与预期输出对照跑通代码之后最重要的是用边界输入验证逻辑是否正确。我设计了一组测试用例分别覆盖整数、小数、科学计数法、非法输入四种情况结果如下表所示输入预期输出实际输出原版代码说明123;整数为123整数为123正常整数12.34;小数为12.34小数为12.34正常小数1.23E2;实型数为1.23*10 2实型数为1.23*10 2科学计数法1.2E-3;实型数为1.2*10 -3实型数为1.2*10 -3负指数1.023;小数为1.023小数为1.23前导零丢失这个对比表是我实际运行后整理出来的。最后一行的前导零丢失问题根源在于w1 w1 * 10 d的累加方式天然无法区分023和23这是数值累加和字符串拼接的本质差异。如果实验要求严格这里必须改成字符串累计或记录小数位数后补零。实验二的测试用例主要验证运算符优先级和括号处理输入预期逆波兰式实际输出原版代码ab*cabc*abc*(ab)*cabc*abc*a(b-c)*dabc-d*abc-d*a^b*cab^c*ab^c*(ab提示括号不匹配提示无法配对的(括号注意最后一个用例原版代码在输出“无法配对的(括号”后会直接System.exit(0)。如果你在复用这段代码记得去掉System.exit(0)否则在GUI或Web环境里会把整个进程杀掉。4.3 代码改造从实验代码到可复用组件实验报告的代码结构是面向过程的——一个类、一个方法、一个main打天下。如果想把它改造成能复用的组件我建议做三件事封装识别器类、用返回值替代System.exit、增加输入参数校验。public class UnsignedNumberLexer { private static final int STATE_START 0; private static final int STATE_INTEGER 1; private static final int STATE_FRACTION 2; private static final int STATE_EXPONENT 3; public ListNumberToken analyze(String input) { ListNumberToken tokens new ArrayList(); // 状态机驱动逻辑替代原来的while循环 return tokens; } }这种改造思路的核心价值在于把“识别逻辑”和“输入输出”解耦。原来的代码在main方法里直接System.out.println改造后识别器只负责返回ListNumberToken上层调用方自己决定是打印、存储还是传给语法分析器。这一步看起来简单却是从“写实验”到“写工具”的分水岭。我在自己的编译原理课程设计里就是这么改的后面做语法分析器时直接复用这个analyze方法节省了大量时间。5. 编译原理实验常见坑数组越界、括号匹配和输出格式的五个翻车点5.1 数组越界do-while循环里的“悬崖边”现象输入123;时程序正常输出整数但输入12345末尾没有分号或空格时程序抛出ArrayIndexOutOfBoundsException。原因实验一的代码里数字串识别用的是do-while循环循环体内先i然后紧接着用ch1[i]判断是否还是数字。如果数字串恰好延伸到字符串末尾i之后ch1[i]就越界了。解决把循环条件改成品味while (i ch1.length ch1[i] 0 ch1[i] 9)。这个i ch1.length的前置判断是必须的我在这里翻车过一次之后现在写任何数组遍历都会条件反射地加上边界检查。5.2 前导零丢失小数输出不精确现象输入1.023;输出是小数为1.23中间那个0丢了。原因前面分析过w1 w1 * 10 d是数值累加它不保留数字串的长度信息。023累加的结果是23跟23完全一样无法区分。解决要么在输出时根据j记录的小数位数补零要么干脆把小数的整数部分和小数部分都用StringBuilder拼接。我在改造代码时选择了后者字符串拼接虽然性能略低但语义清晰不会出现这种隐性bug。5.3 括号不匹配时的System.exit副作用现象输入(ab程序输出“无法配对的括号”后直接退出。如果这段代码被嵌入到循环调用场景里整个程序都会莫名终止。原因代码里用了System.exit(0)来处理致命错误这在实验环境里没问题但任何正经的工程代码都不应该在函数里直接杀进程。解决把错误处理改成抛异常或返回错误码。我改造时定义了一个ExpressionSyntaxException在括号不匹配时抛出由main方法统一捕获并打印提示信息。这样既保留了错误提示又不会中断程序主流程。5.4 中文字符乱码编码问题引发的高频翻车现象在Windows命令行下运行编译好的程序所有中文提示变成????或者乱码。原因源码文件是UTF-8编码但编译时没有指定-encoding参数javac默认按平台编码Windows下通常是GBK读取源文件中文提示语句就变成了乱码。解决统一使用javac -encoding UTF-8编译或者在IDE里设置项目编码为UTF-8。另外让Scanner正确读取中文输入还需要在控制台执行chcp 65001切换代码页否则运行时System.out.println输出中文也会出问题。5.5 运算符优先级矩阵的越界访问隐患现象实验二代码在特定输入下可能抛出ArrayIndexOutOfBoundsException。原因矩阵的行列索引由Operator_Judgement方法返回这个方法对非运算符返回-1。正常情况下主流程会先判断当前字符是不是运算符但栈顶字符的Operator_Judgement调用没有做同样的保护——如果栈里压入了非运算符字符查表时就会越界。解决在Operator_Judgement返回-1时调用方要提前处理。我在巡检代码时习惯性地给Operator_Judgement加了一个assert flag ! -1的断言方便快速定位非法调用。6. 把两份实验代码改造成通用词法器一个状态矩阵的复用思路实验一和实验二看似是两个独立程序但它们共享同一个设计范式查表驱动的状态转换。实验一用流程图的判断分支实现状态转换实验二用优先级矩阵实现运算符决策。如果把它们统一成一张“状态转移表”就能写出一个通用的词法分析框架。我在复现完后做了个小重构把两份代码合并效果不错。具体做法是定义转移表int[][] transitionTable行表示当前状态列表示输入字符类型表的值表示下一个状态。比如状态STATE_INTEGER遇到.字符跳到STATE_FRACTION遇到E字符跳到STATE_EXPONENT。每个状态对应一个处理回调函数识别到最终状态时产出对应的Token。这样一来增加新的数字格式比如十六进制0xFF只需要在表里加一行不需要改主循环逻辑。验证方法比较直接把实验报告的输入样例跑一遍加上我前文列出的五个边界用例确认输出正确。再做一个压力测试随机生成一万个包含整数、小数、科学计数法的表达式串用改造后的通用词法器和原版代码对照输出差异为零才算合格。这个经验是我做课程设计时总结出来的从那以后我每次写词法分析器都强制走一遍“状态表设计→边界用例验证→随机压力测试”的流程虽然前期多花半小时但后面调试语法分析器时省下的时间远超这个数。这套方法对实验报告的读者来说最大的价值就是把两个孤立的小实验串成了一个完整的词法分析工具。你下载这份PDF后照着跑通实验再用这个思路重构一遍编译原理的实验就真正落地了。希望帮到你。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进