简介:这份面向编译原理课程学习者的实训材料,围绕手工构造 TINY 语言词法分析器展开,适合已具备 C/C++ 基础、正在做课程设计或实验的学生参考。压缩包共 10 个文件、约 779KB,内含 tinyscan.cpp 核心源码、可直接编译调试的 Dev 工程与可执行文件,以及 t1.tny 至 t3.tny 等测试源程序;另附 TINY 语言词法分析器实验报告文档,从代码实现到结果验证形成完整过程。读者可据此理解 DFA 状态划分、关键字与标识符识别、整数与运算符判定等细节,并对照枚举类型与记号结构体的设计,掌握扫描器模块的封装思路;实验报告则记录了设计步骤、易错点与测试输出,便于排错与二次修改。目前已有 1333 人学习,适合作为词法分析阶段的上机参考。
1. 从一行:=说起:为什么要手工构造 TINY 语言的词法分析器
接手一份别人写了一半的 TINY 语言解释器,最先卡住的往往不是语法分析,而是最前面那层——源码里的:=到底该切成一个ASSIGN,还是一个:加一个=。这种问题在编译原理课设里几乎人人遇到,而手工构造一个词法分析器就是最划算的投入:一百多行 C 代码、六个 DFA 状态,把字符流稳定切成带行号的 token 流,后面写递归下降才有底气。这个项目做的就是这件事——按 TINY 语言规范从零手写状态机,不依赖 lex/flex,输入.tiny源码,输出可直接对照的 token 序列。刚学编译原理、要做课设、或者想给自己写的 DSL 加一层前端的人,都能直接拿去改。
2. 项目定位与技术选型:单文件 DFA 怎么撑起 TINY 前端
TINY 是一门专为教学裁剪出来的小语言:语句只有if-then-else-end、repeat-until、read、write四类,变量名限定为字母开头的字母数字串,注释用花括号{ ... }包起来且不允许嵌套,赋值符号是:=而不是=,比较只保留<和=。语言小到这种程度,反而特别适合把词法层写透——规则少,每一条正则都能在状态机里找到对应分支,出错时也容易定位到底是哪条规则写歪了。
技术选型上没什么花活:纯 C(C11 标准)、只依赖标准库的stdio.h和ctype.h、构建用一份二十行的 Makefile。没有第三方库、没有代码生成器、没有 CMake 的多层配置,clone 下来make就能跑。这个选择不是偷懒,而是让读者能把注意力全部放在「状态怎么转移」上,而不是花半天配环境。
2.1 从 TINY 规范反推词法单元表
写词法分析器正确的起手式不是打开编辑器,而是先把语言规范摊开,逐条列出「哪些字符序列算一个 token」。这一步做扎实了,后面的 DFA 基本是照着表抄。
| 类别 | 词素示例 | 正则描述 | 产出 token |
|---|---|---|---|
| 保留字 | ifthenelseendrepeatuntilreadwrite | 小写字母串,查表命中 | TOK_IF…TOK_WRITE |
| 标识符 | xcount1 | letter(letter|digit)* | TOK_ID |
| 整数常量 | 042 | digit+ | TOK_NUM |
| 赋值号 | := | 冒号紧跟等号 | TOK_ASSIGN |
| 比较符 | <= | 单字符 | TOK_LT/TOK_EQ |
| 算术符 | +-*/ | 单字符 | TOK_PLUS…TOK_OVER |
| 分隔符 | (); | 单字符 | TOK_LPAREN/TOK_RPAREN/TOK_SEMI |
| 注释 | { anything } | 到}结束,不嵌套 | 丢弃,不产出 |
| 空白 | 空格 制表 换行 | — | 丢弃,换行时行号加一 |
这张表有两个隐含约束值得单独说。第一,注释和空白是「消耗型」规则,它们不产生 token,但会推进读取位置,所以状态机里必须有专门的状态处理它们,不能简单地在主循环开头while (isspace(c)) c = fgetc(...)一把梭——那样注释里的换行就统计不到行号了。第二,:=是唯一一个双字符 token,它决定了状态机必须支持「读第二个字符再决定」的回退逻辑,这也是后面避坑章节里最容易翻车的地方。
2.2 手写 DFA 与 lex/flex 生成器的取舍
很多课设要求里写着「可用 lex」,但真做下来会发现,TINY 这个规模用生成器反而更麻烦。下面是两组方案在同一个项目里的实际对比:
| 维度 | 手写 DFA | lex/flex |
|---|---|---|
| 依赖 | 只需 C 编译器 | 需安装 flex,多一步生成 |
| 可调试性 | printf打状态即可跟 | 生成代码几千行,调试靠猜 |
| 规则规模适配 | 十几条规则,六个状态 | 规则太少,收益不明显 |
| 扩展成本 | 加 token 改三处 | 改.l再重新生成 |
| 教学价值 | 状态转移全程可见 | 正则到 DFA 的过程被藏起来 |
关键词里写着「手工构造」,其实就是选左边这一列。手写的代价是得自己处理ungetc回退、EOF、行号这些边角,但好处是每一行代码你都知道它为什么在那儿。我曾经用生成器做过一版,改一个规则要重新理解生成出来的yylex骨架,那种黑匣子感对学习阶段很不友好。
2.3 目录结构与文件职责
工程刻意保持扁平,四个源文件加一个测试目录就够:
tiny-lexer/ ├── src/ │ ├── token.h # TokenType 枚举 + token 名映射声明 │ ├── lexer.h # Lexer 结构体 + getToken 接口 │ ├── lexer.c # DFA 全部实现,核心文件 │ └── main.c # 读文件、循环取 token、打印 ├── test/ │ ├── sample.tiny # 一份覆盖全部 token 类型的样例 │ └── expected.txt # 期望输出的 token 流,用于 diff ├── Makefile └── README.md职责划分的准则是「谁变化谁独立」。token.h只放枚举和名字表,加新 token 时改这里;lexer.c放状态机,是改动最频繁的文件;main.c只负责 I/O 和输出格式,几乎不动。这种分法在只有一个词法分析器时看着有点重,但等到第 6 章要把 token 流交给语法分析器时,你只要换掉main.c的消费端,lexer.c一行不用改,这个便宜就赚回来了。
提示:
test/expected.txt一定要提交进仓库。词法分析器的输出是纯文本,天然适合做基线比对,后面每次改动都靠它兜底。
3. 核心实现:一个字符从进来到变成 token 的完整链路
整个词法分析器的逻辑可以压缩成一句话:维护一个当前状态,每读一个字符就做一次状态转移,直到进入终止状态S_DONE,然后返回这一轮攒出来的 token。听起来简单,但细节全在「什么时候保存字符」「什么时候回退」「EOF 怎么终止」这三件事上。
3.1 Token 类型与词法分析器上下文
先定义 token 枚举。顺序很重要,因为后面要用它去索引名字数组:
/* src/token.h */ #ifndef TOKEN_H #define TOKEN_H typedef enum { TOK_EOF = 0, TOK_ERROR, TOK_IF, TOK_THEN, TOK_ELSE, TOK_END, TOK_REPEAT, TOK_UNTIL, TOK_READ, TOK_WRITE, TOK_ID, TOK_NUM, TOK_ASSIGN, TOK_EQ, TOK_LT, TOK_PLUS, TOK_MINUS, TOK_TIMES, TOK_OVER, TOK_LPAREN, TOK_RPAREN, TOK_SEMI } TokenType; const char *tokenName(TokenType t); #endifTOK_EOF放在 0 位不是随手一写,而是让「未初始化」和「文件结束」共用同一个值,主循环用do-while时不会因为漏初始化而多跑一轮。TOK_ERROR排第二,任何非法字符都归到它,打印时能把原始词素带出来,方便定位是哪一行写了什么怪字符。
上下文结构体把所有可变状态收在一起,避免满屏全局变量:
/* src/lexer.h */ typedef struct { FILE *src; /* 源码文件指针 */ int lineNo; /* 当前行号,从 1 开始 */ char lexeme[64]; /* 当前词素缓冲,超长截断 */ TokenType cur; /* 最近一次扫描出的 token */ } Lexer; TokenType getToken(Lexer *lx);lexeme定 64 字节是个经验值:TINY 的标识符在规范里没有长度上限,但课设样例里不会超过十几个字符,64 足够且不会因为恶意长串把栈撑爆。真要做生产级词法分析器,这里应该改成动态扩容或只记(start, length)的偏移量,把原文留在缓冲区里。
3.2 getToken 主循环:状态机的骨架
核心函数分两段看。第一段是状态转移,第二段是词素收集。先看转移部分:
/* src/lexer.c */ typedef enum { S_START, S_INNUM, S_INID, S_INASSIGN, S_INCOMMENT, S_DONE } StateType; static TokenType singleCharToken(int c) { switch (c) { case EOF: return TOK_EOF; case '=': return TOK_EQ; case '<': return TOK_LT; case '+': return TOK_PLUS; case '-': return TOK_MINUS; case '*': return TOK_TIMES; case '/': return TOK_OVER; case '(': return TOK_LPAREN; case ')': return TOK_RPAREN; case ';': return TOK_SEMI; default: return TOK_ERROR; /* 语言规范外的字符 */ } } TokenType getToken(Lexer *lx) { StateType state = S_START; bool save = false; /* 当前字符是否计入词素 */ int idx = 0; lx->lexeme[0] = '\0'; while (state != S_DONE) { int c = fgetc(lx->src); if (c == '\n') lx->lineNo++; switch (state) { case S_START: if (isdigit(c)) { save = true; state = S_INNUM; } else if (isalpha(c)) { save = true; state = S_INID; } else if (c == ':') { save = true; state = S_INASSIGN; } else if (c == ' ' || c == '\t' || c == '\n' || c == '\r') { state = S_START; /* 空白丢弃,继续读 */ } else if (c == '{') { state = S_INCOMMENT; /* 注释整体丢弃 */ } else { state = S_DONE; /* 单字符 token 或 EOF */ lx->cur = singleCharToken(c); } break; /* 其余状态见下一段 */ default: state = S_DONE; break; } if (save && idx < (int)sizeof(lx->lexeme) - 1) { lx->lexeme[idx++] = (char)c; lx->lexeme[idx] = '\0'; } } return lx->cur; }save这个标志是整段代码最容易被忽略的设计。S_START处理空白时不该把空格写进lexeme,处理{时也不该把花括号记进去,所以只有真正进入「词素累积」状态的分支才把save置真。词素收集放在switch外面统一做,避免了每个分支都写一遍lexeme[idx++] = c的重复代码。
lineNo的累加放在fgetc之后立刻执行,而不是放在换行字符被真正消费的地方。这个位置选择有讲究:如果放在S_START的空白分支里,注释内部的换行就统计不到;放在读字符之后,无论这个字符最终被丢弃、被回退还是被当作 token 的一部分,行号都准。
3.3 剩余状态:数字、标识符、赋值号和注释
接着看另外四个状态,它们决定了一个 token 什么时候「收口」:
case S_INNUM: if (isdigit(c)) { save = true; /* 继续吃数字 */ } else { if (c != EOF) ungetc(c, lx->src); /* 多读的字符吐回去 */ if (c == '\n') lx->lineNo--; /* 回退要连行号一起回退 */ state = S_DONE; lx->cur = TOK_NUM; } break; case S_INID: if (isalpha(c) || isdigit(c)) { save = true; } else { if (c != EOF) ungetc(c, lx->src); if (c == '\n') lx->lineNo--; state = S_DONE; lx->cur = lookup(lx->lexeme); /* 查保留字表 */ } break; case S_INASSIGN: if (c == '=') { save = true; state = S_DONE; lx->cur = TOK_ASSIGN; /* := 完整匹配 */ } else { if (c != EOF) ungetc(c, lx->src); if (c == '\n') lx->lineNo--; state = S_DONE; lx->cur = TOK_ERROR; /* 孤立的冒号,TINY 里非法 */ } break; case S_INCOMMENT: if (c == '}') { state = S_START; /* 注释结束,回到起始态 */ } else if (c == EOF) { state = S_DONE; lx->cur = TOK_ERROR; /* 注释没闭合就撞到文件尾 */ } break;这四段共享同一个套路:读到「不属于当前 token 的字符」时,先用ungetc把它退回输入流,然后进入S_DONE。退回是必须的,因为标识符后面紧跟的可能是+,这个+属于下一个 token,如果不退回就丢了。
if (c != EOF) ungetc(c, lx->src);里的判空不能省。C 标准规定ungetc(EOF, fp)是未定义行为,在部分实现上会把流搞坏,后续所有读取都返回 EOF,表现出来就是「程序只输出了前几个 token 就结束了」。
if (c == '\n') lx->lineNo--;这行是配套的修补。因为行号在最前面无条件累加过,如果多读的正好是换行符又被退回,行号就得减回来,否则后面所有 token 的行号都会偏大 1,报错定位全错。
最后补一个保留字查表函数。TINY 只有八个关键字,线性扫完全够:
typedef struct { const char *word; TokenType tok; } Reserved; static const Reserved kReserved[] = { {"if", TOK_IF}, {"then", TOK_THEN}, {"else", TOK_ELSE}, {"end", TOK_END}, {"repeat", TOK_REPEAT}, {"until", TOK_UNTIL}, {"read", TOK_READ}, {"write", TOK_WRITE} }; static TokenType lookup(const char *s) { size_t n = sizeof(kReserved) / sizeof(kReserved[0]); for (size_t i = 0; i < n; ++i) { if (strcmp(s, kReserved[i].word) == 0) return kReserved[i].tok; } return TOK_ID; /* 没命中任何关键字,就是普通标识符 */ }这里刻意没有把关键字放进 DFA 状态里识别。八个关键字如果每个都拆成状态,状态机会从六个膨胀到二十多个,而且每加一个关键字都要重画转移图。用「先按标识符规则吃满,再查表」的做法,状态机永远只有那六个状态,扩充关键字只改这张表。这也是大多数手写词法分析器的通行做法——正则负责切分,语义查表负责分类,两件事分开做,都好维护。
3.4 输出、主函数与错误定位
token 打完包就该用起来了。名字表必须和枚举顺序严格对齐:
static const char *kTokenName[] = { "ENDFILE", "ERROR", "IF", "THEN", "ELSE", "END", "REPEAT", "UNTIL", "READ", "WRITE", "ID", "NUM", "ASSIGN", "EQ", "LT", "PLUS", "MINUS", "TIMES", "OVER", "LPAREN", "RPAREN", "SEMI" }; const char *tokenName(TokenType t) { if (t < 0 || t > TOK_SEMI) return "UNKNOWN"; return kTokenName[t]; }主函数保持极简,只做三件事:打开文件、循环取 token、打印。带词素的 token(标识符、数字、错误)额外把lexeme打出来,不带词素的直接打名字:
int main(int argc, char **argv) { if (argc < 2) { fprintf(stderr, "usage: %s <file.tiny>\n", argv[0]); return 2; } FILE *fp = fopen(argv[1], "r"); if (!fp) { perror(argv[1]); return 1; } Lexer lx = { .src = fp, .lineNo = 1, .cur = TOK_EOF }; TokenType t; do { t = getToken(&lx); if (t == TOK_ID || t == TOK_NUM || t == TOK_ERROR) printf("LINE %4d: %-9s %s\n", lx.lineNo, tokenName(t), lx.lexeme); else printf("LINE %4d: %s\n", lx.lineNo, tokenName(t)); } while (t != TOK_EOF); fclose(fp); return 0; }输出格式定成LINE 行号: TOKEN 词素,看着朴素,但有两个好处:行号在左,出错时grep定位一行命中;token 名定宽 9 字符,肉眼扫一列能快速看出语句结构。这套格式从写第一天起就固定下来,后面做回归测试才有稳定的基准。
4. 编译运行:把一份.tiny源码喂进去看 token 流
代码写完只是开始,真正让人心里有底的是把它跑起来、拿一份样例、对着输出逐行核。这一章走一遍从零到跑通的完整流程。
4.1 环境准备与编译命令
只需要一个 C 编译器,gcc 或 clang 都行。先确认版本:
gcc --version # 期望看到类似 gcc (GCC) 11.x 或 13.x 的输出如果不想用 Makefile,一条命令也能编:
gcc -std=c11 -Wall -Wextra -O2 -g -o tiny_lexer src/lexer.c src/main.c参数逐个说:-std=c11是必须的,代码里用了指定初始化器.src = fp这种 C99 特性;-Wall -Wextra打开全部常见告警,词法分析器这类大量用int接fgetc返回值的代码,靠它能拦住不少字符比较的隐式转换问题;-O2在调试阶段可以先关掉,方便单步进函数;-g保留调试符号。
项目自带的 Makefile 长这样:
CC ?= gcc CFLAGS ?= -std=c11 -Wall -Wextra -O2 -g TARGET = tiny_lexer OBJS = src/lexer.o src/main.o $(TARGET): $(OBJS) $(CC) $(CFLAGS) -o $@ $^ src/%.o: src/%.c src/token.h src/lexer.h $(CC) $(CFLAGS) -c -o $@ $< test: $(TARGET) ./$(TARGET) test/sample.tiny > test/actual.txt diff -u test/expected.txt test/actual.txt clean: rm -f $(TARGET) src/*.o test/actual.txt模式规则里把头文件也写成依赖,是为了改token.h里的枚举时能自动触发重编。漏掉这一行的话,加完新 token 只改头文件、跑make提示“无需重新编译”,运行时却输出UNKNOWN,这个坑我踩过一次,排查了十几分钟。
4.2 样例程序与期望输出
写一份尽量覆盖所有 token 类型的.tiny文件,放在test/sample.tiny:
{ sample TINY program } x := 10; if x < 20 then write x end; repeat x := x + 1 until x = 20这份样例有骨架要交代:第一行是注释,测S_INCOMMENT状态;x := 10;同时覆盖标识符、赋值号、数字、分号;if ... then ... end覆盖关键字查表;repeat ... until加上x = 20覆盖比较符和文件结尾不带换行的情形。
编译后运行,正常输出应该是这样:
LINE 2: ID x LINE 2: ASSIGN LINE 2: NUM 10 LINE 2: SEMI LINE 3: IF LINE 3: ID x LINE 3: LT LINE 3: NUM 20 LINE 3: THEN LINE 4: WRITE LINE 4: ID x LINE 5: END LINE 5: SEMI LINE 6: REPEAT LINE 7: ID x LINE 7: ASSIGN LINE 7: ID x LINE 7: PLUS LINE 7: NUM 1 LINE 8: UNTIL LINE 8: ID x LINE 8: EQ LINE 8: NUM 20 LINE 9: ENDFILE核对要点有三个。其一,注释那一行没产生任何 token,说明S_INCOMMENT工作正常;其二,全部关键字都识别成了IF/THEN这类专名而不是ID,说明查表生效;其三,最后一行是ENDFILE而不是ERROR,说明文件尾的处理是对的。三条都对上,这个分析器的基本面就没问题。
4.3 用 token dump 反向验证 DFA
光看输出对错还不够,改代码时更想知道「这个字符为什么被切成了这样」。加一个-v开关,把每次状态转移打出来:
/* 在 getToken 的 switch 结束处插入,仅调试构建启用 */ #ifdef LEX_DEBUG fprintf(stderr, "[DBG] ch='%c' state->%d\n", (c == EOF ? '$' : c), state); #endif编译时加-DLEX_DEBUG打开:
gcc -std=c11 -DLEX_DEBUG -g -o tiny_lexer src/lexer.c src/main.c ./tiny_lexer test/sample.tiny 2> trace.logstderr走 trace,stdout走 token,两者分开,互不干扰,这是命令行工具的基本素养。拿着trace.log对着源码逐字符看一遍,x := 10会呈现为S_START → S_INID → S_DONE → S_START → S_INASSIGN → S_DONE → S_START → S_INNUM → S_DONE的节奏,一旦某处出现意料之外的转移,问题就锁定在读这个字符的那几行了。
日常验证靠make test:
make test # 输出为空表示 test/expected.txt 与实际输出完全一致 # 有差异时 diff 会打印具体哪一行对不上这套「基线文件 + diff」的组合,比人工数输出行数可靠得多,而且第 6 章加新 token 之后,第一时间就能发现有没有把老 token 的切分带歪。
5. 避坑与排查:让 DFA 卡死或错位的五处暗礁
词法分析器的 bug 有个共同特征:程序不崩,但输出悄悄错位,隔几个 token 才看得出来。下面五条是按踩坑频率排的,每条都按「现象 → 原因 → 解决」写。
5.1 程序输出前几个 token 后就不动了
现象:运行后终端只打印了三四行就停住,Ctrl+C才退出,看不出报错。
原因:fgetc返回EOF之后,S_START分支里isdigit(EOF)和isalpha(EOF)都是假,EOF也不等于空格、花括号、冒号,于是落进最后一个else,singleCharToken(EOF)返回TOK_EOF,state置S_DONE,这一轮确实退出了。但如果main里的循环写成while (t != TOK_EOF)且没有do-while打底,第一次取 token 前t未初始化,循环条件随机成立,或者更常见的是:ungetc(EOF, fp)把流搞坏,后续fgetc永远返回EOF,而代码在某个状态下没有正确处理 EOF。
解决:ungetc一律加if (c != EOF)保护;main用do-while保证至少取一次 token;在S_INNUM、S_INID这些状态里显式判断c == EOF后直接收口,不要依赖ungetc回退。
5.2 注释后面的代码整段消失
现象:.tiny文件里一个{忘了配},结果从那一行往后所有 token 都不输出了,只在最后冒一个ERROR。
原因:S_INCOMMENT状态只认},从{一路吃到文件尾,中间所有代码都被当作注释内容丢掉了。
解决:保留S_INCOMMENT里对EOF的判断,撞到文件尾就返回TOK_ERROR,让调用方知道「这里有个没闭合的注释」。更进一步的做法是让Lexer记下注释起始行号,报错时打印unclosed comment starting at line N,定位能快一个数量级。
5.3 冒号后面跟个空格就报错
现象:x : = 10这种写法(虽然不合规范,但手写样例时很容易打出来)直接报ERROR,提示不明确。
原因:S_INASSIGN状态只接受=,遇到空格就回退并置TOK_ERROR,但错误信息里没有说清是「冒号后面必须是等号」。
解决:这个行为本身是对的,TINY 规范里:单独出现就是非法字符。要做的是把错误信息写具体:S_INASSIGN出错时记一个errMsg = "expect '=' after ':'",打印时带上行号和列号。切忌为了“容错”而把:当合法 token 放过去,那会把错误推到语法分析阶段,报出来的位置离真正的问题点十万八千里。
5.4 新加的关键字识别成了标识符
现象:往kReserved表里加了一条{"for", TOK_FOR},枚举也加了,编译通过,但样例里的for还是输出ID。
原因:lookup里用的是strcmp精确比较,而lexeme缓冲区在每次getToken开头会置'\0',正常不会有多余字符。真正的问题是枚举TOK_FOR加在了TOK_SEMI后面,而kTokenName数组没跟着加,tokenName的越界检查t > TOK_SEMI直接把新 token 判成UNKNOWN,或者打印出相邻的旧名字,看起来像“没识别出来”。
解决:加 token 必须同步改三处——枚举、名字数组、状态机分支或查表项,缺一处就会安静地错。用_Static_assert(sizeof(kTokenName)/sizeof(kTokenName[0]) == TOK_SEMI + 1, "token table out of sync");在编译期强制对齐,这一行能省掉后续无数次排查。
5.5 报错行号总是偏大或偏小 1
现象:源码第 3 行有个非法字符$,输出却是LINE 4: ERROR $,或者反过来指向第 2 行。
原因:行号累加的位置和实际消费换行的位置不一致。如果累加写在S_START的空白分支里,那么注释内部、字符串内部(如果扩展了字符串)的换行都统计不到;如果写在ungetc回退之后忘了减回来,每回退一次行号就多算一次。
解决:统一成「fgetc之后立刻if (c == '\n') lineNo++;,所有ungetc之后配套if (c == '\n') lineNo--;」这一套规则,并且只在getToken一处实现,不要在别处再碰行号。改完拿一份每行都放一个错误的样例跑一遍,逐行核对输出里的行号,一次对齐后续就不会再偏。
注意:
ungetc只保证能退回一个字符,连续退两个是未定义行为。如果将来扩展规则需要回退两个字符(比如识别<=失败时想退回=),得自己在Lexer里加一个单字符的pushback字段,不要指望ungetc。
6. 二次开发:把 token 流交给语法分析器
词法分析器单独跑通只是半程,真正的价值在于它能不能被下一层干净地消费。最省事的改法是把main.c里的printf换成回调:给Lexer加一个函数指针void (*onToken)(TokenType, const char *, int, void *),getToken返回前调用一次,main里注册一个「压入 token 队列」的回调。这样词法层彻底不关心输出去向,打印、存数组、直接喂给递归下降都能复用同一份lexer.c。
加一个新 token 要动的四处,列成表更清楚:
| 改动位置 | 具体动作 | 漏改的后果 |
|---|---|---|
token.h枚举 | 在TOK_SEMI前插入新值 | 名字表索引整体错位 |
kTokenName数组 | 对应位置补字符串 | 打印出相邻 token 的名字 |
singleCharToken或状态分支 | 加字符映射或新状态 | 新字符被判为TOK_ERROR |
test/expected.txt | 补上新样例的期望输出 | 回归测试永远红 |
最右边一列是血泪经验:前阵子给一个玩具语言加<=支持,枚举和状态分支都改了,忘了更新基线,make test一直报差异,我还以为是 DFA 写错了,回头翻代码翻了半小时。
验证改动是否安全,靠的就是那颗后悔药——golden file 回归。我的习惯是给每类 token 单独准备一个小.tiny文件,test/cases/下按assign.tiny、comment.tiny、keyword.tiny分开存,make test里用for跑一遍全量 diff。加新 token 时只新增用例、不动老用例,老用例仍然全绿,就说明改动没把既有切分带歪。
从那以后我每次改词法规则,都强制先跑一遍make test再动lexer.c——不是为了验证新功能,而是为了确认那几十个老 token 还站在原地。词法层是整条编译链的最上游,这里错一位,下游的语法树就全歪,而它偏偏又不崩、只安静地错,所以基线比对这件事怎么强调都不过分。希望帮到你。
本文还有配套的精品资源,点击获取