编译原理课的课设任务里,"词法分析器"往往是最容易被低估的老大难。我拿到"希冀编译原理词法分析器完全版"这个项目时,第一反应是:不就是把一串字符串拆成一个个token吗?可真到了在线评测系统上反复提交、一次次被红叉打回来之后,我才明白"完全版"这三个字的重量。
这篇文章,我把整个完成过程复盘一遍:从需求拆解、方案选型、核心代码实现,到评测调试和踩坑记录,尽量写清楚每一步背后的"为什么"。无论你是正在为编译原理课设发愁,还是单纯想弄清楚词法分析器到底是怎么工作的,这份记录应该都能让你直接照抄、少走弯路。
1. 项目到底要做什么:需求拆解与目标解析
1.1 词法分析器在编译器里的位置
几乎所有编译器教材都会画这样一张图:源程序 → 词法分析 → 语法分析 → 语义分析 → 中间代码生成 → 优化 → 目标代码生成。词法分析处于最前面,它的任务说穿了就是一件事:把源代码这个字符流,按规则切分成一个个有意义的"单词",也就是token。
你可以把它理解成中文分词。"今天天气真好"这句话,人一眼就知道切成"今天/天气/真好";但计算机不认识。词法分析器就是给计算机装上这种"切词"能力。它读入原始字符,输出一串token流,每个token包含两个关键信息:这是什么类型(关键字、标识符、数字、运算符还是分隔符),以及它对应的文本内容是什么。
为什么第一步这么重要?因为后面所有阶段的解析,都不再关心字符层面的事情,只针对token工作。哪怕是最简单的语法分析,如果词法阶段给出的token类型错了、漏了、或者多了,后面全盘崩溃。这也是评测系统考察"完全版"的原因:它要的不是一个能跑通hello world的玩具,而是一个覆盖完整token类型、能处理边界情况、遇到错误不崩掉的东西。
1.2 "完全版"三个字的真实含义
项目标题里的"完全版",我一开始没当回事,后来被评测系统教育得很深刻。它至少包含这四层意思:
第一,token类型要覆盖全。如果只识别标识符和数字,那叫半成品。一个像样的C语言子集词法分析器,至少得覆盖:关键字、标识符、数字常量(整数、浮点数,甚至还可能有科学计数法)、字符串常量、字符常量、运算符(算术、关系、逻辑、赋值、自增减)、分隔符(括号、分号、逗号、花括号等)。有些题目还会把注释也列入要求,虽然注释最终不产生token,但你必须正确跳过。
第二,规则要符合最长匹配原则。什么意思?遇到>=这个输入,你不能把它拆成>和=两个token,而要整体识别为>=。遇到++、+=、<<、&&这类连续字符组成的算符,都要按最长匹配原则处理。这是词法分析器评测用例里最喜欢考查的点,也是初次实现时最容易翻车的地方。
第三,要能处理各种边界情况。比如末尾没有分号的代码、数字后面紧跟字母、字符串中有转义字符、注释跨越了多行、空文件、只有注释没有代码的文件。评测系统不会跟你讲情面,所有正常输入和异常输入都会跑一遍。
第四,错误出现时不能直接崩。评测系统一般要求:遇到非法字符或非法格式,输出错误信息,然后尽可能继续分析后面的内容。这就要求代码里对错误路径有明确处理。
1.3 先弄清评测系统到底在比对什么
做这种课设项目,最重要的一步其实是先搞清楚最终交付物的格式。同一个词法分析器,不同平台要求的输出格式千差万别。我见过要求输出(类别编号, 文本)的,也见过要求输出<类别名, 文本>或者纯文本列表的。
所以动手写代码之前,我的建议是先确认三件事:
- 输出一行token时,类型名是英文单词还是数字编号还是自定义简写?
- 标识符和关键字是不是要分成两个类型输出?还是关键字也归入标识符、用附加标记区分?
- 语法错误的输出格式是什么?要不要包含行号和列号?
我这份实现最终采用的输出格式是:每行一个token,格式为类型名 文本,例如KEYWORD if、IDENFR a。这个格式易于扩展,也方便对拍比对。如果你面对的评测系统有不同的格式要求,只需要改一个打印函数,后面我会专门讲这个适配点。
2. 方案选型:手写DFA和自动生成工具到底选哪个
2.1 两条技术路线的真实对比
词法分析器怎么实现,业内其实就两条路:一条是用现成的词法分析器生成工具,比如flex、lex;另一条是手写状态机。课堂上一般两边都会教,但课设环节你真正要提交的东西往往有讲究。
用工具生成的思路是:把token模式写成正则表达式,工具自动构造出对应的NFA、再确定化为DFA、最后生成C代码。优点非常明显,代码量小,模式好改;缺点是在评测环境里你可能根本没法安装工具,而且生成出来的代码不是"你的实现"——如果课设要求手写,直接交生成代码可能被判定为不符合要求。
手写状态机的思路是:自己写一个或多个识别函数,按字符逐个推进,用if/switch或者状态转移表的方式实现DFA逻辑。优点是可控性强、不依赖外部工具、代码完全是自己掌控的;缺点是模式越复杂,需要的状态和分支就越多,逻辑容易写乱。
我在这个项目里选择的是手写路径。原因只有两点:一是评测系统直接编译.c文件,我不想引入任何前置工具链;二是手写实现对token类型、错误恢复、输出格式的掌控度最高,遇到评测用例不通过时能快速定位问题。
2.2 手工DFA的核心思想:状态转移不复杂
听起来高大上,其实思维模型很简单。每个token的识别可以看成一个自动机:
- 标识符:第一个字符是字母或下划线,后续字符是字母、数字或下划线,命中一个字符就继续留在"标识符中"这个状态。
- 数字:以数字开头,后续可能继续是数字;如果遇到小数点且后面还紧跟数字,进入浮点数状态;如果遇到e/E,进入指数状态。
- 运算符:从起始状态看当前字符,如果它是
>,说明有可能出现>=或>>,需要再看下一个字符才能决定识别什么。
手工实现的关键就一个:用变量表示当前状态,每读入一个字符,根据当前状态与字符类型决定下一个状态。为了不把代码写成大型蜘蛛网,我建议把状态转移封装成几个独立的识别函数,而不是全局搞一个巨大的switch。
2.3 数据结构与Token类型设计
我先把基础数据结构固定下来,这决定了后面所有代码长什么样:
#include <stdio.h> #include <stdlib.h> #include <ctype.h> #include <string.h> typedef enum { TK_IDENTIFIER, // 标识符 TK_KEYWORD, // 关键字 TK_NUMBER, // 数字常量 TK_STRING, // 字符串常量 TK_OPERATOR, // 运算符 TK_DELIMITER, // 分隔符 TK_EOF, // 文件结束 TK_ERROR // 错误 } TokenType; typedef struct { TokenType type; char text[64]; int line; int col; } Token;type保存token类别,text保存原始文本,line和col记录位置信息,用于报错和日志。很多评测系统并不要求输出行列号,但自己调试时非常有用。
2.4 输出格式做成一个独立函数
前面说过格式会变,所以我把打印逻辑单独提出来:
const char* tokenTypeName(TokenType type) { switch (type) { case TK_IDENTIFIER: return "IDENFR"; case TK_KEYWORD: return "KEYWORD"; case TK_NUMBER: return "NUMBER"; case TK_STRING: return "STRING"; case TK_OPERATOR: return "OPERATOR"; case TK_DELIMITER: return "DELIMITER"; case TK_EOF: return "EOF"; case TK_ERROR: return "ERROR"; default: return "UNKNOWN"; } } void printToken(Token t) { printf("%s %s\n", tokenTypeName(t.type), t.text); }我这份实现把标识符和关键字分开输出,因为很多编译原理课设的语法分析阶段会单独拿这两类做判断。如果你的评测系统要求把它们合并,只需要在打印函数里做一次转换即可。
3. 核心识别函数的实现细节
3.1 输入管理与单字符读写
词法分析器从头到尾就是在做"读一个字符 → 判断 → 再读一个字符"的循环,所以输入管理部分是整个项目的地基。我见过有人用getchar()逐个读,这样代码也能跑,但遇到需要"往前看一个字符再回退"的场景会很痛苦,比如识别>=和>的差异时,读完>后发现后面是=,需要把=退回去或者保存状态。
我的做法是把整个源文件一次性读入内存,用一个全局指针扫描:
static char* source; static size_t pos; static int line = 1; static int col = 0; char peek_char() { return source[pos]; } char get_char() { char c = source[pos++]; if (c == '\n') { line++; col = 1; } else { col++; } return c; }peek_char()只返回当前位置的字符但不推进指针,get_char()返回字符并推进。需要回退时,直接操作pos、line、col三个变量即可。这个设计让代码在边界处理上特别舒服。
读取整个文件的方法也很简单:
char* readSource(const char* path) { FILE* fp = fopen(path, "r"); if (!fp) { perror("open source failed"); exit(1); } fseek(fp, 0, SEEK_END); long size = ftell(fp); fseek(fp, 0, SEEK_SET); char* buf = (char*)malloc(size + 2); fread(buf, 1, size, fp); buf[size] = '\0'; fclose(fp); return buf; }注意结尾加了一个'\0'哨兵字符。这样扫描到'\0'就认为是文件结束,不需要频繁检查pos是否越界。
3.2 标识符与关键字:先识别、后查表
大多数编译原理教材都会强调一个细节:关键字也是由字母组成的字符串,所以词法分析器遇到字母开头时,先把整个标识符扫出来,然后再查一张关键字表,判断它到底是保留字还是普通标识符。
这个顺序千万不能搞反。如果先判断"前缀是不是关键字",ifx会变成if+x两个token,这显然是错误的分词。
关键字表用静态数组即可:
static const char* keywords[] = { "break", "case", "char", "const", "continue", "default", "do", "else", "float", "for", "if", "int", "return", "void", "while" }; static const int keywordCount = sizeof(keywords) / sizeof(keywords[0]);识别函数如下:
Token lexIdentifier() { int start = (int)pos; int startLine = line, startCol = col; while (isalnum(peek_char()) || peek_char() == '_') { get_char(); } int len = (int)(pos - start); if (len >= 64) len = 63; char text[64]; strncpy(text, source + start, len); text[len] = '\0'; for (int i = 0; i < keywordCount; i++) { if (strcmp(text, keywords[i]) == 0) { Token t = { TK_KEYWORD, "", startLine, startCol }; strcpy(t.text, text); return t; } } Token t = { TK_IDENTIFIER, "", startLine, startCol }; strcpy(t.text, text); return t; }有一个细节:标识符的第一个字符必须是字母或下划线,不能在数字后面直接加字母,这个判断留给主循环去完成。另外C语言是区分大小写的,If不是关键字,只是普通标识符,查表时用strcmp严格相等就可以了。
3.3 数字常量:整数、浮点、科学计数法
数字识别是所有部分里最需要细心的地方,它涉及"什么时候允许读后续字符"和"什么时候必须回退"。
我按三个状态来设计:整数部分、小数点部分、指数部分。
Token lexNumber() { int start = (int)pos; int startLine = line, startCol = col; // 整数部分 while (isdigit(peek_char())) { get_char(); } // 浮点部分:只有小数点后紧跟数字才认为是浮点数 if (peek_char() == '.') { int savePos = (int)pos; int saveLine = line, saveCol = col; get_char(); // 吃掉 '.' if (isdigit(peek_char())) { while (isdigit(peek_char())) get_char(); } else { // 小数点后不是数字,说明 '.' 不是这个数字的一部分,回退 pos = savePos; line = saveLine; col = saveCol; } } // 指数部分:e/E 后允许有 +/-,然后必须有数字 if (peek_char() == 'e' || peek_char() == 'E') { int savePos = (int)pos; int saveLine = line, saveCol = col; get_char(); if (peek_char() == '+' || peek_char() == '-') { get_char(); } if (isdigit(peek_char())) { while (isdigit(peek_char())) get_char(); } else { // 指数格式不合法,回退到 e/E 之前 pos = savePos; line = saveLine; col = saveCol; } } int len = (int)(pos - start); if (len >= 64) len = 63; char text[64]; strncpy(text, source + start, len); text[len] = '\0'; Token t = { TK_NUMBER, "", startLine, startCol }; strcpy(t.text, text); return t; }这个"先保存位置、发现不合适再回退"的操作,实际就是手工实现最长匹配。做这个项目时,我最初没有对1..2这种输入做处理,结果它把整个1.识别成数字,后面的.2当成以点开头的token,评测直接出现格式错乱。加上回退逻辑后,1..2会被正确拆成NUMBER 1、OPERATOR .、NUMBER .2这种相对合理的序列。虽然C语言本身不接受1..2,但词法分析器的职责是切分,不是判定语法合法性,遇到这种输入保证不崩溃就够了。
3.4 运算符与分隔符:最长匹配的考场
运算符识别是另一个把"最长匹配"考到极致的地方。=,==,+=,++这些都需要看下一个字符才能定。
我的做法是先准备一张双字符运算符表,遇到可能的开头字符时先查两字符组合:
static const char* twoCharOps[] = { "==", "!=", "<=", ">=", "&&", "||", "++", "--", "+=", "-=", "*=", "/=", "%=", "<<", ">>" }; int isTwoCharOp(const char* s) { for (int i = 0; i < sizeof(twoCharOps)/sizeof(twoCharOps[0]); i++) { if (strcmp(twoCharOps[i], s) == 0) return 1; } return 0; } Token lexOperatorOrDelimiter() { int start = (int)pos; int startLine = line, startCol = col; char two[3] = { peek_char(), '\0', '\0' }; if (source[pos + 1] != '\0') { two[1] = source[pos + 1]; if (isTwoCharOp(two)) { get_char(); get_char(); Token t = { TK_OPERATOR, "", startLine, startCol }; strncpy(t.text, two, 2); t.text[2] = '\0'; return t; } } char one = get_char(); Token t = { TK_OPERATOR, "", startLine, startCol }; t.text[0] = one; t.text[1] = '\0'; return t; }这里有一个值得注意的坑:像.这种字符,既可能作为小数点参与数字,也可能作为成员访问运算符出现(如结构体访问),还可能只是一个独立的标点。因此运算符识别函数里不能把所有单字符都当成操作符处理,需要留一份分隔符表,如; , ( ) { } [ ] :,这些应该归类为TK_DELIMITER而不是TK_OPERATOR。具体的归类标准看你的课程规范,我建议把以下字符视为分隔符:; , ( ) { } [ ]。:视具体语言而定,我这份实现把它归为分隔符。
3.5 字符串常量与转义处理
如果题目要求支持字符串,那就必须在读字符串时处理转义字符。C语言里\"表示字符串中的引号,\\表示反斜杠,\n表示换行。如果不处理转义,遇到"a\"b"时,词法分析器会在第二个引号那儿提前结束,导致后面的字符全乱套。
字符串识别的基本逻辑:
Token lexString() { int start = (int)pos; int startLine = line, startCol = col; get_char(); // 吃掉开头的 " while (peek_char() != '\0') { char c = get_char(); if (c == '\\') { if (peek_char() != '\0') { get_char(); // 吃掉转义后面的字符 } } else if (c == '"') { int len = (int)(pos - start); if (len > 64) len = 64; char text[64]; strncpy(text, source + start, len); text[len] = '\0'; Token t = { TK_STRING, "", startLine, startCol }; strcpy(t.text, text); return t; } } Token t = { TK_ERROR, "unclosed string", startLine, startCol }; return t; }这个函数遇到未闭合的字符串会直接返回TK_ERROR,让主循环进入错误处理分支。很多评测系统对字符串内的换行处理要求不同,有的允许字符串跨行,有的不允许,这需要看题目给的语法定义。我这份实现没有对字符串内换行做特殊限制,按"读入到闭合引号为止"处理。
3.6 注释与空白的过滤
空白字符(空格、制表符、换行)严格来说不属于任何token,必须跳过。注释也一样,//和/* */在词法分析阶段就是被丢弃的内容,但丢弃前要正确识别它们的边界。
void skipWhitespaceAndComments() { while (1) { while (isspace(peek_char())) { get_char(); } if (peek_char() == '/' && source[pos + 1] == '/') { while (peek_char() != '\n' && peek_char() != '\0') { get_char(); } continue; } if (peek_char() == '/' && source[pos + 1] == '*') { get_char(); // '/' get_char(); // '*' int closed = 0; while (peek_char() != '\0') { if (peek_char() == '*' && source[pos + 1] == '/') { get_char(); get_char(); closed = 1; break; } get_char(); } if (!closed) { // 遇到未闭合注释,输出错误信息并继续 fprintf(stderr, "line %d: unclosed comment\n", line); } continue; } break; } }注释容易出的问题有两个:一是块注释跨越多行时,行号统计不能乱;二是在注释外层判断/*时,如果代码里同时有除号/,判断顺序必须严格。上面这段代码先检查//,再检查/*,最后才是普通字符,顺序对了就不会把/开头的运算符漏掉。
4. 把各模块串起来:完整主流程与代码拼装
4.1 主控制循环
有了各类识别函数,主循环就非常清爽了:
void tokenize(const char* path) { source = readSource(path); pos = 0; line = 1; col = 1; while (1) { skipWhitespaceAndComments(); Token t; char c = peek_char(); if (c == '\0') { t = (Token){ TK_EOF, "EOF", line, col }; printToken(t); break; } if (isalpha(c) || c == '_') { t = lexIdentifier(); } else if (isdigit(c)) { t = lexNumber(); } else if (c == '"') { t = lexString(); } else { t = lexOperatorOrDelimiter(); } printToken(t); if (t.type == TK_ERROR) { // 错误输出后继续,避免死循环 get_char(); } } }注意最后那个get_char():当识别函数返回错误token时,必须保证至少消费一个字符,否则会出现相同的错误位置反复输出、死循环。如果你在lexOperatorOrDelimiter里对未知字符没有消费任何字符,这里就起到了兜底作用。
4.2 错误处理策略
错误处理的追求不是让程序绝对不出错,而是出错后还能继续跑。我的策略分三级:
- 非法字符:比如源码里出现
@、#、`这类没有明确定义的字符,把它作为一个TK_ERRORtoken输出,然后继续。 - 未闭合注释:输出错误日志到标准错误流,但分析不中止。
- 未闭合字符串:返回
TK_ERROR,主循环消费一个字符后继续。
有些评测系统要求的是"遇到非法字符立即停止", 有的要求"继续分析"。这两种策略我都实践过,我的建议是:把错误出口封装成函数,方便一键切换行为。比如你可以在printToken里加一个全局开关,当某行错误出现时决定是否立即退出。
4.3 一个可以直接提交的主函数示例
int main(int argc, char* argv[]) { if (argc < 2) { fprintf(stderr, "usage: %s <source-file>\n", argv[0]); return 1; } tokenize(argv[1]); return 0; }如果你需要从标准输入读取,把readSource的路径参数去掉,改用fread(stdin)即可。很多评测系统会直接调用程序并传入文件路径,也可能把源码重定向到标准输入,两种模式都支持会更稳妥。
5. 评测系统上的踩坑与排查
5.1 回溯问题:比想象中容易出错的边界
我在3.3节里提过的"保存位置再回退",是整个词法分析器里让我掉头发最多的地方。除了数字,运算符识别也同样需要回退。举个例子,输入是1 < = 2,如果代码在识别<时先偷看了后面的空格,发现不是<=,那这个空格并没有被消费,不会有问题。但输入是1 <= 2,识别到<后偷看下一个字符是=,这时必须消费两个字符,否则会漏掉=。
这个"偷看"和"消费"的平衡,本质就是DFA里的前瞻。我的代码里大量使用peek_char()和source[pos+1]来避免不必要的状态变量。
实测下来最稳妥的调试方式是:在开发阶段临时加一个"忠实打印每次读取字符"的函数,眼睛看着字符流出错时,能很快定位是哪里多消费了一个字符。等你跑通全部分支,再把这个调试输出删掉。
5.2 关键字的坑:大小写和下划线
有一类评测用例专门考察关键字与标识符的边界。比如:
intIf = 1;正确的分词应该是IDENTIFIER intIf、OPERATOR =、NUMBER 1、DELIMITER ;。如果代码在读到int时就急着查关键字表,就会错误地切出KEYWORD int、IDENTIFIER If。这个问题就是识别顺序没掌握好。整个标识符必须先完整扫出来,再整体与关键字表比对,不要在中途截断。
还有一类隐蔽用例是_和__开头的标识符,很多语言规范允许,我在识别函数里也把它们作为标识符处理。如果你的课程定义下划线不能开头,就把主循环的c == '_'分支删掉,同时把它当作错误字符处理。
5.3 浮点数与点运算符的冲突
这个坑我在3.3节已经埋下伏笔。很多同学写的数字识别函数,一看到小数点就吞进去,结果遇到1..2、1.f、1.e2这类输入,输出的token完全不对。比如1.f,正确做法应该是先识别出数字1,再识别出点运算符.,最后识别出标识符f。如果贸然把1.整体识别成数字,就会把f吞掉或者产生一个非法数字。
我在数字识别里专门做了"小数点后必须有数字才算浮点数"的判断,就是为了应对这种情况。类似地,指数部分e/E后面也必须有有效数字,否则回溯。
5.4 注释未闭合与多行处理
很多评测系统会特意准备一个只有注释、没有任何代码的文件来测试。如果注释处理逻辑不完善,可能会输出一行错误信息,或者在文件末尾进入死循环。我建议对"只有/* hello没有闭合"的输入做专门测试。
未闭合块注释的处理可以自定义:有的评测系统要求忽略整个未闭合注释,有的要求报错但继续。我在实现里选择了打印错误到stderr、注释内容全部丢弃、分析继续执行,这样既不会阻塞主流程,也能留下报错线索。
多行注释特别注意行号列号更新。由于我在get_char()统一维护line、col,块注释跨行时行号会自动更新,不会出现后续token行号错乱的问题。
5.5 常见问题速查表
| 症状 | 可能原因 | 解决方案 |
|---|---|---|
| 输出结果比预期多一个token | 运算符识别时多消费了一个字符 | 检查两字符运算符的匹配分支是否回退 |
| 数字后面跟字母变成两个token | 数字识别把字母吞了或者产生非法token | 数字识别函数遇到字母立即收尾,不做强制消费 |
| 关键字全部被识别成标识符 | 关键字表没匹配或字母大小写不一致 | 确认关键字表内容和语言规范一致 |
| 注释块导致程序卡死 | 块注释未闭合且没有退出机制 | 在未闭合分支里输出错误信息并强制跳出 |
| 错误token导致死循环 | 出错时没有消费任何字符 | 主循环里对TK_ERROR调用get_char() |
| 行号列号全错 | 换行时没有更新行列变量 | 统一在get_char()中维护行列 |
5.6 一个容易被忽略的评测细节:空文件与纯空白文件
空文件或者只有空白的文件,评测系统也会测。正确的输出应该只有一个EOFtoken。如果你在主循环开头没有处理好peek_char() == '\0'的分支,空文件可能什么都不输出,或者出现意外的错误token。我的建议是无论什么输入,都保证有且只有一个EOF输出作为收尾。
6. 自测用例设计与提交建议
6.1 构造一个覆盖矩阵
我建议在正式提交前,建一个专门的测试目录,里面放上下列类型的输入文件:
- 基础代码:包含变量声明、赋值、if/else、while、for、函数定义,确保常用token类型都覆盖。
- 运算符密集:把
==、!=、<=、>=、&&、||、++、--、+=、-=等全部写进一个文件,验证最长匹配。 - 数字边界:整数、浮点数、科学计数法、指数前有正负号、小数点不接数字、数字后直接跟字母。
- 字符串边界:含转义引号、转义反斜杠、空字符串、未闭合字符串。
- 注释边界:行注释、块注释、多行块注释、注释里有除号和乘号、未闭合块注释。
- 杂项:空文件、只有注释、只有空白、非法字符、超长标识符。
- 综合样例:一个接近真实C代码的文件,比如冒泡排序、链表插入函数。
6.2 如何与参考输出对拍
如果你身边有同学已经通过评测的代码,或者课程提供了参考例子,最好的办法就是做对拍:同一份输入,你的输出和参考输出逐行比较。
没有参考代码时,我自己调试的做法是:把每一个测试文件的人工预期输出写成一个.txt,然后用diff命令和程序实际输出做比对。比如:
./lexer test/op.c > my_output.txt diff my_output.txt expected_op.txt这个diff会在第一时间圈出差异行。方法论很简单:先保证小文件全对,再逐步扩大到复杂文件。不要一上来就对着大文件瞎调。
6.3 提交前的核对清单
最后提交之前,我再过一遍这份清单,每一项都是我用红叉换来的教训:
- 程序是否能处理标准输入和文件输入两种模式。
- 输出格式是否和题目要求完全一致,包括空格、换行、大小写。
- 空文件和纯注释文件是否能正常输出
EOF。 - 错误时是否会死循环。
- 所有源文件是否能一次性编译通过,是否需要额外的依赖库。
- 边界token文本是否会被截断(我设的64字节上限,测试时最好包含一个超过64字节的标识符来验证)。
- 是否有残留的调试输出污染了标准输出。
如果你面对的是在线评测系统,还有一个很重要的点:标准错误流和多标准输出流是分开的,我把注释未闭合、非法字符等错误信息输出到stderr,这样既不会污染token结果,也能在日志里看到问题。如果你的评测系统要求错误也输出到标准输出,就把fprintf(stderr, ...)换成printf(...)即可,但这个开关一定要提前确认。
最后再分享一个我在实际操作里体会很深的经验:不要试图一遍写出全对的核心代码,更值得花时间的,是把你生成的测试文件、预期输出和整个词法分析器一起做成一个小工程。哪怕评测系统不要求提交测试用例,这个过程也会让你对DFA、最长匹配、回溯这些抽象概念的理解深一个层次。这个项目做完之后,再往后看语法分析,很多纠结都会迎刃而解。