简介:面向编译原理初学者与课程实验需求,这份北京邮电大学词法分析器实验包围绕编译器前端扫描器的设计与实现展开,适合正在学习词法规则、记号识别与错误处理的学生对照实践。压缩包共4个文件,以2个txt说明与测试文本、1个h头文件和1个cpp源文件为主,整体约10KB,体量轻便,便于快速查看核心代码与实验材料。已有1000人学习下载。内容覆盖词法规则定义、输入处理、分词逻辑、符号栈管理及错误处理等关键环节,附带的说明与测试文本可用于验证标识符、关键字、常量、运算符等词素的识别效果。读者可借助源代码理解从词法规则到扫描算法的落地方式,梳理实验实现思路,并对照测试用例排查边界情况与异常输入,为后续语法分析学习打基础。
1. 课程实验的真相:一个词法分析器到底要跑通哪几步
“编译原理”这门课最劝退的时刻,不是推文法,而是实验一那天下午——你打开压缩包,里面躺着一份能直接编译运行的词法分析器源码、一组 .c 风格的测试用例,而实验指导书只有三页。这份课程实验资源干的事很具体:把一段类 C 源程序切成一串 Token,每个 Token 带上类型、原始词素和行列号,交给后面的语法分析器直接消费。它适合两类人:被实验一卡住、想先跑通再回头啃原理的;以及自己写完了但输出格式对不上、想找份参考实现做对照的。词法分析器看着简单,真正拉开分数的往往是关键字表顺序、最长匹配回退、注释与换行处理这些边角,玄学得很。
2. 项目结构与技术选型:C++ 手写扫描器 + 正则到 DFA 的两条路线
拿到包先别急着 make,先把目录摊开看。这类实验项目通常有两条并行的实现路线:一条是纯手写的扫描器(一个字符一个字符往前啃,边啃边判断),另一条是把词法规则写成正则表达式,走 Thompson 构造 → 子集构造 → DFA 最小化的标准流水线,最终生成一张状态转移表来驱动扫描。两条路线通常共用同一套 Token 定义和测试用例,方便你在实验报告里做对比。
2.1 目录结构:src 放实现,rules 放规则,test 放验收
我按常见组织方式把目录梳理成下面这样,你对照自己包里的文件应该能一一对上:
lexer-lab/ ├── src/ │ ├── main.cpp # 入口:读文件、循环取 token、按格式打印 │ ├── scanner.h/.cpp # 手写扫描器:最长匹配 + 回退 │ ├── regex_parser.h/.cpp # 正则表达式解析成后缀形式 │ ├── nfa.h/.cpp # Thompson 构造:正则 -> NFA │ ├── dfa.h/.cpp # 子集构造 + Hopcroft 最小化 │ └── token.h # Token / TokenType 定义,全项目共用 ├── rules/ │ └── tokens.rules # 词法规则表:名字 = 正则 ├── test/ │ ├── case01_basic.c │ ├── case02_keywords.c │ ├── case03_comment.c │ └── expected/ # 每个 case 对应的期望 token 流 ├── Makefile └── README.mdsrc/里最容易被忽略的是token.h——它是整个项目的契约,扫描器、DFA 生成器、打印逻辑全都要 include 它。rules/tokens.rules是可扩展性的关键,规则写成一行一条的形式,改词法不用动 C++ 代码,重新跑一遍生成流程即可。test/expected/是验收标准,很多同学跑出来“看着像对的”,一比对 expected 就发现行列号差了一位。
提示:动手前先
find . -name "*.rules" -o -name "*.l",确认项目用的是自定义规则文件还是 lex/flex 的 .l 文件,这决定你后面改规则的方式完全不同。
2.2 核心数据结构:Token、TokenType 与符号表
整个项目的数据流就围绕一个结构体转。先把类型枚举写清楚,后面所有逻辑都是围绕它做的分派:
// src/token.h #pragma once #include <string> enum class TokenType { KW_INT, KW_IF, KW_ELSE, KW_WHILE, KW_RETURN, // 关键字 ID, // 标识符 NUM, // 整数字面量 FLOAT_LIT, // 浮点字面量 STR_LIT, // 字符串字面量 OP, // 运算符 + - * / = < > 等 DELIM, // 分隔符 ( ) { } ; , COMMENT, // 注释(可选:大部分实现直接丢弃) ERROR, // 非法字符 END_OF_FILE // 文件结束 }; struct Token { TokenType type; // 词法单元类型 std::string lexeme; // 原始词素,保留原样便于报错 int line; // 起始行号,从 1 开始 int column; // 起始列号,从 1 开始 };lexeme存原始字符串而不是自己再拼一遍,是为了报错时能原样打印“第 7 行第 12 列附近出现非法字符 '#'”,而不是让用户猜。line/column记的是词素的起始位置,不是结束位置——语法分析器报错时更关心“这个词从哪开始的”。
标识符还要过一道符号表,重复出现的名字复用同一个 id,这样实验二建符号表时能直接接上:
// 扫描器内部维护,用于标识符去重 class SymbolTable { public: // 返回该标识符的稳定编号,第一次见就分配新号 int intern(const std::string& name) { auto it = table_.find(name); if (it != table_.end()) return it->second; int id = static_cast<int>(table_.size()); table_.emplace(name, id); return id; } private: std::unordered_map<std::string, int> table_; };参数说明:intern的入参是识别出来的完整标识符字符串,返回值是稳定编号。用unordered_map而不是map是因为这里不需要有序遍历,只查不遍历,平均 O(1) 比红黑树的 O(log n) 划算。如果你后面要按字典序输出符号表,再换回map就行。
2.3 输入输出约定:从 .c 源文件到 token 流
输入是普通文本文件,输出是逐行打印的 token 流,格式必须和test/expected/完全一致,否则 diff 不过。常见格式是<类型, "词素", 行:列>:
<KW_INT, "int", 1:1> <ID, "a", 1:5> <OP, "=", 1:7> <NUM, "1", 1:9> <DELIM, ";", 1:10> <END_OF_FILE, "", 1:11>主流程就是一个 while 循环,直到拿到END_OF_FILE为止:
// src/main.cpp int main(int argc, char** argv) { if (argc < 2) { std::cerr << "usage: lexer <source-file>\n"; return 1; } std::ifstream in(argv[1], std::ios::binary); if (!in) { std::cerr << "cannot open " << argv[1] << "\n"; return 2; } std::string src((std::istreambuf_iterator<char>(in)), std::istreambuf_iterator<char>()); Scanner scanner(src); // 一次性把源文件吞进内存 for (;;) { Token tok = scanner.nextToken(); // 每调用一次吐一个 token printToken(tok); // 按约定格式输出 if (tok.type == TokenType::END_OF_FILE) break; } return 0; }逻辑说明:用std::istreambuf_iterator一次读完整个文件,比逐行 getline 简单,因为词法分析需要跨行看(块注释、字符串里的换行)。ios::binary是为了避免某些平台上文本模式的换行转换把列号算歪,这一点在跨平台交作业时是血泪经验。Scanner构造时保存源串,nextToken()内部维护pos_,每次从当前位置往前扫描,扫完推进指针——下一节就拆这个函数。
3. 核心链路拆解:正则表达式怎么变成一个能跑的 DFA
这一章是项目的技术核心。规则文件里写的ID = [a-zA-Z_][a-zA-Z0-9_]*这种正则,最终要变成一张二维状态转移表trans[state][char] -> state,扫描时查表即可。整条链路四步:正则转后缀、后缀建 NFA、NFA 子集构造转 DFA、DFA 最小化。少一步也能跑,但实验报告里少一步就少一分。
3.1 规则文件怎么变成内存里的 NFA(Thompson 构造)
正则先要被解析成后缀表达式(也叫逆波兰式),因为后缀形式没有括号、不需要回溯,用栈就能一步步建出 NFA。解析器部分不贴全,重点看构造:Thompson 构造的核心思想是每种正则运算对应一小块固定的图,然后用 epsilon 边把小块拼起来。状态节点定义如下:
// src/nfa.h constexpr char EPS = '\0'; // 用 '\0' 表示 epsilon 转移 struct NFAState { char trans = EPS; // 转移字符,EPS 表示空转移 int next1 = -1; // 第一条出边 int next2 = -1; // 第二条出边(epsilon 分叉时用) }; int newState(std::vector<NFAState>& g) { g.emplace_back(); return static_cast<int>(g.size()) - 1; }三个基本构造块,分别是单字符、连接、闭包:
// 单字符 a:s --a--> e std::pair<int,int> buildChar(char c, std::vector<NFAState>& g) { int s = newState(g), e = newState(g); g[s].trans = c; g[s].next1 = e; return {s, e}; } // 连接 AB:把 A 的接受态用 epsilon 连到 B 的起始态 std::pair<int,int> buildConcat(std::pair<int,int> a, std::pair<int,int> b, std::vector<NFAState>& g) { g[a.second].trans = EPS; g[a.second].next1 = b.first; return {a.first, b.second}; } // 闭包 A*:新增起止态,配四条 epsilon 边 std::pair<int,int> buildStar(std::pair<int,int> a, std::vector<NFAState>& g) { int s = newState(g), e = newState(g); g[s].trans = EPS; g[s].next1 = a.first; g[s].next2 = e; g[a.second].trans = EPS; g[a.second].next1 = a.first; // 回到子图起点,实现重复 g[a.second].next2 = e; // 或者直接跳过,零次匹配 return {s, e}; }逻辑说明:buildStar里那四条 epsilon 边是 Thompson 构造的固定套路——新起点可以进子图(匹配一次以上)也可以直接到新终点(匹配零次),子图终点可以回子图起点(继续匹配)也可以到新终点(结束)。参数上,每个函数都返回{起始态, 接受态}这样一对下标,方便上层继续拼接。选择法A|B同理:新建起点,用两条 epsilon 分别指向 A、B 的起点,A、B 的接受态再用 epsilon 汇到同一个新终点。
3.2 NFA 到 DFA:子集构造把状态数压下来
NFA 有个致命问题:一个状态读一个字符可能跳到多个状态,运行时得随时回溯,写起来容易翻车。子集构造把“状态集合”打包成一个 DFA 状态,从而做到确定性——每个状态读每个字符只有唯一去向。
先写 epsilon 闭包,这是整个算法的地基:
// 求状态集合的 epsilon 闭包:能零成本到达的所有状态 std::set<int> epsilonClosure(const std::set<int>& states, const std::vector<NFAState>& g) { std::set<int> closure = states; std::vector<int> work(states.begin(), states.end()); while (!work.empty()) { int s = work.back(); work.pop_back(); if (g[s].trans != EPS) continue; // 只有 epsilon 边才继续走 for (int nxt : {g[s].next1, g[s].next2}) { if (nxt >= 0 && !closure.count(nxt)) { closure.insert(nxt); work.push_back(nxt); } } } return closure; }参数说明:入参states是一组 NFA 状态,返回它们加上所有 epsilon 可达状态的并集。用set<int>而不是unordered_set,因为后面要把集合当作 DFA 状态的唯一键,需要稳定有序才能比较和哈希。
接着是move和主循环:
// 从集合 T 出发,读字符 c 能到达的 NFA 状态集合 std::set<int> move(const std::set<int>& T, char c, const std::vector<NFAState>& g) { std::set<int> result; for (int s : T) if (g[s].trans == c && g[s].next1 >= 0) result.insert(g[s].next1); return result; // 注意:这里还没做 epsilon 闭包 } // 子集构造主循环 void subsetConstruction(const std::vector<NFAState>& nfa, int nfaStart, const std::set<int>& nfaAccept, DFA& out) { std::map<std::set<int>, int> stateMap; // NFA 集合 -> DFA 状态号 std::queue<std::set<int>> work; auto start = epsilonClosure({nfaStart}, nfa); stateMap[start] = 0; work.push(start); while (!work.empty()) { auto cur = work.front(); work.pop(); int curId = stateMap[cur]; if (std::includes(cur.begin(), cur.end(), nfaAccept.begin(), nfaAccept.end())) out.accept.insert(curId); // 含接受态则标为接受 for (char c : alphabet) { // alphabet 是规则里出现的所有字符 auto nxt = epsilonClosure(move(cur, c, nfa), nfa); if (nxt.empty()) continue; if (!stateMap.count(nxt)) { stateMap[nxt] = static_cast<int>(stateMap.size()); work.push(nxt); } out.trans[curId][c] = stateMap[nxt]; } } }逻辑说明:move只找一步直达,epsilonClosure补上零成本路径,两者组合才是完整的转移。alphabet是规则里所有出现过的字符的并集,一般用std::set<char>存,别偷懒用 256 全字符表——虽然不出错但会生成大量无效边,最小化时白白多跑几轮。out.accept记录哪些 DFA 状态是接受态,扫描时命中即产出 token。
3.3 DFA 最小化:合并等价状态,去掉冗余跳转
子集构造出来的 DFA 经常有几十个状态,但真正“行为不同”的可能只有十来个。最小化的思路是划分:先按“接受态 / 非接受态”分成两组,然后反复检查同一组内的状态读同一字符是否跳到同一组,不是就拆开,直到划分稳定。
// Hopcroft 划分的简化版:反复按转移目标分组 void minimize(DFA& dfa) { // 初始划分:接受态一组,非接受态一组 std::map<int,int> group; // 状态 -> 组号 for (int s : dfa.allStates()) group[s] = dfa.accept.count(s) ? 1 : 0; bool changed = true; while (changed) { changed = false; std::map<std::pair<int,std::vector<int>>, int> sig; // 签名 -> 新组号 std::map<int,int> nextGroup; for (int s : dfa.allStates()) { std::vector<int> row; for (char c : alphabet) row.push_back(group.count(dfa.trans[s][c]) ? group[dfa.trans[s][c]] : -1); auto key = std::make_pair(group[s], row); if (!sig.count(key)) sig[key] = static_cast<int>(sig.size()); nextGroup[s] = sig[key]; } if (nextGroup != group) { group = nextGroup; changed = true; } } dfa.rebuildFrom(group); // 按组号重建转移表,去掉重复状态 }逻辑说明:sig里存的是“当前组号 + 每一个字符跳到的目标组号”组成的签名,签名相同说明两个状态在所有输入下的行为完全一致,可以合并。changed控制迭代,划分只减不增,最多迭代状态数次就收敛。参数上dfa.trans用二维表或map<int, map<char,int>>都行,前者查询快,后者省内存。
注意:最小化时千万不要把不同类型的接受态合到一组。
ID的接受态和NUM的接受态在“接受”这一点上相同,但产出 token 类型不同,合并了就会把标识符识别成数字——这个坑我在实验报告里踩过一次,输出里全是<NUM, "abc">。
3.4 手写扫描器:最长匹配 + 指针回退才是真功夫
DFA 建好了,驱动扫描其实很短,但有两个细节必须写对:最长匹配和回退。比如输入a+++++b,读到a后面跟+,扫描器要能确定+属于下一个 token,不能贪心地把a+当成标识符的一部分。
Token Scanner::nextToken() { skipWhitespaceAndComment(); // 跳过空白与注释 if (pos_ >= src_.size()) return Token{TokenType::END_OF_FILE, "", line_, col_}; int start = pos_; int startLine = line_, startCol = col_; int state = dfa_.start; int lastAccept = -1, lastAcceptPos = -1; // 最近一次命中的接受态 TokenType lastType = TokenType::ERROR; while (pos_ < src_.size()) { char c = src_[pos_]; auto it = dfa_.trans[state].find(c); if (it == dfa_.trans[state].end()) break; // 无路可走,停 state = it->second; advance(c); // 推进 pos_ 并维护行列号 if (dfa_.accept.count(state)) { // 记下当前最优命中 lastAccept = state; lastAcceptPos = pos_; lastType = dfa_.typeOf(state); // 接受态对应的 token 类型 } } if (lastAccept == -1) { // 一个字符都没匹配上 char bad = src_[pos_]; advance(bad); return Token{TokenType::ERROR, std::string(1, bad), startLine, startCol}; } // 回退到最近一次成功匹配的位置,实现最长匹配语义 rewindTo(lastAcceptPos, startLine, startCol); std::string lex = src_.substr(start, lastAcceptPos - start); return Token{lastType, lex, startLine, startCol}; }逻辑说明:lastAccept和lastAcceptPos是这段代码的灵魂。DFA 一路上可能经过好几个接受态(比如if和iff),但只有最后那个才是最长匹配的合法 token,所以每命中一次接受态就记一次位置,走不通时回退到那个位置。advance负责推进pos_同时更新line_/col_,遇到\n就把行号加一、列号归零。skipWhitespaceAndComment单独抽出来,因为它还涉及块注释跨行,逻辑比想象中长。
rewindTo实现上不建议真的“重置整个扫描器”,而是记下start和lastAcceptPos两个下标,直接把pos_设回lastAcceptPos,行列号用起始行加词素里换行符个数重算:
void Scanner::rewindTo(int newPos, int startLine, int startCol) { pos_ = newPos; line_ = startLine; col_ = startCol; for (int i = 0; i < newPos - lastStart_; ++i) if (src_[lastStart_ + i] == '\n') { ++line_; col_ = 1; } else ++col_; }这段看着像个事后补丁,但它是“最长匹配”能成立的地方。很多同学的实现里if之后紧跟字母会直接报错,就是因为没做回退,扫描器走到死路时不知道退到哪里。
4. 编译与运行:三个命令把实验跑起来
代码看懂了,接下来是让它跑起来。这类实验项目通常提供一个 Makefile,把扫描器和 DFA 生成器拆成两个可执行目标,跑测试用例用脚本或手敲命令都行。
4.1 编译:一条 make 与三个可执行目标
CXX = g++ CXXFLAGS = -std=c++17 -Wall -Wextra -O2 -g SRC = $(wildcard src/*.cpp) OBJ = $(SRC:.cpp=.o) all: lexer gen_dfa lexer: $(OBJ) $(CXX) $(CXXFLAGS) -o $@ $^ gen_dfa: src/gen_dfa.o src/nfa.o src/dfa.o src/regex_parser.o $(CXX) $(CXXFLAGS) -o $@ $^ %.o: %.cpp $(CXX) $(CXXFLAGS) -c $< -o $@ test: lexer ./run_tests.sh clean: rm -f $(OBJ) lexer gen_dfa三个目标各管一件事:make编出扫描器lexer和 DFA 生成器gen_dfa;make test跑全部测试用例做 diff;make clean清干净。-std=c++17是必须的,enum class和std::optional在里面;-Wall -Wextra别省,能挡掉一批“忘记 return”的低级错误。如果包里的 Makefile 用的是CXXFLAGS = -std=c++11,改成 17 一般不会破坏什么,但如果你机器上的老编译器不支持,回退到 11 也行,代码里没用太新的特性。
编译命令按顺序敲:
make clean # 清掉上次的产物,避免半新半旧的 .o 混在一起 make -j4 # 四个核一起编,快了不止一点 ./gen_dfa rules/tokens.rules > build/dfa.txt # 先验证规则能生成 ./lexer test/case01_basic.c # 再跑扫描第二条-j4对于这个体量的项目属于锦上添花,但make clean那步不能省——改了token.h之后增量编译可能漏掉依赖它的 .cpp,症状是“我明明改了枚举,输出还是旧的”,这种玄学问题一半出在这里。
4.2 用测试用例跑出第一份 token 流
test/case01_basic.c一般是最小用例,就几行声明:
int a = 1; int b = a + 2;跑完之后应该得到:
<KW_INT, "int", 1:1> <ID, "a", 1:5> <OP, "=", 1:7> <NUM, "1", 1:9> <DELIM, ";", 1:10> <KW_INT, "int", 2:1> <ID, "b", 2:5> <OP, "=", 2:7> <ID, "a", 2:9> <OP, "+", 2:11> <NUM, "2", 2:13> <DELIM, ";", 2:14> <END_OF_FILE, "", 3:1>对照 expected 做 diff:
./lexer test/case01_basic.c > /tmp/out.txt diff -u test/expected/case01_basic.txt /tmp/out.txtdiff无输出才算过。如果有输出,先看是不是行号列号差一位,再看着是不是某个关键字被识别成了ID。这套流程跑通一个用例,剩下的基本是重复劳动,直接写个循环:
for f in test/case*.c; do name=$(basename "$f" .c) ./lexer "$f" > "/tmp/$name.out" if diff -q "test/expected/$name.txt" "/tmp/$name.out" > /dev/null; then echo "PASS $name" else echo "FAIL $name"; diff -u "test/expected/$name.txt" "/tmp/$name.out" fi done4.3 输出格式与常见报错定位
跑不通的时候,报错信息往往比 diff 更有用。项目里建议在printToken里对ERROR类型特殊处理,直接打到 stderr:
| 现象 | 大概率原因 | 定位方法 |
|---|---|---|
| 输出为空,进程直接退出 | 文件读入失败,ifstream判断没走到 | 在main开头打印argv[1]和src.size() |
全是ERROR单字符 token | 规则文件没被gen_dfa正确加载 | 先单独跑./gen_dfa,看 DFA 状态数是否合理 |
关键字全变成ID | 关键字规则在标识符规则之后匹配,或被合并 | 检查规则文件顺序和接受态的 token 类型映射 |
| 行列号整体偏 1 | 行列号从 0 开始计 | 统一改成从 1 开始,起点初始化成line_ = 1, col_ = 1 |
| 块注释后面的内容全丢 | 注释未闭合时一直扫到 EOF 没报错 | 在skipWhitespaceAndComment里对 EOF 做显式判断 |
gen_dfa单独能跑是个重要设计,它把“规则写错”和“扫描器写错”两类问题分开了。DFA 生成不出来就是规则写错了,生成出来但扫描结果不对才是扫描器的问题。
5. 避坑与排查:那些让我在机房里坐到熄灯的坑
实验一看着简单,坑全在边角。下面五条是我自己和周围同学反复踩过的,每条按“现象 → 原因 → 解决”写,对着症状直接查。
5.1 扫描阶段的三个坑
坑一:关键字识别成标识符。现象是int a = 1;输出<ID, "int">而不是<KW_INT, "int">。原因通常是关键字表和标识符共用一条规则,只在产出 token 前做一次查表,但查表时用的lexeme还在扫描缓冲区里没截断,或者查表的大小写没统一。解决办法是在buildIdentifier出口处做一次精确匹配,且映射表用const std::unordered_map<std::string, TokenType>静态初始化,别每个字符都重建一次:
static const std::unordered_map<std::string, TokenType> kKeywords = { {"int", TokenType::KW_INT}, {"if", TokenType::KW_IF}, {"else", TokenType::KW_ELSE}, {"while", TokenType::KW_WHILE}, {"return", TokenType::KW_RETURN}, }; TokenType classify(const std::string& lex) { auto it = kKeywords.find(lex); return it != kKeywords.end() ? it->second : TokenType::ID; }坑二:a+++++b死循环或提前截断。现象是扫描到这个表达式时程序卡住,或者输出a、++、++、+b这类奇怪切分。原因是贪心匹配后没有回退,指针已经越过了合法边界却不知道退。解决办法就是 3.4 节里那套lastAcceptPos记录加rewindTo,凡是往前走一步之前,先确认这一步走完还能不能回到最近的接受态。
坑三:CRLF 换行把列号算错。现象是在 Windows 上编辑过的测试文件跑出来行号对、列号对不上。原因是\r被当成普通字符消耗掉,但没更新列号逻辑,或者把\r\n当成了一个换行算两次。解决办法是在advance里显式处理:
void Scanner::advance(char c) { ++pos_; if (c == '\r') { /* 若下一个是 \n,等它来处理换行 */ } else if (c == '\n') { ++line_; col_ = 1; } else { ++col_; } }或者更省事:读入文件后先做一次\r\n→\n的归一化,整篇源串处理一次,后面所有逻辑都不用特殊判断。我一般选后者,改一行胜过改十处。
5.2 自动机构造阶段的两个坑
坑四:最小化之后接受态合并错。现象是某些标识符被识别成数字或关键字,而且只在特定前缀下出问题。原因是划分算法只按“是不是接受态”分了两组,没把“接受态对应的 token 类型”算进签名。解决办法是在初始划分或签名里带上类型:
// 初始划分时按 token 类型分组,而不是简单二分 for (int s : dfa.allStates()) { if (dfa.accept.count(s)) group[s] = 100 + static_cast<int>(dfa.typeOf(s)); // 每类接受态一组 else group[s] = 0; }坑五:epsilon 闭包写成了普通 BFS,漏掉分叉。现象是某些带*或|的规则匹配不到,比如[a-z]+匹配单字符可以,多个字符就断。原因是epsilonClosure里只沿着next1走,忘了 epsilon 状态有两条出边。解决办法是把next1、next2都入栈,入栈前判重,别让它无限循环。这个 bug 特别隐蔽,因为单元测试用单字符能过,一到真实源码就翻车。
注意:DFA 生成器最好能打印每个状态的编号、是否为接受态、出边数,跟规则文件一起对着看。我在状态数超过 40 之后基本靠这张表定位问题,纯看代码根本看不出哪条边接歪了。
6. 二次开发:从课程实验到能用的词法分析器
跑通之后,这个项目真正的价值在“能改”。实验一的代码骨架其实和很多真实项目里的 lexer 同构,区别只在规则规模和健壮性。下面三件事是我每次基于这类项目做二次开发时必做的。
6.1 扩一条词法规则要改哪三个地方
不少人加个新运算符(比如+=、->)会改半天,其实涉及的地方固定:
| 改动点 | 文件 | 具体动作 |
|---|---|---|
| 规则 | rules/tokens.rules | 加一行OP_PLUS_ASSIGN = \+= |
| 枚举 | src/token.h | TokenType里加对应枚举值 |
| 映射 | src/gen_dfa.cpp或规则动作 | 把接受态关联到新的TokenType |
改完重新make && ./gen_dfa rules/tokens.rules > build/dfa.txt,再跑一遍全量测试。如果新规则是前缀冲突型(+和+=),确认最长匹配逻辑能正确处理——a += 1应该出+=而不是+加=。这里有个容易忽略的点:规则文件里+必须转义,因为它在正则里是量词,不转义会解析成“前一个字符重复一次以上”,编译能过、运行结果完全不对。
6.2 错误恢复与位置信息:让后面的语法分析器少骂人
课程实验里遇到非法字符直接吐ERROR就完事,但要做二次开发,就得考虑错误恢复。我在自己的实现里加了一条规则:遇到非法字符不中断,记录位置和字符,跳过它继续扫,最后统一汇总所有错误一次性报出来。语法分析阶段拿到干净的 token 流,比中途因为一个#断掉舒服太多。
具体做法是把nextToken里的ERROR返回改成“填充到错误列表并递归调用自己”:
Token Scanner::nextToken() { // ... 前面逻辑不变 if (lastAccept == -1) { char bad = src_[pos_]; errors_.push_back({startLine, startCol, bad}); // 收集而非抛出 advance(bad); return nextToken(); // 跳过坏字符,继续找下一个 token } // ... }errors_用std::vector<LexError>存,main结束时检查非空就打到 stderr,但 token 流照常输出。这样一份带小错的源文件也能扫出完整结果,便于对照 expected 找差异。一个小提醒:递归调用要考虑极端情况(比如整个文件全是非法字符),把递归改成循环更稳,这里为了可读性保留递归形式。
6.3 怎么验证你改完没改坏
改完词法分析器最怕“新功能能用、老功能坏了”。我的习惯是做三层验证:第一层跑原有全部测试用例,一个 diff 都不能有;第二层拿几份真实风格的源文件(自己手写的几百行即可)跑一遍,人工扫一眼 token 流是否合理;第三层写个随机输入生成器,随机拼字符、括号、引号,看程序会不会卡死或崩溃,这层专治指针回退类的 bug:
# 随机生成 200 份脏输入,只要进程超时或非零退出就记一笔 for i in $(seq 1 200); do head -c 4096 /dev/urandom | tr -dc 'a-zA-Z0-9_+-*/=<>();{}"'"'"'\n ' \ > "/tmp/fuzz_$i.c" timeout 2 ./lexer "/tmp/fuzz_$i.c" > /dev/null 2>&1 || echo "BAD $i" donetimeout 2是防死循环的关键,tr -dc只保留合法字符集和少量标点,模拟“看起来像源码但语法全错”的输入。只要有一份超时,就说明某条路径下的回退没写对。
最后说个教训。我最早写这类项目时,习惯改完直接拿case01跑一下看见有输出就交,结果在case03_comment上被块注释未闭合的 EOF 判断坑了一整晚——扫描器在文件末尾死等*/,pos_越界,[]访问到std::string的'\0'还在继续走,最后是靠 gdb 单步才定位到。从那以后我每次改完nextToken相关代码,都强制走一遍“全量 diff + 三份脏输入 + 一份随机垃圾”的流程,哪怕只是改了一个字符常量的分类分支。希望这套流程能帮到你,少在机房里多坐两个小时。
本文还有配套的精品资源,点击获取