简介:这是一份编译原理课程配套的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表示指数默认为正,遇到负号才改为-1;j既记录小数位数也参与科学计数法的指数计算;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配合j=4,就变成1.234。而最终输出的指数是e * (p - j + 1),这个公式的含义是:原始数值的指数部分减去整数位数加一,恰好等于科学计数法表示下的指数。
举个例子,输入1234E2,整数部分w=1234,j=4,指数部分p=2,e=1。那么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.23,w1 = 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行,直接抛数组越界异常。我在测试时输入a+b*c发现没问题,但输入a+b-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对象。我在复现时换成了Stack<Character>,代码简洁很多,也更容易排查问题。实验报告用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,这是数值累加和字符串拼接的本质差异。如果实验要求严格,这里必须改成字符串累计或记录小数位数后补零。
实验二的测试用例主要验证运算符优先级和括号处理:
| 输入 | 预期逆波兰式 | 实际输出(原版代码) |
|---|---|---|
a+b*c | abc*+ | abc*+ |
(a+b)*c | ab+c* | ab+c* |
a+(b-c)*d | abc-d*+ | abc-d*+ |
a^b*c | ab^c* | ab^c* |
(a+b | 提示括号不匹配 | 提示无法配对的(括号 |
注意最后一个用例,原版代码在输出“无法配对的(括号”后会直接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 List<NumberToken> analyze(String input) { List<NumberToken> tokens = new ArrayList<>(); // 状态机驱动逻辑,替代原来的while循环 return tokens; } }这种改造思路的核心价值在于:把“识别逻辑”和“输入输出”解耦。原来的代码在main方法里直接System.out.println,改造后识别器只负责返回List<NumberToken>,上层调用方自己决定是打印、存储还是传给语法分析器。这一步看起来简单,却是从“写实验”到“写工具”的分水岭。我在自己的编译原理课程设计里就是这么改的,后面做语法分析器时直接复用这个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副作用
现象:输入(a+b,程序输出“无法配对的括号”后直接退出。如果这段代码被嵌入到循环调用场景里,整个程序都会莫名终止。
原因:代码里用了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后照着跑通实验,再用这个思路重构一遍,编译原理的实验就真正落地了。希望帮到你。
本文还有配套的精品资源,点击获取