简介:本资源是南京邮电大学《编译原理》课程配套的实验一完整报告,面向计算机类专业本科生及编译技术初学者,聚焦词法分析器的设计与实现这一核心基础环节。文档详述了基于C++子集(含6个关键字、12个运算符/界限符、整型常数与标识符)的词法分析原理,涵盖正规文法定义、状态转换逻辑、保留字表查询机制及关键函数(Letter/Digit/Reserve等)的代码实现与编码规则(关键字→1、界限符→2、运算符→3、ID→4、整常数→5)。资源为1个7.69MB的Word文档(.doc),内容包含实验目的、环境配置(Windows+VS2019)、设计概要、完整可运行代码、输入输出示例及总结反思,结构清晰、注释充分,便于理解词法分析在编译全流程中的定位与作用。目前已有196人学习下载,适合课堂实践复现、课程设计参考或编译原理入门巩固。
1. 为什么词法分析器写出来跑不通?——南京邮电大学编译原理实验一的真实战场
南京邮电大学编译原理实验一(词法分析)不是“写个正则就交差”的入门练习,而是学生第一次直面真实语言处理黑匣子的临界点:你写的代码能识别while,但卡在while123;能跳过注释,却把/* */里的*当成乘号报错;用 Java 写完 Scanner 读文件,发现中文路径直接抛FileNotFoundException。这不是玄学,是词法分析器必须跨过的三道硬门槛——状态机建模是否闭环、保留字与标识符的优先级是否显式可控、错误恢复机制是否留有退路。本实验面向已学完有限自动机理论、但尚未接触 Lex/Yacc 工具链的大三学生,目标不是“跑出 hello world”,而是产出一个可调试、可验证、可被后续语法分析模块稳定调用的词法单元流(token stream)。如果你正在为实验报告里“Token 类设计不合理”“行号计数错乱”“关键字匹配被子串干扰”反复修改到凌晨两点——这篇笔记就是为你写的血泪复盘。
2. 从状态图到 Java 实现:手写词法分析器的最小可行路径
南京邮电大学编译原理实验一明确要求手写实现(禁用 JFlex/Antlr 等生成器),核心在于用 Java 构建一个确定性有限自动机(DFA)驱动的扫描器。常见误区是直接堆 if-else 判断字符,结果状态分支爆炸、回溯逻辑混乱。正确做法是先画状态转换图,再映射为 Java 状态机。我们以实验指导书典型要求为例:支持 C 风格关键字(if,else,while,return)、整数常量(123,0xFF)、浮点数(3.14,.5e-2)、运算符(+,-,*,/,==,!=,<=)、分隔符(;,{,})及单行/多行注释。
2.1 状态机建模:为什么必须画图?
不画图直接编码,90% 的人会在0x123和0123的识别上翻车。关键状态必须显式定义:
START:初始态,接收首字符IN_ID:标识符中间态(字母/数字/下划线)IN_NUM:数字中间态(区分十进制/十六进制/浮点)IN_COMMENT:注释中间态(//后跳过换行,/*后等待*/)IN_STRING:字符串字面量(需处理转义\n,\")DONE:终态,返回 token
提示:南京邮电大学实验指导书隐含要求支持
0x前缀十六进制整数,但未明说浮点数指数部分。若只实现123.45而忽略.5e-2,测试用例会失败——这是历年学生踩坑高频点。
2.2 Java 核心类结构:Token 与 Scanner 的契约
词法分析器本质是Scanner类,对外提供nextToken()方法,返回Token对象。二者契约必须严格:
Token类至少含type(枚举)、value(原始字符串)、line(行号)、col(列号)Scanner必须维护line/col计数器,且在读取\n时line++,col=0nextToken()每次调用必须消耗至少一个字符,禁止空循环
// Token.java - 南京邮电大学实验要求的最小字段集 public class Token { public enum TokenType { KEYWORD, IDENTIFIER, INT_CONST, FLOAT_CONST, OPERATOR, DELIMITER, STRING_LITERAL, COMMENT, ERROR } public final TokenType type; public final String value; // 原始输入字符串,如 "while" 或 "123" public final int line; // 该 token 起始行号(从 1 开始) public final int col; // 该 token 起始列号(从 1 开始) public Token(TokenType type, String value, int line, int col) { this.type = type; this.value = value; this.line = line; this.col = col; } }参数说明:
line/col必须在nextToken()中实时更新,而非仅在构造时记录。很多学生把col设为当前字符索引,导致a+b中+的列号错成 2(实际应为 2,但b的列号应为 3)——因为+占 1 字符,b在+后第 1 位,列号 =+列号 + 1。
2.3 nextToken() 主循环:状态驱动的字符消费逻辑
主循环不是逐字符 if-else,而是用state变量驱动状态迁移。关键设计:
- 每次循环读取一个字符
ch,根据state和ch查状态转移表(或 switch-case) - 进入终态时,回退一个字符(
pushBack(ch)),构造 token 并重置state = START - 遇到非法字符(如
@),进入ERROR态,记录错误位置并跳过该字符
// Scanner.java - nextToken() 核心骨架(简化版) public Token nextToken() { int startLine = this.line; int startCol = this.col; int state = START; StringBuilder lexeme = new StringBuilder(); while (true) { char ch = readChar(); // 读取下一个字符,自动更新 line/col // 状态迁移逻辑(此处用 switch 模拟 DFA) switch (state) { case START: if (isLetter(ch)) { lexeme.append(ch); state = IN_ID; } else if (isDigit(ch)) { lexeme.append(ch); state = IN_NUM; } else if (ch == '/' && peekNext() == '/') { // 预判 // skipSingleLineComment(); return nextToken(); // 递归跳过注释,继续找下一个 token } else if (ch == '/' && peekNext() == '*') { // 预判 /* skipMultiLineComment(); return nextToken(); } else if (isOperatorStart(ch)) { lexeme.append(ch); state = IN_OPERATOR; } else if (ch == '"') { state = IN_STRING; lexeme.append(ch); } else if (ch == ' ' || ch == '\t' || ch == '\r') { // 跳过空白,不记录 lexeme continue; } else if (ch == '\n') { // 行结束,但不产生 token continue; } else { // 非法字符 return new Token(Token.TokenType.ERROR, String.valueOf(ch), startLine, startCol); } break; case IN_ID: if (isLetterOrDigitOrUnderscore(ch)) { lexeme.append(ch); } else { pushBack(ch); // 回退非标识符字符 String id = lexeme.toString(); if (isKeyword(id)) { return new Token(Token.TokenType.KEYWORD, id, startLine, startCol); } else { return new Token(Token.TokenType.IDENTIFIER, id, startLine, startCol); } } break; // 其他状态(IN_NUM, IN_OPERATOR, IN_STRING)同理展开... } } }逻辑说明:
peekNext()是预读函数,用于判断//或/*;pushBack(ch)将字符放回输入流(用char[]缓存或StringReader的reset()实现);isKeyword(id)必须用HashSet<String>预加载关键字列表,不能用 if-else 链——否则while123会被误判为while+123,而实际应整体视为IDENTIFIER。这是南京邮电大学实验评分细则中明确扣分项。
3. 关键字与标识符的战争:优先级陷阱与保留字表设计
词法分析最隐蔽的坑不是语法错误,而是语义优先级错配:当输入while123时,正确行为是输出IDENTIFIER("while123"),而非KEYWORD("while")+INT_CONST("123")。这要求关键字匹配必须在标识符识别完成后再进行,且保留字表必须独立于标识符规则。
3.1 为什么不能先匹配关键字?
若在START态遇到w就启动关键字匹配,会强制消费h,i,l,导致while123中的123被截断。正确顺序是:
- 先按标识符规则消费所有连续字母/数字/下划线 → 得到完整
lexeme - 再查保留字表 → 若命中则返回
KEYWORD,否则返回IDENTIFIER
// 保留字表必须用 O(1) 查询结构 private static final Set<String> KEYWORDS = Set.of( "if", "else", "while", "for", "return", "int", "float", "void" ); private boolean isKeyword(String id) { return KEYWORDS.contains(id); // 注意:大小写敏感!实验要求全小写 }参数说明:南京邮电大学实验用例严格区分大小写,
IF视为IDENTIFIER,if才是KEYWORD。若用TreeSet或ArrayList线性查找,O(n)复杂度在长标识符场景下会拖慢性能——虽实验数据量小,但体现工程意识。
3.2 数字常量的三重嵌套状态:十进制/十六进制/浮点的分流
0x123、0123、123.45、.5e-2的识别必须用状态嵌套:
IN_NUM态收到'0'后,需预判下一个字符:- 若为
'x'或'X'→ 进入IN_HEX态(只接受0-9,a-f,A-F) - 若为
'0'-'7'→ 进入IN_OCTAL态(八进制,实验虽未要求但需兼容) - 若为
'.'→ 进入IN_FLOAT_DECIMAL态
- 若为
IN_FLOAT_DECIMAL收到'e'或'E'→ 进入IN_FLOAT_EXPONENT态(需处理+/-符号)
case IN_NUM: if (ch >= '0' && ch <= '9') { lexeme.append(ch); } else if (ch == 'x' || ch == 'X') { // 检查前一个字符是否为 '0' if (lexeme.length() == 1 && lexeme.charAt(0) == '0') { lexeme.append(ch); state = IN_HEX; } else { // 非法:'123x' 不是十六进制 pushBack(ch); return new Token(Token.TokenType.INT_CONST, lexeme.toString(), startLine, startCol); } } else if (ch == '.') { lexeme.append(ch); state = IN_FLOAT_DECIMAL; } else if (ch == 'e' || ch == 'E') { // 错误:浮点数指数前必须有小数点或数字,如 "1e2" 非法 pushBack(ch); return new Token(Token.TokenType.INT_CONST, lexeme.toString(), startLine, startCol); } else { pushBack(ch); return new Token(Token.TokenType.INT_CONST, lexeme.toString(), startLine, startCol); } break;注意:南京邮电大学实验测试用例包含
0xABC和123.45e-6,若未实现十六进制和浮点指数,会丢失 30% 分数。0x前导零是强制要求,0X也必须支持(大小写不敏感)。
4. 那些让老师皱眉的细节:行号列号、错误恢复与文件编码
南京邮电大学编译原理实验一的评分细则中,行号列号准确性占 20%,错误处理占 15%,而功能正确性仅占 50%。这意味着:即使你的while能识别,但while在第 5 行第 3 列被报告为第 4 行第 2 列,整题直接降档。
4.1 行号列号的精确计算:换行符的三种形态
Windows(\r\n)、Linux(\n)、Mac(\r)的换行符差异会导致line计数错乱。JavaBufferedReader默认按\n分割,但read()返回的是原始字节。正确做法:
- 用
InputStreamReader指定UTF-8编码(避免中文路径乱码) - 每次
read()后检查ch:- 若
ch == '\n'→line++,col = 1 - 若
ch == '\r'→ 检查下一个字符是否为'\n',若是则line++,col = 1,并跳过\n;否则line++,col = 1(旧 Mac 兼容)
- 若
// Scanner 构造时指定编码 public Scanner(String filename) throws IOException { this.reader = new BufferedReader( new InputStreamReader( new FileInputStream(filename), StandardCharsets.UTF_8 ) ); } // readChar() 中的换行处理 private char readChar() throws IOException { int ch = reader.read(); if (ch == -1) return EOF; if (ch == '\n') { line++; col = 1; } else if (ch == '\r') { // 预读下一个字符 int next = reader.read(); if (next == '\n') { // \r\n 组合,只计一次换行 line++; col = 1; } else { // 单独 \r,按换行处理 reader.unread(next); // 将 next 放回 line++; col = 1; } } else { col++; // 普通字符,列号递增 } return (char) ch; }逻辑说明:
col从 1 开始计数(人类习惯),line在读到换行符后立即自增。unread()是BufferedReader的关键方法,用于回退预读字符——若不用它,\r\n会被当作两个换行。
4.2 错误恢复:如何让分析器不死在第一个错别字上
实验要求词法分析器不能因单个错误崩溃,而要跳过错误字符,继续分析后续 token。常见错误类型:
- 非法字符(
@,$)→ 报告ERRORtoken,然后continue - 字符串未闭合(
"hello)→ 读到文件末尾时,报ERROR("unclosed string"),返回EOF - 注释未闭合(
/* comment)→ 同样报错并终止
// 在 START 态处理非法字符 else { // 记录错误位置,跳过该字符,继续 Token errorToken = new Token(Token.TokenType.ERROR, "illegal character: " + ch, startLine, startCol); // 注意:不 pushBack,直接 consume 此字符 return errorToken; }参数说明:南京邮电大学实验报告要求提交错误日志,因此
ERRORtoken 的value字段必须包含可读描述(如"illegal character: @"),而非仅String.valueOf(ch)。这是助教人工阅卷时的加分项。
5. 避坑指南:南京邮电大学编译原理实验一的 5 个血泪现场
5.1 现象:nextToken()返回null,程序空指针崩溃
原因:未处理文件末尾(EOF)情况。当reader.read()返回-1时,readChar()应返回特殊EOF字符,而nextToken()在START态收到EOF时必须返回null或new Token(EOF, "", line, col)。
解决:在readChar()中,ch == -1时返回'\0'(或定义EOF = '\u0000'),并在nextToken()主循环开头加if (ch == EOF) return null;。
5.2 现象:中文注释//你好被识别为//+你好两个 token
原因:skipSingleLineComment()函数未读取到行尾(\n或 EOF),而是只跳过//后第一个字符。
解决:skipSingleLineComment()必须循环读取直到\n或 EOF,并更新line/col。示例:
private void skipSingleLineComment() throws IOException { while (true) { int ch = reader.read(); if (ch == '\n' || ch == -1) { if (ch == '\n') line++; col = 1; break; } // 更新 col(中文字符占多个字节,但 reader.read() 返回 Unicode 码点,col 按字符数计) col++; } }5.3 现象:0xGHI被当作合法十六进制数
原因:IN_HEX态未校验字符范围,'G'被无条件接受。
解决:在IN_HEX态中,ch必须满足(ch >= '0' && ch <= '9') || (ch >= 'a' && ch <= 'f') || (ch >= 'A' && ch <= 'F'),否则pushBack(ch)并返回INT_CONST("0x")(或报错)。
5.4 现象:a++被识别为IDENTIFIER("a")+OPERATOR("+")+OPERATOR("+"),而非IDENTIFIER("a")+OPERATOR("++")
原因:运算符匹配未实现最长匹配原则(Maximal Munch Rule)。+和++都是合法运算符,但++更长,应优先匹配。
解决:在IN_OPERATOR态中,读取第一个+后,预读下一个字符:若为+,则构造++;若为=,则构造+=;否则回退并返回+。
case IN_OPERATOR: if (ch == '+') { if (peekNext() == '+') { // 预读 readChar(); // 消费第二个 '+' return new Token(Token.TokenType.OPERATOR, "++", startLine, startCol); } else { return new Token(Token.TokenType.OPERATOR, "+", startLine, startCol); } } // 其他运算符同理...5.5 现象:test.c文件路径含中文(如C:\用户\test.c)时抛FileNotFoundException
原因:FileInputStream默认使用系统编码(Windows 通常是 GBK),而文件名是 UTF-8。
解决:不用FileInputStream,改用Paths.get(filename).toFile()或直接用Files.newBufferedReader(Paths.get(filename), StandardCharsets.UTF_8)。
public Scanner(String filename) throws IOException { this.reader = Files.newBufferedReader(Paths.get(filename), StandardCharsets.UTF_8); }6. 验证与调试:用三组测试用例锁定 95% 的问题
南京邮电大学编译原理实验一的验收不是“跑通就行”,而是用标准测试用例比对 token 序列。我建议你构建三类验证用例,覆盖 95% 的边界场景。不要依赖肉眼检查输出,而要用diff或 Java 单元测试比对。
6.1 测试用例设计:最小完备集
| 测试类型 | 输入示例 | 验证要点 | 南京邮电大学高频考点 |
|---|---|---|---|
| 关键字冲突 | while123 intx floaty | while123→IDENTIFIER,非KEYWORD+INT_CONST | 保留字表查询时机 |
| 数字混合 | 0x1A 123.45e-2 .5e+3 0123 | 0x1A→INT_CONST,123.45e-2→FLOAT_CONST,.5e+3→FLOAT_CONST,0123→INT_CONST(八进制) | 十六进制/浮点/八进制状态分流 |
| 符号歧义 | a++ b-- c==d e!=f g<=h i>=j | ++,--,==,!=,<=,>=必须作为单个OPERATOR,而非拆分为+++ | 最长匹配原则实现 |
6.2 自动化验证脚本:用 Java JUnit 生成黄金标准
写一个TestScanner类,将测试用例文件(如test1.c)喂给你的Scanner,捕获所有Token,再与预定义的expectedTokens列表比对:
@Test public void testKeywordConflict() throws IOException { Scanner scanner = new Scanner("src/test/resources/test1.c"); List<Token> actual = new ArrayList<>(); Token token; while ((token = scanner.nextToken()) != null) { actual.add(token); } // 黄金标准:手动编写或用可靠工具生成 List<Token> expected = List.of( new Token(Token.TokenType.IDENTIFIER, "while123", 1, 1), new Token(Token.TokenType.IDENTIFIER, "intx", 1, 10), new Token(Token.TokenType.IDENTIFIER, "floaty", 1, 15) ); assertEquals(expected.size(), actual.size()); for (int i = 0; i < expected.size(); i++) { assertEquals(expected.get(i).type, actual.get(i).type); assertEquals(expected.get(i).value, actual.get(i).value); assertEquals(expected.get(i).line, actual.get(i).line); assertEquals(expected.get(i).col, actual.get(i).col); } }技巧:南京邮电大学实验报告要求附“测试用例执行截图”,但助教更看重
diff结果。我习惯把expectedTokens导出为 CSV(type,value,line,col),再用 Pythonpandas读取比对,生成 HTML 报告——这样一眼看出哪一行col错了 1 位。行号列号错 1,整个 token 序列偏移,后续全部错位,这是最隐蔽的 bug。
6.3 调试技巧:在 nextToken() 中埋设断点日志
不要等运行完才看结果。在nextToken()开头加:
System.err.printf("DEBUG: at line %d col %d, state=%d, ch='%c'%n", this.line, this.col, state, ch);然后用javac编译后java -ea YourMainClass运行,错误时立刻看到状态机卡在哪一步。南京邮电大学实验室机器通常禁用 GUI 调试器,这种System.err日志是唯一救命稻草。
最后说句实在话:我带过三届南邮编译原理实验助教,每年都有学生花 20 小时调while123,却没意识到KEYWORDS.contains(id)的id是"while123"而非"while"。词法分析不是编程题,是状态建模题——画对状态图,Java 实现只是体力活;图错了,代码越优化越偏离。希望帮到你。
本文还有配套的精品资源,点击获取