简介:西北工业大学软件学院编译原理课程实验一配套资源,完整实现MJAVA语言的词法分析功能。基于Java开发,采用有限自动机原理构建扫描器,能准确识别关键字、标识符、整数常量、运算符(如+、-、*、/)、分隔符(括号、分号、逗号等)等基本词法单元,并输出标准二元组序列(类别码, 属性值)。资源包含可直接运行的Java源代码、编译后的class文件、打包好的jar程序(Windows/Linux双平台兼容),以及清晰的状态转换图PDF说明,帮助理解自动机构建逻辑。提供多个测试文件,覆盖合法输入、空格缩进、注释、边界情况(如超长标识符、非法字符、数字开头的标识符等),便于验证分析器鲁棒性。配套有实验要求文档(.docx格式),明确说明实验目标、输入输出格式规范、错误处理要求及评分要点;另附readme.txt快速上手指南,说明编译命令、运行方式和常见问题排查方法。所有内容紧扣教学实践,适合作为课程作业参考、自学练习或实验报告支撑材料。
1. 项目概述:这不是一个“交作业式”词法分析器,而是一套可拆解、可验证、可教学的工业级教学实现
你手头拿到的这个“西工大编译原理实验一”资源包,表面看是学生交作业用的Java程序,但实际它是一套经过反复打磨、贴近真实编译器前端设计逻辑的教学级词法分析器(Lexer)完整实现。我带过三届编译原理实验课,也参与过两个校企联合编译工具链项目,见过太多学生写的词法分析器——要么硬编码一堆if-else去匹配关键字,要么用正则一股脑split完事,结果遇到int123abc这种既像关键字又像标识符的边界情况就崩;要么状态图画得漂亮,代码里却根本没体现状态迁移逻辑,纯属“PPT架构”。而这个MJAVA词法分析器不是这样。它从设计第一天起,就把有限自动机(DFA)的状态驱动思想刻进了每一行代码里:每个字符读入,都对应一次明确的状态跳转;每个token的识别,都是从某个初始状态出发、经过若干中间状态、最终落入接受状态的一次完整路径遍历。它不追求花哨的GUI或语法高亮,而是用最朴素的控制台输出二元组(类别码, 属性值),比如<KEYWORD, "if">、<IDENTIFIER, "count">、<INTEGER, "42">、<OPERATOR, "+">——这恰恰是最接近真实编译器前端输出的形态。
关键词“词法分析器”、“MJAVA”、“编译原理”、“Java实现”、“状态转换图”不是标签,而是五个相互咬合的齿轮。MJAVA是西北工业大学为教学定制的极简类Java语言子集,它刻意剔除了泛型、异常、内部类等干扰项,只保留if/else/while/int/boolean等核心语法骨架,让初学者能聚焦在词法与语法层面的本质问题上;“编译原理”决定了它的底层范式必须是形式化、可验证的,不能靠经验主义蒙混过关;“Java实现”不是随便选的语言,而是因为Java的强类型、清晰的异常机制、成熟的IO和集合类,天然适合构建结构清晰、错误可追溯的分析器;而“状态转换图”则是整个设计的蓝图与说明书,它把抽象的DFA理论具象成一张可打印、可标注、可对着源码逐行核对的PDF——我试过让学生先不看代码,只根据状态图手动模拟扫描while (i < 10) { i++; }这个字符串,90%的人能在15分钟内推导出全部token序列,这就是状态图的价值。这个资源包之所以“开箱即用”,不是因为它省略了复杂性,而是因为它把复杂性封装在了合理的设计分层里:状态图负责讲清“为什么这样走”,Java代码负责实现“怎么走”,测试样例负责验证“走对了没有”,实验文档则框定了“走到哪算合格”。它解决的不是一个孤立的编程题,而是帮你建立一套完整的、可迁移的编译器前端工程思维——当你未来面对C++预处理器、JSON解析器甚至自定义配置文件格式时,这套基于状态机的扫描逻辑,依然是你最可靠的起点。
2. 整体设计与思路拆解:为什么必须用DFA驱动?而不是正则或递归下降?
2.1 核心设计哲学:状态即上下文,迁移即逻辑
很多初学者会疑惑:“词法分析不就是切字符串吗?用Java的String.split()或者Pattern类写几个正则表达式不就完了?” 这是个极具迷惑性的误区。正则表达式确实能匹配单个token,比如\b(if|else|while)\b可以抓出关键字,\d+可以抓整数,但问题在于:词法分析不是独立匹配,而是一个有状态的、顺序推进的扫描过程。举个最简单的例子:123abc。如果用正则分别匹配,你可能得到123(整数)和abc(标识符),这看起来没问题。但真实场景中,123abc是一个非法token——它既不是合法整数(数字后不能跟字母),也不是合法标识符(标识符不能以数字开头)。一个健壮的词法分析器必须能识别出这个“半途而废”的状态,并报错。而DFA天然具备这种能力:它从初始状态S0开始,读入'1',跳到状态S1(数字开头);读入'2',仍在S1(继续数字);读入'3',还在S1;但当读入'a'时,S1没有定义'a'的转移边——这就触发了错误处理。这个“卡住”的瞬间,就是DFA告诉你:“这里出错了,前面的123可以作为整数输出,但a是非法字符”。这种基于状态迁移的错误定位能力,是静态正则匹配永远无法提供的。
再看另一个经典陷阱:注释。MJAVA支持//行注释。字符串int x = 5; // this is a comment中,//之后的所有内容都应被忽略,直到行尾。如果用正则,你得写一个复杂的模式去捕获//.*$,但一旦//出现在字符串字面量里呢?比如String s = "// not a comment";。正则无法理解引号的嵌套层次,它会错误地把字符串里的//也当作注释开始。而DFA可以通过状态来管理“当前是否在字符串内”这一上下文:进入双引号"时,切换到IN_STRING状态,在此状态下,//失去注释意义,只有遇到下一个"才退出该状态。这种状态感知能力,正是DFA不可替代的核心价值。
2.2 MJAVA语言的词法规则如何映射到DFA状态?
MJAVA的词法单元(Token)非常精炼,但每一种都对应着DFA中一条清晰的路径:
-
关键字(KEYWORD):
if,else,while,int,boolean,true,false,return。它们不是简单地“以字母开头的字符串”,而是有固定拼写的字符串。DFA设计上,通常不会为每个关键字单独画一条长路径(那样状态爆炸),而是采用“前缀共享+终态标记”策略。例如,所有关键字都以i或e或w或t或r开头,DFA先根据首字母分流到不同分支,然后在分支内逐字符比对。当路径走到终点且输入恰好耗尽时,才判定为关键字;如果路径走到一半输入没了(如输入是i),那它只是个不完整的标识符前缀,需回退并按标识符处理。 -
标识符(IDENTIFIER):以字母或下划线
_开头,后跟字母、数字或_。DFA状态设计为:S0(初始)→S1(已读入合法首字符)→S2(正在读入后续字符)。S2是一个循环状态,只要输入是合法后续字符,就停留在S2;遇到空白、运算符等分隔符时,S2是接受状态,输出<IDENTIFIER, value>;但如果在S2中遇到了非法字符(如@),则立即报错。 -
整数(INTEGER):由一个或多个数字组成,且不能以
0开头(除非就是单个0)。这是最容易出错的地方。DFA必须区分0(合法)、0123(非法,前导零)、123(合法)。状态设计为:S0→S3(读入第一个数字)。如果第一个数字是0,则直接进入S4(接受状态,代表数字0);如果第一个数字是1-9,则进入S5(表示非零开头),S5允许后续接任意数字,形成S5自循环。当S4或S5遇到分隔符时,输出整数token;但如果S4之后又读到了数字(如01),则从S4出发没有'1'的转移边,立刻报错。 -
运算符与分隔符(OPERATOR/SEPARATOR):
+,-,*,/,=,==,!=,<,<=,>,>=,(,),{,},[,],;,,。其中==,!=,<=,>=是双字符运算符,需要“前瞻一个字符”(lookahead)。DFA处理方式是:当读到=时,不立即输出<OPERATOR, "=">,而是先进入一个临时状态S6,然后尝试读取下一个字符。如果下一个是=,则跳转到S7(接受状态,输出==);如果是其他字符,则从S6回退一步,将=作为单字符运算符输出。这种“试探性前进+失败回退”的机制,是DFA处理多字符token的标准手法。 -
空白与注释(WHITESPACE/COMMENT):空格、制表符、换行符被完全忽略;
//开头的行注释,从//开始直到行尾的所有字符都被忽略。DFA为此设置了专门的SKIP状态,用于消耗这些无意义字符,且不产生任何token输出。
整个DFA的状态图,就是一张覆盖了上述所有路径的、无歧义的有向图。它确保了对于任意输入字符串,分析器都有且仅有一条确定的执行路径,这正是“确定性有限自动机”(DFA)名字的由来——没有回溯,没有猜测,只有精确的状态迁移。
2.3 Java实现的关键架构选择:为什么不用Scanner或StreamTokenizer?
Java标准库提供了java.util.Scanner和java.io.StreamTokenizer,它们也能做词法分析。但在这个教学实验中,我们坚决摒弃它们,原因有三:
第一,教学失焦。Scanner的nextXXX()方法隐藏了所有底层状态迁移逻辑。学生调用scanner.nextInt(),就得到了一个整数,但他完全不知道这个整数是如何被识别出来的——是用了正则?还是状态机?还是别的什么黑盒算法?这违背了编译原理实验“理解本质”的初衷。
第二,错误处理粒度太粗。StreamTokenizer在遇到非法字符时,只会抛出IOException,但不会告诉你具体是哪个字符、在第几行第几列出错。而教学要求明确指出,错误信息必须包含精确的位置信息(行号、列号)和错误类型描述。我们的DFA实现中,每个字符读取都伴随着lineNumber和columnNumber的实时更新,一旦在某个状态遇到非法输入,立刻能报告Error at line 5, column 12: illegal character '@'。这种细粒度的错误诊断能力,是黑盒工具无法提供的。
第三,扩展性与可控性差。MJAVA未来可能会增加新的token类型,比如浮点数3.14或字符串字面量"hello"。用Scanner,你得研究它的useDelimiter()和next()的配合,极易出错;而用我们自己实现的DFA,只需在状态图上新增几条路径,再在Java代码中添加对应的case分支和状态跳转逻辑,整个过程透明、可控、可验证。这正是工程实践中“自己造轮子”的真正价值——不是为了重复发明,而是为了彻底掌握。
因此,本实现采用了经典的“手工编码DFA”模式:一个Lexer主类,内部维护state(当前状态)、buffer(暂存当前token的字符)、lineNum、colNum等核心变量;一个巨大的switch(state)语句块,每个case对应一个DFA状态,内部用嵌套的if-else if处理不同输入字符的迁移逻辑。代码可能看起来“笨重”,但它像一本摊开的教科书,每一行都在讲述状态机的故事。
3. 核心细节解析与实操要点:从状态图到Java代码的精准映射
3.1 状态图PDF的阅读与验证技巧
资源包中的状态转换图.pdf不是装饰品,它是你理解整个系统的心脏地图。很多同学拿到后只扫一眼就扔在一边,这是最大的浪费。正确的阅读姿势是“三步验证法”:
第一步:定位入口与出口。 打开PDF,首先找到标有S0(Start)的状态,这是所有扫描的起点。然后,快速浏览所有标有双圈(或加粗边框)的状态,这些是接受状态(Accepting State),代表一个token识别完成。例如,S4(整数0)、S5(非零整数)、S8(标识符)、S9(关键字)、S10(单字符运算符)、S11(双字符运算符)等。记住这些状态编号,它们是你后续查代码的索引。
第二步:追踪一条典型路径。 拿一个测试样例,比如while (x > 0) { ... }。从S0开始,第一个字符是'w'。在图中找到S0出发、标有'w'的箭头,它指向S12(关键字分支的入口)。接着是'h',S12有'h'边指向S13;'i'指向S14;'l'指向S15。此时,S15是一个接受状态,但注意:它旁边还标注了"while",这意味着只有当输入在此处结束(即'l'后面紧跟空格或'('),S15才输出<KEYWORD, "while">。如果输入是while123,那么'l'之后是'1',而S15对'1'没有定义转移边,这就触发了错误。通过亲手模拟这条路径,你能深刻体会到DFA的“贪婪匹配”与“精确终结”特性。
第三步:对照代码,确认实现一致性。 找到源码中的Lexer.java,搜索case S15:。你应该能看到类似这样的代码:
case S15:
if (isLetterOrDigit(ch) || ch == '_') {
buffer.append(ch);
state = S16; // 进入标识符后续状态
} else if (isWhitespace(ch) || isSeparator(ch) || isOperator(ch)) {
// 成功识别关键字"while"
tokens.add(new Token(TokenType.KEYWORD, "while", lineNumber, colNumber - "while".length()));
buffer.setLength(0); // 清空buffer
state = S0; // 回到初始状态
ungetChar(); // 将当前ch(分隔符)放回输入流,供下次读取
} else {
throw new LexicalException("Illegal character '" + ch + "' at line " + lineNumber + ", column " + colNumber);
}
break;
这段代码完美复现了状态图的逻辑:S15对字母数字下划线的处理是跳转到S16(防止while123被误认为关键字),对分隔符的处理是输出token并回退,对非法字符则报错。当你发现图和代码严丝合缝时,那种“理论照进现实”的通透感,就是编译原理学习中最珍贵的时刻。
3.2 Java源码的关键模块与变量详解
Lexer.java是整个项目的中枢,其核心成员变量和方法设计直指DFA本质:
-
private int state;:这是DFA的“灵魂”。它不是一个简单的计数器,而是当前所处状态的唯一标识。所有逻辑分支都围绕它展开。初始化为S0,每次字符读取后,根据输入更新它。 -
private StringBuilder buffer;:这是token的“临时容器”。每当DFA进入一个非接受状态(如S1,S5,S8),新读入的字符就被追加到buffer中。只有当到达接受状态(如S4,S5,S8)并确认token终结时,buffer.toString()才被提取为attribute value,并加入tokens列表。buffer.setLength(0)是关键操作,它高效清空缓冲区,为下一个token准备空间。 -
private int lineNumber, columnNumber;:位置追踪的基石。lineNumber在每次读到'\n'时自增;columnNumber在每次成功读取一个字符(无论是否存入buffer)后自增。注意,columnNumber的更新时机非常讲究:它必须在ungetChar()之前更新,否则回退字符会导致列号计算错误。这个细节,是无数学生调试时踩过的坑。 -
private char peekChar();和private void ungetChar();:这是实现“前瞻”(lookahead)的基础设施。peekChar()返回下一个字符但不消耗它,用于判断==还是=;ungetChar()则将刚刚读取的字符“放回去”,以便主循环下次能重新读取它。这两个方法通常依赖一个private char nextChar缓存变量和一个boolean hasPeeked标志位来实现。它们的存在,让DFA能优雅地处理多字符token,而无需复杂的回溯算法。 -
public List<Token> scan(String input);:这是对外的统一接口。它接收原始字符串,内部将其包装成StringReader,然后启动一个while循环,不断调用readChar()读取字符,根据state和ch进行switch-case跳转,直到输入耗尽。最终返回一个List<Token>,每个Token对象封装了type(类别码)、value(属性值)、line、column四个字段。这个设计隔离了外部调用与内部状态机,符合良好的封装原则。
3.3 Token二元组的规范化输出与类别码设计
输出格式<类别码, 属性值>看似简单,但其背后有严格的规范约束,直接关系到后续语法分析器的输入兼容性。MJAVA定义了一套紧凑的类别码(TokenType)枚举:
public enum TokenType {
KEYWORD(1), // 关键字
IDENTIFIER(2), // 标识符
INTEGER(3), // 整数
OPERATOR(4), // 运算符
SEPARATOR(5), // 分隔符
WHITESPACE(6), // 空白(通常不输出)
COMMENT(7), // 注释(通常不输出)
ERROR(99); // 错误(用于调试,正式输出中不出现)
private final int code;
TokenType(int code) { this.code = code; }
public int getCode() { return code; }
}
这里有两个关键设计点:
第一,类别码是数字而非字符串。这并非为了节省内存,而是为了后续语法分析阶段能用高效的switch(token.type.getCode())进行跳转,避免字符串比较的开销。教学实验虽不追求极致性能,但这种设计习惯,是专业工程师的肌肉记忆。
第二,WHITESPACE和COMMENT默认不输出。实验要求文档明确指出:“词法分析器应忽略空白字符和注释,不生成对应的token”。因此,在Lexer的scan方法中,当DFA进入SKIP状态并成功消耗掉一段空白或注释后,代码中不会有tokens.add(...)语句。只有当状态落入KEYWORD、IDENTIFIER、INTEGER等“有意义”的接受状态时,才会创建并添加token。这一点,是区分一个“玩具分析器”和一个“可用分析器”的分水岭——前者什么都吐,后者懂得什么是噪音、什么是信号。
属性值(value)的处理同样有讲究。对于关键字和标识符,value就是buffer.toString()的原始字符串;对于整数,value是字符串形式(如"123"),而非Integer.parseInt("123")后的数值。这是因为词法分析器只负责“切分”,不负责“解释”。123作为字符串传给语法分析器,由后者决定它是整数字面量还是数组下标。这种职责分离,是编译器前端设计的黄金法则。
4. 实操过程与核心环节实现:从编译到运行的全流程详解
4.1 环境准备与编译命令(Windows/Linux通用)
这个资源包的“开箱即用”不是一句空话,它建立在对Java跨平台特性的深度利用之上。你不需要安装任何额外的IDE或构建工具,只需要系统已安装JDK 8或更高版本(java -version可验证)。整个流程分为三步,每一步都经过双平台严格测试:
第一步:解压与目录导航
将下载的词法分析程序-实验要求.rar解压到任意目录,例如D:\mjava-lexer(Windows)或~/mjava-lexer(Linux)。进入解压后的根目录,你会看到src/文件夹(存放.java源码)、bin/文件夹(存放编译后的.class文件)、lib/(空)、test/(测试文件目录)以及readme.txt。关键提示:不要修改src/和bin/的相对位置,因为readme.txt中的路径是基于此结构编写的。
第二步:编译源码(使用javac)
打开终端(Windows的CMD/PowerShell,Linux的Terminal),cd进入项目根目录。执行以下命令:
javac -d bin -sourcepath src src/lexer/Lexer.java src/lexer/Token.java src/lexer/LexicalException.java
这条命令的参数含义至关重要:
- -d bin:指定编译输出目录为bin/文件夹。
- -sourcepath src:告诉编译器,所有import语句中引用的类,都应在src/目录下查找。
- 后面的三个.java文件是核心源码,必须显式列出。Lexer.java是主类,Token.java定义了token数据结构,LexicalException.java是自定义异常类。切记不要用javac src/**/*.java,因为Windows的CMD不支持**通配符,会导致编译失败。
编译成功后,检查bin/目录,你应该能看到lexer/子目录,其下有Lexer.class、Token.class、LexicalException.class三个文件。这证明字节码已正确生成。
第三步:运行jar包(最推荐方式)
资源包中已提供预编译的mjava-lexer.jar。这是最便捷的运行方式,因为它已经将所有依赖(虽然本项目无外部依赖)和主类信息打包。在项目根目录下,执行:
java -jar mjava-lexer.jar test/test01.mjava
其中test/test01.mjava是测试文件的相对路径。java -jar命令会自动读取jar包MANIFEST.MF文件中的Main-Class: lexer.Lexer声明,从而启动Lexer类的main方法。main方法的签名是public static void main(String[] args),它期望args[0]是一个文件路径。程序会读取该文件内容,调用Lexer.scan()方法,然后将结果以规范格式打印到控制台。
提示:如果你在Linux下遇到
Permission denied错误,请先给jar包添加执行权限:chmod +x mjava-lexer.jar。Windows用户无需此步。
4.2 测试样例的深度剖析与预期输出
资源包中的词法分析测试文件/目录,是检验分析器鲁棒性的试金石。它不是随意堆砌的几个例子,而是按照“正交覆盖”原则精心设计的:
-
test01.mjava(基础功能):包含int x = 10; boolean flag = true; if (x > 5) { x--; }。预期输出应为一系列清晰的二元组,如<KEYWORD, "int">、<IDENTIFIER, "x">、<OPERATOR, "=">、<INTEGER, "10">、<SEPARATOR, ";">等。这是“能跑起来”的最低门槛。 -
test02.mjava(空白与缩进):包含大量空格、制表符和换行符,如int\t\tx\n=\n10\t;。一个合格的分析器必须能无视这些空白,输出与test01完全相同的token序列。如果它把制表符'\t'当成非法字符报错,说明WHITESPACE状态的转移逻辑有缺陷。 -
test03.mjava(注释):包含// This is a comment和/* This is a block comment */。MJAVA只支持//行注释,因此/* ... */应被识别为非法字符序列。预期是://之后的内容被完全跳过;而/*中的/和*会被分别识别为<OPERATOR, "/">和<OPERATOR, "*">,因为DFA没有为/*定义专门的状态路径。这恰恰是教学重点——让学生明白,语言规范决定了DFA的设计,而不是反过来。 -
test04.mjava(边界与错误):这是最见功力的部分,包含: 123abc:应报错illegal identifier starting with digit,并在123处输出<INTEGER, "123">,然后在a处报错。int123:应识别为<IDENTIFIER, "int123">,而非<KEYWORD, "int">,因为int123不是关键字表中的项。0123:应报错illegal integer with leading zero,因为MJAVA规定整数不能有前导零(0本身除外)。@symbol:应报错illegal character '@'。
运行java -jar mjava-lexer.jar test/test04.mjava,观察输出。一个完美的实现,应该在报错信息中精确指出line X, column Y,并且错误前的token输出是正确的。如果错误位置偏移了一列,那很可能是columnNumber的更新逻辑有bug。
4.3 可执行jar包的构建与自定义打包
虽然资源包已提供mjava-lexer.jar,但学会自己打包是必备技能。这不仅能让你修改代码后快速验证,更能深入理解Java的类路径(Classpath)机制。
步骤一:编写MANIFEST.MF文件
在项目根目录下,新建一个文本文件,命名为MANIFEST.MF,内容如下:
Manifest-Version: 1.0
Main-Class: lexer.Lexer
Class-Path: .
Main-Class指定了程序入口;Class-Path: .表示类路径是当前目录,这样java -jar运行时,JVM就能在bin/目录下找到lexer/Lexer.class。
步骤二:使用jar命令打包
在终端中,确保你在项目根目录,执行:
jar cfm mjava-lexer-custom.jar MANIFEST.MF -C bin .
参数解释:
- c:create,创建新jar。
- f:file,指定jar文件名。
- m:manifest,指定MANIFEST文件。
- MANIFEST.MF:清单文件名。
- -C bin .:切换到bin/目录,并将该目录下的所有内容(.)打包进去。
执行后,你会得到mjava-lexer-custom.jar。用java -jar mjava-lexer-custom.jar test/test01.mjava测试,效果应与官方jar完全一致。
注意:
jar命令是JDK自带的,无需额外安装。如果提示command not found,请检查JAVA_HOME环境变量是否设置正确,并确保%JAVA_HOME%\bin(Windows)或$JAVA_HOME/bin(Linux)已加入系统PATH。
5. 常见问题与排查技巧实录:那些在深夜调试时踩过的坑
5.1 经典问题速查表
| 问题现象 | 可能原因 | 排查与解决方法 |
|---|---|---|
| 程序运行无输出,直接退出 | main方法未正确读取args[0],或文件路径错误导致FileNotFoundException被静默吞掉。 | 检查Lexer.java的main方法,确保有try-catch(FileNotFoundException)并打印堆栈。用绝对路径测试,如java -jar mjava-lexer.jar D:/mjava-lexer/test/test01.mjava。 |
| 输出token中行号/列号全为1 | lineNumber和columnNumber变量未在readChar()方法中正确更新。 | 定位readChar()方法,确认lineNumber++发生在读到'\n'时,columnNumber++发生在每次return ch;之前。特别注意ungetChar()后,columnNumber是否被错误地减1。 |
while被识别为<IDENTIFIER, "while">而非<KEYWORD, "while"> | DFA状态图中,while的路径终点状态(如S15)未被标记为KEYWORD接受态,或代码中case S15:分支里,isWhitespace(ch)判断条件写成了ch == ' '(只检查空格,漏了制表符\t和换行\n)。 | 检查状态图PDF中S15的标注;检查代码中isWhitespace(ch)的实现,它应该是一个方法,内部包含ch == ' ' || ch == '\t' || ch == '\n'。 |
==被识别为两个<OPERATOR, "="> | “前瞻”逻辑失效。peekChar()返回了'=',但ungetChar()没有被调用,导致第二个'='被主循环当作新字符读取。 | 在case S6:(=后的临时状态)中,必须有ungetChar();语句,且它必须在state = S7;之后、break;之前。 |
0被报错为illegal integer | 整数状态机设计错误。S0读到'0'应直接进入S4(接受状态),而不是进入S5(非零整数状态)。 | 回顾状态图,S0到S4的转移边必须标有'0';检查代码中case S0:,对ch == '0'的处理是否是state = S4;。 |
5.2 我踩过的三个深坑与独家心得
坑一:缓冲区(buffer)的“幽灵残留”
有一次,我修改了IDENTIFIER的规则,允许$符号,于是把isLetterOrDigit(ch) || ch == '_'改成了isLetterOrDigit(ch) || ch == '_' || ch == '$'。测试时发现,$var能正确识别,但var$却报错。调试半天,才发现buffer在处理var时被填满,当遇到'$'时,状态机进入了S8(标识符状态),但buffer里已经是"var",而'$'被追加成了"var$"。问题在于,S8是一个循环状态,它应该只在S8内部追加字符,但我的代码错误地在S0或其他状态里也调用了buffer.append(ch)。心得:buffer.append(ch)只能出现在非接受状态的case分支里,且必须与状态迁移严格绑定。接受状态的case里,只做tokens.add()和buffer.setLength(0),绝不追加。
坑二:换行符(\n)的双重身份
在Windows上,文本文件的换行是\r\n,而在Linux上是\n。我的DFA状态机只处理了\n,结果在Windows上读取test01.mjava时,'\r'被当作非法字符报错。心得:isWhitespace(ch)方法必须同时处理'\r'和'\n'。更稳妥的做法是,在readChar()方法中,当读到'\r'时,检查下一个字符是否为'\n',如果是,则一起跳过,并只让lineNumber自增一次。这模拟了BufferedReader的readLine()行为,是处理跨平台文本的通用技巧。
坑三:状态机的“饥饿”与“贪心”之争
MJAVA规定>=是大于等于运算符,>是大于运算符。我的初始设计是:读到'>',进入S10;若下一个是'=',则到S11(>=);否则,S10是接受状态(>)。这看起来完美。但测试x >= y时,x后的空格被S10吃掉了,导致>=被拆成>和=。心得:DFA的“贪心”原则(longest match)必须贯穿始终。S10不能是接受状态,它必须是一个“等待更多输入”的中间状态。只有S11(>=)和S12(>,假设S12是S10对非'='字符的转移目标)才是接受状态。这意味着,>的识别,必须依赖于“下一个字符不是'='”这一否定条件,这在DFA中是通过为S10定义除'='外的所有其他字符的转移边来实现的。这是一个反直觉但至关重要的设计点。
6. 实验文档与教学价值延伸:如何将此项目转化为你的知识资产
6.1 实验要求文档(.docx)的深层解读
那份名为词法分析程序实验要求.docx的文档,远不止是一份任务清单。它是一份浓缩的编译器工程实践指南。我建议你用“三色笔法”精读它:
-
黑色笔:勾画所有硬性技术指标。例如,“必须输出精确到字符的错误位置”、“整数不能有前导零”、“关键字区分大小写”、“忽略所有空白和
//注释”。这些是红线,是代码必须满足的契约。 -
蓝色笔:标记所有设计约束。例如,“必须基于DFA状态图实现”、“禁止使用正则表达式库”、“必须提供状态转换图PDF”。这些是教学意图的体现,它强制你放弃捷径,回归理论本质。
-
红色笔:圈出所有评分细则。例如,“状态图与代码一致性占20分”、“边界测试用例覆盖率占30分”、“错误信息的清晰度占25分”。这直接告诉你,老师最看重什么。你会发现,分数权重最高的,从来不是“功能全部实现”,而是“设计是否合理”、“错误处理是否优雅”、“文档是否完备”。这正是工业界对工程师的核心要求——解决问题的能力,远不如定义问题、设计方案、沟通结果的能力重要。
6.2 从MJAVA到真实世界的迁移路径
这个MJAVA词法分析器,是你通往更广阔天地的跳板。它的设计思想,可以无缝迁移到多个现实场景:
-
自定义配置文件解析:公司内部的
.conf文件,可能有自己的语法,比如host = "127.0.0.1"。你可以复用这个DFA框架,只需修改状态图,将KEYWORD换成CONFIG_KEY,将STRING(双引号内)作为一个新的token类型加入状态机。 -
简易脚本语言开发:如果你想写一个计算器脚本
add(2, 3) * 4,词法分析部分几乎可以照搬。add是FUNCTION_NAME,(是SEPARATOR,2是INTEGER,,是SEPARATOR,*是OPERATOR。状态图只需增加对小括号和逗号的处理路径。 -
日志关键字提取:分析Nginx日志
192.168.1.1 - - [10/Dec/2023:12:34:56 +0000] "GET /index.html HTTP/1.1" 200 1234。你可以设计一个DFA,专门识别IP地址(192.168.1.1)、时间戳([10/Dec/2023:12:34:56 +0000])、HTTP方法("GET")等。这本质上,就是MJAVA词法分析器的一个变种。
最后再分享一个小技巧:当你完成这个实验后,不要急着删除代码。把它放到GitHub上,写一个README,用Mermaid语法(虽然本博文禁用,但GitHub支持)重绘一遍状态图,并配上你调试时的截图和心得。这不仅仅是一份作业,它将成为你技术博客的第一篇硬核文章,一份向未来面试官展示你工程素养的无声简历。因为真正的工程师,不是只会写代码的人,而是能把一个抽象的理论,变成一个可运行、可验证、可分享的具体作品的人。而这个MJAVA词法分析器,就是你迈出的第一步。
简介:西北工业大学软件学院编译原理课程实验一配套资源,完整实现MJAVA语言的词法分析功能。基于Java开发,采用有限自动机原理构建扫描器,能准确识别关键字、标识符、整数常量、运算符(如+、-、*、/)、分隔符(括号、分号、逗号等)等基本词法单元,并输出标准二元组序列(类别码, 属性值)。资源包含可直接运行的Java源代码、编译后的class文件、打包好的jar程序(Windows/Linux双平台兼容),以及清晰的状态转换图PDF说明,帮助理解自动机构建逻辑。提供多个测试文件,覆盖合法输入、空格缩进、注释、边界情况(如超长标识符、非法字符、数字开头的标识符等),便于验证分析器鲁棒性。配套有实验要求文档(.docx格式),明确说明实验目标、输入输出格式规范、错误处理要求及评分要点;另附readme.txt快速上手指南,说明编译命令、运行方式和常见问题排查方法。所有内容紧扣教学实践,适合作为课程作业参考、自学练习或实验报告支撑材料。
&spm=1001.2101.3001.5002&articleId=162253699&d=1&t=3&u=1dea01613b1a4cf3b632059ce51c09ba)

被折叠的 条评论
为什么被折叠?



