简介:这份资源是东北大学秦皇岛分校2123121编译原理课程的实验报告文档,面向正在学习编译原理、需要完成词法分析实验的高校学生与自学者。报告围绕词法分析程序的设计与实现展开,系统梳理了基本字、运算符、标识符、常数、界符五类单词的识别规则,并给出基于Java的源码实现,涵盖字母开头词、数字开头词及其他字符的处理逻辑,同时讨论了空格过滤与注释处理等实验注意事项,可作为课程设计参考与实验报告撰写范本。资源包内共1个doc文件,约230KB,内容完整、结构清晰,便于直接查阅与借鉴。目前已有206人学习下载,适合需要理解词法分析流程、对照源码调试或整理实验报告的学习者使用。
1. 编译原理实验报告怎么写才不像“抄答案”:从词法分析到 LL(1) 的完整落地路径
很多人拿到“编译原理实验报告”这个任务时,第一反应是去搜“编译原理第三版答案”或者找一份现成的 .doc 改改交差。我带过几届学生的课程设计,也帮同事做过内部 DSL 的解析器,可以很直接地说:实验报告本身不是重点,报告里那套从词法分析到语法分析的代码能不能跑通、能不能讲清楚为什么这么写,才是老师和企业面试官真正看的东西。这份“东北大学秦皇岛分校-2123121编译原理实验报告.doc”背后对应的,大概率是词法分析器 + 递归子程序法语法分析器 + LL(1) 文法处理这一整套经典实验链路。如果你正在为这份报告发愁,或者想用 Java 把编译原理实验真正做出来而不是复制粘贴,那接下来的内容会按“先跑通、再讲透、最后避坑”的顺序,把每个环节的参数、代码和踩坑点都摊开讲。
2. 词法分析器:从字符流到 Token 序列的最小实现
2.1 为什么词法分析要单独抽一层
编译原理实验里最常见的翻车现场,就是有人把词法分析和语法分析揉在一个函数里,读到字符直接判断该归约还是该移进。这种做法在小规模实验里能跑,但一旦文法稍微复杂一点,代码就会变成一团乱麻。词法分析器的职责非常明确:把源程序的字符流切分成有意义的 Token 序列,每个 Token 带上类型和值。语法分析器只面对 Token 序列,不需要关心字符层面的空格、换行、注释怎么处理。
我一般会把词法分析器设计成一个独立的类,对外只暴露一个nextToken()方法。这样语法分析器在需要的时候主动拉取下一个 Token,形成“拉式”结构。这种设计在递归子程序法里特别自然,因为递归下降本身就是按需向前看。
Token 的类型定义要覆盖实验要求里常见的几类:关键字、标识符、整数常量、运算符、界符。关键字和标识符的区别在于关键字是预先定义好的保留字,比如if、while、int,标识符则是用户自定义的名字。这里有个容易忽略的点:关键字识别必须在标识符识别之前做,否则if会被当成普通标识符。常见做法是用一个HashSet<String>存关键字,识别出标识符后查表判断。
2.2 用 Java 实现词法分析器的核心代码
下面这段代码是一个最小可用的词法分析器骨架,支持整数、标识符、关键字和常见运算符。你可以直接复制到实验环境里跑。
import java.util.*; public class Lexer { private String src; // 源代码字符串 private int pos; // 当前读取位置 private char current; // 当前字符 private static final Set<String> KEYWORDS = new HashSet<>(Arrays.asList( "if", "else", "while", "int", "return", "void" )); public Lexer(String src) { this.src = src; this.pos = 0; this.current = src.length() > 0 ? src.charAt(0) : '\0'; } // 前进一个字符 private void advance() { pos++; current = pos < src.length() ? src.charAt(pos) : '\0'; } // 跳过空白字符 private void skipWhitespace() { while (current == ' ' || current == '\t' || current == '\n' || current == '\r') { advance(); } } // 读取标识符或关键字 private Token readIdentifier() { StringBuilder sb = new StringBuilder(); while (Character.isLetterOrDigit(current) || current == '_') { sb.append(current); advance(); } String word = sb.toString(); if (KEYWORDS.contains(word)) { return new Token(TokenType.KEYWORD, word); } return new Token(TokenType.IDENTIFIER, word); } // 读取整数 private Token readNumber() { StringBuilder sb = new StringBuilder(); while (Character.isDigit(current)) { sb.append(current); advance(); } return new Token(TokenType.NUMBER, sb.toString()); } // 获取下一个 Token public Token nextToken() { skipWhitespace(); if (current == '\0') { return new Token(TokenType.EOF, ""); } if (Character.isLetter(current) || current == '_') { return readIdentifier(); } if (Character.isDigit(current)) { return readNumber(); } // 处理运算符和界符 String op = String.valueOf(current); advance(); return new Token(TokenType.OPERATOR, op); } }这段代码的逻辑很直白:nextToken()是唯一入口,每次调用先跳过空白,然后根据当前字符判断是标识符、数字还是运算符。readIdentifier()里用StringBuilder累积字符,读完查关键字表决定返回 KEYWORD 还是 IDENTIFIER。readNumber()只处理整数,如果实验要求支持浮点数,需要额外处理小数点。
参数方面,src是完整的源代码字符串,pos是当前读取下标,current是当前字符的缓存。注意advance()里对越界的处理:当pos超出字符串长度时,current设为'\0',这样nextToken()就能用'\0'判断输入结束。这个细节很多人在实验里会漏掉,导致最后一个 Token 读完后死循环。
Token 类本身很简单,两个字段:type和value。TokenType用枚举定义 KEYWORD、IDENTIFIER、NUMBER、OPERATOR、EOF 就够了。如果你想让实验报告更完整,可以在nextToken()里加行号记录,方便后续报错时定位。
2.3 词法分析器的测试与验证方法
写完词法分析器后,不要急着写语法分析。先单独测试词法分析器,用一段包含各种 Token 的源代码跑一遍,把输出的 Token 序列打印出来。比如输入int main() { return 0; },期望输出是KEYWORD(int) IDENTIFIER(main) OPERATOR(() OPERATOR()) OPERATOR({) KEYWORD(return) NUMBER(0) OPERATOR(;) OPERATOR(})。
测试时重点检查三个地方:关键字是否被正确识别、标识符里的下划线和数字是否被正确包含、运算符是否被正确切分。如果发现==被切成两个=,说明你的运算符处理只做了单字符匹配,需要增加双字符运算符的前瞻判断。这个坑在实验报告里经常出现,因为很多示例代码只处理单字符运算符。
3. 递归子程序法:把 LL(1) 文法变成可执行的 Java 方法
3.1 递归子程序法的适用条件与选型理由
递归子程序法,也叫递归下降分析法,是 LL(1) 文法最自然的实现方式。它的核心思想是:为文法中的每个非终结符写一个解析函数,函数体按照产生式的右部依次调用其他非终结符的解析函数或匹配终结符。这种方法的优点是代码结构清晰,和文法一一对应,调试起来非常直观。缺点是遇到左递归文法会无限递归,所以使用前必须消除左递归和提取左公因子。
为什么实验里普遍要求用递归子程序法而不是 LR 分析法?因为递归子程序法的代码量和理解成本都低得多,适合在有限时间内完成。而且 LL(1) 文法的预测分析表可以直接指导递归下降的代码结构,两者是对应的。如果你在实验报告里能说清楚“为什么选递归子程序法而不是算符优先分析法”,这本身就是加分项。
选型理由可以这样写:算符优先分析法需要构造优先关系表,处理范围有限;LR 分析法需要构造项目集规范族,代码复杂度高;递归子程序法在 LL(1) 文法条件下代码最简洁,且易于手工实现和调试。对于编译原理实验这种规模,递归子程序法是性价比最高的选择。
3.2 从 LL(1) 文法到 Java 解析函数的映射
假设你的实验文法是这样的(已经消除左递归):
E -> T E' E' -> + T E' | ε T -> F T' T' -> * F T' | ε F -> ( E ) | id对应的递归子程序法代码结构就是:parseE()调用parseT()然后调用parseEPrime();parseEPrime()看当前 Token 是不是+,是就消费掉然后调用parseT()和parseEPrime(),不是就返回(对应 ε 产生式)。这种“看当前 Token 决定走哪条产生式”的逻辑,就是 LL(1) 里“1”的含义:只需要向前看一个 Token。
下面是用 Java 实现的递归子程序法核心代码:
public class Parser { private Lexer lexer; private Token currentToken; public Parser(Lexer lexer) { this.lexer = lexer; this.currentToken = lexer.nextToken(); // 预读第一个 Token } // 消费当前 Token 并前进 private void eat(TokenType type) { if (currentToken.getType() == type) { currentToken = lexer.nextToken(); } else { throw new RuntimeException("语法错误:期望 " + type + ",实际 " + currentToken.getType() + ",值=" + currentToken.getValue()); } } // E -> T E' public void parseE() { parseT(); parseEPrime(); } // E' -> + T E' | ε private void parseEPrime() { if (currentToken.getType() == TokenType.OPERATOR && currentToken.getValue().equals("+")) { eat(TokenType.OPERATOR); // 消费 + parseT(); parseEPrime(); } // 否则对应 ε 产生式,直接返回 } // T -> F T' private void parseT() { parseF(); parseTPrime(); } // T' -> * F T' | ε private void parseTPrime() { if (currentToken.getType() == TokenType.OPERATOR && currentToken.getValue().equals("*")) { eat(TokenType.OPERATOR); parseF(); parseTPrime(); } } // F -> ( E ) | id private void parseF() { if (currentToken.getType() == TokenType.OPERATOR && currentToken.getValue().equals("(")) { eat(TokenType.OPERATOR); // 消费 ( parseE(); eat(TokenType.OPERATOR); // 消费 ) } else if (currentToken.getType() == TokenType.IDENTIFIER) { eat(TokenType.IDENTIFIER); } else { throw new RuntimeException("语法错误:F 期望 ( 或 id,实际 " + currentToken.getType()); } } }这段代码的关键在于eat()方法:它检查当前 Token 类型是否匹配,匹配就消费并前进,不匹配就抛异常。eat()是整个解析器的安全阀,所有终结符匹配都必须走它,不能直接操作currentToken。很多实验代码在匹配时直接写currentToken = lexer.nextToken(),跳过了类型检查,导致语法错误被静默忽略。
parseEPrime()和parseTPrime()里的 ε 产生式处理方式是“不匹配就返回”,这是递归下降里处理可选结构的标准做法。注意递归调用parseEPrime()自身实现了循环效果,不需要额外写 while 循环。
3.3 预测分析表与递归代码的对应关系
LL(1) 文法的预测分析表可以直接指导递归子程序法的代码结构。对于每个非终结符,预测分析表的行是该非终结符,列是输入符号,单元格里是对应的产生式。递归下降代码里的 if-else 分支,本质上就是在查预测分析表。
以E'为例,预测分析表里E'行在+列对应E' -> + T E',在$(结束符)和)列对应E' -> ε。代码里parseEPrime()只判断了+,其他情况都走 ε 分支。这种对应关系在实验报告里可以用表格展示,让老师看到你理解了两者的一致性。
| 非终结符 | + | * | ( | ) | id | $ |
|---|---|---|---|---|---|---|
| E | E->TE' | E->TE' | ||||
| E' | E'->+TE' | E'->ε | E'->ε | |||
| T | T->FT' | T->FT' | ||||
| T' | T'->ε | T'->*FT' | T'->ε | T'->ε | ||
| F | F->(E) | F->id |
这张表在实验报告里的价值在于:它证明你的文法通过了 LL(1) 检查,没有冲突。如果某个单元格里有两个产生式,说明文法不是 LL(1) 的,需要进一步提取左公因子或消除左递归。
4. 实验报告里最容易翻车的五个坑
4.1 坑一:左递归没消除,程序栈溢出
现象:运行解析器时抛出StackOverflowError,或者程序卡死不动。原因:文法里存在直接左递归,比如E -> E + T | T,递归下降时parseE()第一件事就是调用parseE(),无限递归。解决:把左递归改写成右递归形式,E -> T E',E' -> + T E' | ε。改写后必须重新计算 FIRST 集和 FOLLOW 集,确认新文法是 LL(1) 的。
4.2 坑二:Token 预读逻辑写错,第一个 Token 被跳过
现象:解析器总是报“期望 id,实际 EOF”,但输入明明有内容。原因:在构造函数里调用了lexer.nextToken()初始化currentToken,但在parseE()里又调用了一次nextToken(),导致第一个 Token 被跳过。解决:确保currentToken只在构造函数里初始化一次,之后所有前进都通过eat()方法。这是递归下降里最经典的血泪教训,我见过至少五份实验报告栽在这里。
4.3 坑三:ε 产生式处理不当,死循环
现象:程序在某个非终结符的解析函数里反复循环,不消费任何 Token。原因:parseEPrime()里判断条件写成了if (currentToken != null),永远为真,递归调用自己但不消费 Token。解决:ε 产生式的处理必须是“不满足其他产生式条件时直接返回”,不能写成循环。递归下降里没有显式循环,循环效果是通过递归调用实现的,但递归调用前必须消费 Token。
4.4 坑四:错误恢复没做,一个错误导致满屏异常
现象:输入里有一个拼写错误,解析器抛出异常后直接终止,后面的内容完全不检查。原因:eat()方法里直接抛RuntimeException,没有做错误恢复。解决:实验级别的解析器可以不做复杂错误恢复,但至少要在异常信息里带上行号和当前 Token 的值,方便定位。更好的做法是引入同步集合(FOLLOW 集),在出错时跳到同步 Token 继续解析。
4.5 坑五:实验报告只贴代码,不写推导过程
现象:报告里只有代码和运行截图,没有文法推导、FIRST 集、FOLLOW 集、预测分析表。原因:很多人把实验报告当成代码提交流程,忽略了理论部分。解决:实验报告的核心价值在于展示你理解从文法到代码的完整推导链路。FIRST 集和 FOLLOW 集的计算过程、预测分析表的构造、递归下降代码与产生式的对应关系,这三样必须写清楚。代码只是最终产物,推导过程才是老师判断你是否真正掌握的依据。
5. 让实验报告从“能跑”到“能讲”:验证与进阶技巧
5.1 用多组测试用例验证解析器的边界
解析器写完后,不要只用一组正确输入测试。准备至少四组用例:第一组是正确表达式,比如a + b * c,验证基本功能;第二组是带括号的嵌套表达式,比如(a + b) * (c + d),验证递归调用;第三组是语法错误输入,比如a + * b,验证错误提示;第四组是边界输入,比如空字符串或只有空白字符,验证 EOF 处理。
每组用例都要记录预期输出和实际输出。如果第三组用例没有抛出异常而是静默通过,说明你的eat()方法里有类型检查漏洞。这种漏洞在实验报告里是致命的,因为它意味着解析器无法区分合法和非法输入。
5.2 把词法分析器的行号信息接入语法错误提示
基础版的词法分析器只返回 Token 类型和值,不记录行号。但在语法报错时,如果能提示“第 3 行第 5 列附近期望)”,调试效率会高很多。实现方式是在 Lexer 里维护一个line变量,每次遇到\n时递增,Token 里增加line字段。
这个改进在实验报告里属于加分项,因为它体现了工程化思维。很多实验报告只要求“能跑”,但如果你能主动加上行号追踪和错误定位,老师会认为你真正理解了编译器前端的工作方式。代价很小,只需要在advance()里加一个判断,在 Token 构造函数里多传一个参数。
5.3 从实验到真实项目:递归下降的适用边界
递归下降在实验里很好用,但在真实项目里有明确的适用边界。它的优势是代码可读性强、易于手工编写和调试;劣势是对文法限制多(必须是 LL(1)),且递归深度受栈大小限制。对于表达式解析这种嵌套不深的场景,递归下降完全够用;但对于包含大量嵌套结构的语言(比如深度嵌套的 JSON 或复杂 DSL),递归下降可能导致栈溢出。
工业界常见的做法是:手写词法分析器 + 递归下降解析器用于简单 DSL 和配置文件;对于复杂语言,用 ANTLR 或 Yacc 等工具生成解析器。如果你在实验报告的最后能提一句“递归下降适用于 LL(1) 文法,对于更复杂的文法需要考虑 LR 分析法或解析器生成器”,这会让报告的技术深度明显提升。
我自己带项目时的一个习惯是:先用递归下降快速实现一个原型,验证文法设计是否合理;如果文法复杂度超出 LL(1) 范围,再切换到 ANTLR 重新生成。这个“先手写、再工具化”的路径,比一上来就上工具更有利于理解编译原理的本质。希望帮到你。
本文还有配套的精品资源,点击获取