如果你在学校里学过《编译原理》,大概率会碰上这样一门硬核课:理论课讲正则表达式、自动机、文法推导,实践课让你在一个在线判题系统上做练习和实验。我第一次做编译原理的在线练习与实验时,最大的感受是:上课听懂了,一到做题就懵。教材里的状态转移图画得很漂亮,可真要自己动手写一个能通过评测的词法分析器,又是另一回事。
这篇文章就把我从“看得懂教材”到“跑通评测”的完整过程整理出来,包括实验一涉及的词法分析核心原理、状态机怎么落地成代码、在线评测平台有哪些防不胜防的坑,以及一套可以直接照着写的自查清单。想把这门课的第一阶段作业拿下,或者想把自动机理论真正变成代码的老同学,可以拿来当参考。
1. 编译原理实验第一关到底在考察什么
先说我上手前的误区。我以为编译原理实验第一关就是把课本上的词法分析例子抄一遍,比如识别几个关键字、几个运算符,能跑就行。实际上,在线练习与实验1的内容远不止“写个词法分析器”这么简单,它把理论课的多个知识点串在了一起,基本包括下面这几类任务。
第一类是文法与正则表达式的基础题。例如给你一个语言定义,让你写出对应的正则文法,或者反过来,把一个正则表达式转成NFA。这类题看起来是送分题,实际上很容易在细节上失分,比如运算符优先级没考虑,空串的处理漏掉,或者状态转换的终止状态标错。
第二类是构造确定型有穷自动机。最常见的要求是识别无符号整数、浮点数、科学计数法、标识符、关键字这些词法单元。很多同学直接按照教材里的模板画一个总状态图,但在线评测会给出非常刁钻的测试用例,比如.5和5.这种边界写法,要求你能明确说明接受还是不接受。这个阶段比的不是会不会画大框架,而是能不能把状态划分到足够细。
第三类是手写词法分析程序,这也是实验1的重头戏,需要你真正实现一遍扫描源程序、切分出token的过程。评测系统会喂入若干组源程序片段,然后比对程序输出的token序列。这类题的难点在于:不只是主流程要正确,连行号、列号、错误提示的格式都要完全匹配,多输出一个空格可能都是错误。
还有一点容易被忽略的是实验报告的书写。很多练习实验会要求提交代码之外,还要写文档,说明自动机的设计思路、状态转换表、测试样例和结果分析。平台一般会留提交报告的入口,这部分分值其实很可观,不要因为代码写得好就轻视文档。
我个人的理解是,实验1不是单纯为了写一个能跑的小程序,而是要你把“正则文法 → NFA → DFA → 最小化DFA → 代码实现”这条链路亲手走一遍。理解了这条链路,后续的语法分析实验中那些递归下降、LR分析,学起来就没那么飘了。
2. 在线评测平台的两个隐藏玩法和三个最常见失分点
我用的在线平台是学校统一接入的某课程实训系统,页面设计比较朴素,左边是题目描述,右边是代码编辑区或者答案提交区,提交之后系统会自动编译运行并给出结果。很多同学第一次用,只关注“代码对不对”,忽略了这个平台本身的特殊要求。
2.1 隐藏玩法一:多测试点评测,错误信息很“粗”
在线平台通常把测试数据分成多个测试点,每个测试点是一个独立的输入文件,对应一组测试输入。它会告诉你“第2个测试点答案错误”,但不会告诉你是谁输错了,更不会给你看完整的输入输出对比。
所以写完代码,别急着反复提交瞎猜,先把所有能想到的边界情况在自己的机器上测一遍。尤其是那些特别短的输入,比如空文件、只有一个换行符、只有一个EOF的情况,评测平台上经常设置成隐藏测试点,专门坑那些只测正常样例的同学。
2.2 隐藏玩法二:输出格式是“死”的
这一点非常关键。题目描述里会给出要求,例如每个token输出格式为<type,value>或者(line, type, value),中间用什么分隔符、末尾要不要换行、字符串是否需要用引号包裹,都必须在提交前仔细核对。
我自己就栽过一次:要求输出 token 的 value 时原样保留内容,也就是字符串不带引号,我在代码里习惯性加了引号,结果一半的测试点全崩了。还有一种是要求按行号升序输出,行号相同按列号升序,忽略这个次序规则也会直接吞掉好几个测试点。
2.3 最容易丢分的三个地方
先给新手看一个常见失分点清单,后面再展开讲。
| 失分场景 | 典型表现 | 原因 |
|---|---|---|
| 标识符误判为关键字 | “intx”应该是一个标识符,却被当成关键字 int 加标识符 x 处理 | 切分时没有采用最长匹配,先把前缀给匹配了 |
| 浮点数边界写错 | “3.” 到底认不认?平台可能坚定地认为是“3”和出错 | 状态图里没有定义小数点后必须有数字,或定义了但状态转移没实现 |
| 错误恢复方式不对 | 遇到非法字符,程序直接退出或死循环 | 没有设计从错误状态回退并继续扫描的逻辑 |
特别是第一点,很多人写词法分析时习惯把关键字表直接放在标识符判断之前,先比较前缀。正确的是先按标识符的规则拼出完整单词,再查关键字表,如果完全相同才认定为关键字,否则就是普通标识符。这个顺序一旦反了,评测会很有意见。
3. 词法分析实验的从零实现:从状态图到C代码
接着我用一道非常经典的词法分析实验题来演示全流程:要求识别无符号数、标识符、关键字、若干运算符与界符。这道题来自某课程实训平台编译原理实验模块,代码框架也不复杂,但它把人逼到了很多细节里去。
3.1 第一步:先画状态转换图,别急着敲键盘
我在做实验时习惯先在纸上画状态图。你要识别的无符号数,可以包含整数和小数两种形式,通常状态图里至少要区分:整数部分、小数点、小数部分。如果题目还要求科学计数法,比如1e-5,那还要加上指数符号e/E、指数符号后的正负号、指数部分的数字。
不要小看这张图。判断“3.”是否合法,完全取决于状态图里“小数点之后必须有数字”这个约束。若题目描述里说“无符号数由数字和小数点组成,小数点前或后必须有数字”,那么3.合法,.5也合法,但3.后面的状态的终止标志要标对。很多同学把“小数点后必须跟数字”实现成了“小数点后可以有数字也可以直接结束”,这就会导致测试点判错。
3.2 第二步:基于状态转换表驱动扫描
等状态图确定之后,写代码最稳妥的方式是“表驱动”,也就是定义一个二维状态转移表,行是当前状态,列是当前读入字符所属的类别,表项是下一个状态。这样写出来的代码结构清晰,后面要加新的识别类型,只需要改表、改分类函数,不需要重写扫描逻辑。
代码骨架类似这样。以C语言为例,先定义状态枚举:
typedef enum { STATE_START, STATE_IN_INT, STATE_IN_FRAC, STATE_DOT, STATE_IN_EXP, STATE_EXP_SIGN, STATE_IN_EXP_NUM, STATE_DONE, STATE_ERROR } State;然后是字符分类函数,把输入字符归入若干类,比如数字、字母、小数点、正负号、空白、运算符、其他。转移表和状态枚举对应。
int getCharType(char ch) { if (ch >= '0' && ch <= '9') return CHAR_DIGIT; if ((ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z')) return CHAR_LETTER; if (ch == '.') return CHAR_DOT; if (ch == '+' || ch == '-') return CHAR_SIGN; if (ch == ' ' || ch == '\n' || ch == '\t') return CHAR_BLANK; return CHAR_OTHER; }主扫描循环就是不断读字符、查转移表、更新状态,同时记录 token 起点和当前字符串内容。这里要特别注意“最长匹配”的处理方式:每当到达一个可接受状态时记录下当前位置作为“最好的终点”,继续往后读,只有当读入下一个字符后进入错误状态,才回退到记录的位置并输出token。
打个比方,这就好比在自助餐厅拿餐,看到喜欢的菜先夹到盘里,但眼睛继续往前看,发现前面还有更好的炸鸡腿,那就在回头去夹。数据处理上,不是真的回头,而是把token的右边界指针回拨到上次“可接受状态”的位置。
3.3 第三步:错误恢复和关键字表分离
词法分析器在遇到非法字符时绝对不能整个程序崩溃。常见的做法是,在错误状态记录错误信息,把这个非法字符跳过,然后回到起始状态继续扫描。这个机制保证了后续的token还能继续被识别出来,否则一个非法字符就能让评测程序在后面的测试点全部卡死。
关键字表的匹配顺序要放在完整的标识符识别出来之后:
if (isKeyword(tokenText)) { return TOKEN_KEYWORD; } else { return TOKEN_IDENTIFIER; }这个逻辑非常简单,但确实是最不容易出错的实现方式,推荐同学们直接采用。
4. 实训平台在线评测的边界测试点与调试技巧
我做完一版能跑的实现之后,提交到评测系统,本以为能一遍过,结果连续被几个测试点打回来。这里把我后来总结出的边界测试点清单分享出来,正好也帮大家少走一些弯路。
4.1 必测的输入样例集合
不管题目具体是什么,下面这些样例都应该自己先跑一遍,因为它们几乎出现在所有词法分析题的隐藏测试里。
- 纯空文件:只有EOF
- 只有空白字符:比如空格、换行、制表符混合
- 只有一个标识符:比如
abc - 关键字紧挨运算符:比如
int a=3; - 注释与代码之间无空格:比如
/*comment*/int - 字符串与代码之间的空白:比如
"hello" + x - 很长的标识符,超出预定缓冲区长度
- 文件中最后一行没有换行符
这些用例看着简单,但最容易暴露状态转移和边界条件处理的问题。比如“纯空文件”,如果初始化逻辑里没有处理“直接读到EOF”的情况,可能会输出一个空token导致格式错乱。再比如“最后一行没有换行符”,如果用fgets按行读又有状态残留,也可能出错。
4.2 两种调试工具,治好了我的眼睛
排错时第一件事不是去网上搜答案,而是善用本地构造样例和打印调试信息。
第一种工具是自己写的小型样例生成器,把可能出错的极端情况拼到一个文件里,反复跑自己的分析器,看输出和预期是否一致。第二种是给分析器加一个-debug开关,输出每一步的状态变化和读入字符。这样就能清楚地看到程序在哪个字符、哪个状态下出现了非预期转移。
有时候状态图里想的是一回事,代码写出来又是另一回事。比如从整数的终态读到小数点时,应该留在整数状态还是进入小数状态?答案可能是进入专门的小数状态,而不是直接输出5这个token。用Debug模式一眼就能看出是哪一步跳错了。
4.3 Windows与Linux的换行符之争
我在本地用Windows环境试验时,程序跑得好好的,一提交到云端就提示某些用例错误。后来发现是换行符差异:本地文件是\r\n,而评测系统里是\n。我的字符分类函数把\r当作普通字符,导致它出现在token里,直接污染了输出。
解决办法有两种:一种是在扫描时忽略\r,另一种是在处理空白字符时把\r和\n都归入空白类。千万不要依赖环境自动转换,因为在线评测用的是Linux容器,代码里写的文件读取逻辑必须对两者都健壮。
这里也提醒一句:如果读入方式选择按getchar()逐个读,那么天然不用关心文件的换行符表示,但如果按行fgets读取,\r会留在行尾。理清这个差异,会省掉很多无谓的失分。
5. 实验1的代码设计与常用结构对比
一开始我用了一堆全局变量,main函数里写了两百行逻辑,结果改一个地方就要连带改三处。后来重构了一版,把词法分析器拆得更干净,用起来也顺手很多。针对难度不高的实验1,我整理了三种可行的代码组织方式,区别还挺明显。
| 实现方式 | 优点 | 缺点 | 适合场景 |
|---|---|---|---|
| 纯手写状态分支(if/switch) | 直观,逻辑全在明面上 | 状态一多就变成一坨,难维护 | 只识别五六种简单token |
| 状态转移表 + 驱动循环 | 结构清晰,新增状态只改表 | 要先花时间设计表和分类 | 大多数词法分析实验题 |
| 正则表达式生成器(如lex/flex) | 写规则极快,自动生成分析器 | 实验课往往不允许,且平台不支持 | 工程实践,不在线提交的环境 |
我在实验1里选的是第二种。状态转移表的分量不重,但逻辑全部集中在驱动循环里,维护起来很轻松。
代码主循环如下:
while (1) { ch = getNextChar(); State next = stateTable[currentState][getCharType(ch)]; if (next == STATE_INVALID) { if (isFinalState(currentState)) { // 回退,输出 token,重新从上一位置扫描 rollback(); outputToken(); currentState = STATE_START; // 注意:当前这个字符需要重新处理,不能直接丢掉 continue; } // 真正的非法字符,记录错误并跳过 reportError(ch); currentState = STATE_START; } else { appendToBuffer(ch); currentState = next; } }要特别留意的是回退的逻辑。当遇到下一个字符不在合法转移集合里时,说明当前token已经结束,但这个“下一个字符”还没有被消费。最简单可靠的方式是用一个unread操作把这个字符塞回输入流,或者用一个变量保存它、在下一次循环开头直接用,不要把它丢掉。
有一种常见的写法问题:在读取越界字符时直接break出循环,导致最后一个token没有被检查是否可以接受就丢失了。正确做法是,读到EOF时也要先检查当前状态是否可接受,如果可接受就先输出最后一个token,再退出。
6. 从实验1延伸出来的后劲:这些设计将来还能用在哪儿
实验1做完之后,代码先别急着扔。我后来发现,实验1里的词法分析器不仅仅是应付一个阶段作业,它其实是整个编译器前端的地基。后面做语法分析实验的时候,很多痛苦其实都来自词法分析器当时没有设计好。
6.1 token类型设计影响后续语法分析的代码量
第一个直接的影响是token的类型划分。如果当时只是简单地把所有运算符都算作一个<运算符>类型,后面写递归下降分析器的时候,区分=和==还是+与++就非常费劲。建议在实验1就把每种运算符和界符分开定义独立类型,比如TOKEN_ASSIGN、TOKEN_EQUAL、TOKEN_PLUS、TOKEN_INC。乍一看有点繁琐,但后面写语法分析的token匹配时,可以直接对着类型判断,少写很多字符串比较。
当然也要看题目要求。如果评测系统明确要求输出<操作符, +>这种格式,那你势必要在value里存运算符文本;如果输出是<类型>,那最好使用独立类型。无论如何,内部的数据结构值得做细一点。
6.2 位置信息的记录是隐藏加分项
很多实验1的评测要求里没有明显提到行号列号,但后续实验会要求定位语法错误,或者在报错信息里给出出错行列。如果在词法分析阶段就顺手记录了每个token的行号和列号,后面语法阶段会很省事。
记录的方式很简单:维护一个行计数器,遇到\n自增;维护一个列计数器,遇到\n归零。每当一个token的起点确定时,把它当时对应的行列存入token结构体里。这个改动量不大,也就是在输出缓存里加上两个整数,但价值不小。
6.3 错误信息与错误恢复策略的一致性
有些同学在实验1里遇到非法字符就打印一行提示“unknown character”,评测也能过。可是到了语法分析实验,源程序里可能包含各种奇怪的字符(比如中文全角符号),如果词法阶段只报错不说明位置,后续的调试会变得非常痛苦。建议实验1里就把错误信息统一为Line <line>: unexpected character '<ch>',这样不仅评测能接受,自己的调试体验也会好很多。
我当时就吃了这个亏:实验1为了省几行代码,错误提示只写了“error”,没有任何位置信息。结果实验2做语法分析时,面对几千行的测试输入,每次报错都不知道在哪一行,只能用二分法手动查,效率极低。
7. 附一个自查清单,提交前过一遍
根据我自己迭代多次的经验,整理了一个“提交前五分钟确认单”,每次新实验开始时先对照着过一遍,能避免大量无谓的提交失败。
| 检查项 | 具体做法 |
|---|---|
| 输出格式逐字核对 | 对照题目示例,重点看分隔符、末尾换行、字符串是否加引号、是否输出行号列号 |
| 关键字判断顺序 | 先拼完整标识符再查表,禁止前缀匹配 |
| 边界输入测试 | 空文件、纯空白、最后一行无换行、只有一行注释 |
| 换行符处理 | 确认\r已被忽略或归类为空白 |
| EOF处理 | 最后一个token在EOF来临时要能正常输出,不能丢 |
| 非法字符后恢复 | 报错之后能否继续识别后面的合法token |
| 最长匹配实现 | 遇到非法转移时能否回退到最近的可接受状态 |
这套自查清单不只适用这一道题,后面几乎每个实验都通用。哪怕题目换了、语言换成Java或Python,思路也一样:输出格式永远是第一拷问点,其次是状态边界,然后是恢复机制。
写到这里,想起我做实验1时的那次“生死三小时”。眼看评测系统上一片红,自己却不知道哪里错,最后发现就是关键字表顺序问题和.5那个小数点的合法性。当时的感觉是:编译原理的实验,本质上是在逼着你把每一个状态、每一个边界条件都想清楚,语言本身倒不是重点。
如果你也在做类似的第一阶段实验,别急着怀疑教材原理没看透,先把状态图画清楚、把边界样例跑全,再盯着输出格式逐字抠一遍。这门课的残酷之处在于,它不允许“差不多就行”的模糊态度,但这也正是它能让你真正体会到“计算机是严谨科学”的原因所在。