news 2026/11/2 2:32:34

从词法到AST:C++手写LL(1)语法分析器全链路

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
从词法到AST:C++手写LL(1)语法分析器全链路

简介:面向高校编译原理课程学习者与需要完成语法分析实验的学生,这份资料围绕递归子程序法设计并实现语法分析程序,衔接词法分析识别结果,对给定文法中的各类语法成分进行判定与输出,适配CG实验平台的自动评测流程。压缩包共2个文件,包含1份实验问题说明文档和1份C++实现源码,整体约17KB,便于对照文法要求理解实现思路、检查输出格式与预读处理细节。已有6110人学习下载,代码在CG实验平台满分通过,可作为课程实验的完整参考。读者可重点参考递归下降子程序的划分方式、按单词顺序输出词法信息、在指定语法成分结束前另起一行输出“<语法成分名>”等评测要求,并据此排查空产生式、候选式选择与回溯边界等常见问题,快速搭建可运行、可提交的实验方案。

1. 从一个崩溃的实验验收说起:语法分析器到底在做什么

当年帮人调一份编译原理实验,程序在验收前十分钟跑示例输入直接崩溃,输出的不是语法树而是一串越界下标。原因很朴素:手写递归下降时把E' -> ε这条产生式当成不处理,结果输入里的换行符被当成合法 token 一路吞进了非终结符匹配,越走越偏。这份「编译原理-语法分析实验(C++版)」工程解决的正是这类问题:它把词法分析、FIRST/FOLLOW 集合求解、LL(1) 预测分析表构建、递归下降与表驱动两种执行方式、抽象语法树生成、错误恢复这一整条链路都用 C++ 手写实现,并且每一步都有可打印的中间结果。适合正在做编译原理课程实验、想搞清楚「文法是文法、程序是程序,中间怎么接上」的开发者,也适合想把语法分析模块塞进自研 DSL 或配置语言解析器的人。

2. 定位与技术选型:为什么这份实验用 C++ 手写分析器

2.1 这份工程到底覆盖了编译前端的哪些环节

多数同学对「语法分析实验」的印象是:给一个文法,生成分析表,跑几个句子,对错就完事。这份工程把范围铺得更开,从前端入口到结果输出是一条完整链路。

输入层是手写的词法分析器,不依赖 flex 之类的生成器,字符流按状态机规则切成 Token,保留行号和列号,便于后面报错定位。中间层是文法处理模块,读入.grm文法文件,把每一行拆成左部非终结符和右部符号序列,自动识别 ε 空产生式,建立终结符与非终结符集合。表生成层做 FIRST 集合、FOLLOW 集合的不动点迭代,再据此填充预测分析表,同时输出冲突报告。执行层同时给了两套实现:递归下降版按非终结符一对一写成成员函数,表驱动版用一个显式栈加循环跑,两者共用同一份分析表数据,结果必须一致,这也是一种交叉验证。输出层生成语法树,并支持按缩进打印,方便肉眼比对。

把词法和语法拆成两个独立模块,是这类实验最基本的分层。好处是词法出错和语法出错能分开报告:@这种非法字符属于词法错误,id + + id属于语法错误,混在一起报会让调试变成黑匣子。

2.2 数据契约:Token、产生式、语法树三套结构

工程能不能改得动,很大程度上取决于结构体定义得干不干净。我一般会在include/下单独放一个types.h,把跨模块流转的数据全部收拢,避免每个.cpp里各自定义一份。

// include/types.h —— 三个模块之间只通过这三类结构通信 enum class TokenType { ID, // 标识符,如 a、count NUMBER, // 数字字面量 KEYWORD, // 保留字,如 if、while OP, // 运算符,如 + - * / DELIM, // 分隔符,如 ( ) ; , END, // 输入结束哨兵,对应 $ ERROR // 词法错误,携带冒号后的错误信息 }; struct Token { TokenType type; std::string lexeme; // 原始文本,语法分析阶段主要靠它做表查找 int line; int column; }; // 一条产生式:E -> T E' 存成 {lhs="E", rhs={"T","E'"}, id=0} struct Production { int id; std::string lhs; std::vector<std::string> rhs; // 空产生式用空 vector 表示,不存 "ε" }; // 语法树节点:label 是产生式左部或终结符,children 顺序即右部顺序 struct AstNode { std::string label; std::string value; // 终结符节点存词素,非终结符节点留空 int line; std::vector<std::unique_ptr<AstNode>> children; };

这里有个容易被忽略的细节:空产生式的右部用空vector表示,而不是存一个字符串"ε"。原因是后面填分析表时,判断「这条产生式会不会推导出空串」全靠rhs.empty(),一旦把ε当普通符号存进去,就得在每个遍历里特判,血泪经验是这种特判迟早漏一处。同理,Token里必须带line和column,语法分析阶段报错时才能定位到具体位置,否则你只能告诉用户「语法错误」,非常难受。

Production::id看起来多余,其实是关键设计:分析表里存的不是产生式对象,而是整数 id。这样表就是一个纯粹的二维整型映射,既省内存又方便打印成对角矩阵做实验报告。

2.3 为什么用 C++ 而不是脚本语言

用 Python 写同样的逻辑大概只要一半代码量,但这份实验选 C++ 有它的道理:它逼着你显式管理模块边界。LEXER类持有输入流和当前字符,Grammar持有产生式集合和两个符号集合,Parser持有分析表和栈,每个类的构造顺序和依赖关系都写在main.cpp里,读一遍就知道数据怎么流动。

构建方式两条路都留了。习惯 CMake 的用CMakeLists.txt,一行cmake -B build && cmake --build build搞定;临时改代码想快速验证的,直接一条g++命令更省事。

# CMakeLists.txt 核心部分 cmake_minimum_required(VERSION 3.16) project(syntax_analyzer CXX) set(CMAKE_CXX_STANDARD 17) # 用到 std::optional 和 unique_ptr,别降到 11 add_executable(syntax src/main.cpp src/lexer.cpp src/grammar.cpp src/first_follow.cpp src/table.cpp src/parser_rd.cpp src/parser_table.cpp src/ast.cpp ) target_include_directories(syntax PRIVATE include)

选 C++17 而不是 C++11,主要是std::optional和结构化绑定用起来顺手,std::map的try_emplace也能省掉不少find后insert的样板代码。如果实验室机器上只有老版本 GCC,把标准降到 14 也能编,只要把那几处optional改成返回bool加输出参数即可,工作量大概十分钟。

3. 工程结构与调用链:一次 parse() 内部发生了什么

3.1 目录结构拆解

这份工程的目录不算深,但分层清晰,每个文件职责单一,改起来不容易牵连别处。

路径职责改动频率
include/types.hToken、Production、AstNode 等跨模块结构低
include/lexer.h/src/lexer.cpp字符流到 Token 流的状态机低
include/grammar.h/src/grammar.cpp文法文件解析、符号分类中(换文法时)
src/first_follow.cppFIRST/FOLLOW 不动点迭代低
src/table.cppLL(1) 分析表构建、冲突检测中
src/parser_rd.cpp递归下降执行器中(加文法时)
src/parser_table.cpp表驱动执行器低
src/ast.cpp语法树构造与打印中
grammar/.grm文法文件高
test/输入样例和期望输出高

两个执行器(parser_rd和parser_table)并存,是这份工程最有教学价值的地方。递归下降版函数和文法符号一一对应,适合演示「预测分析表的每一行怎么变成代码」;表驱动版不关心文法长什么样,换文法只换数据,适合演示「数据驱动」。两者都跑一遍、比对语法树是否一致,能查出一大批表填错的 bug。

3.2 一次分析从进入到返回的调用链

main.cpp的逻辑很短,但顺序不能乱:先读文法、再求集合、再建表、再读词法、最后解析。顺序错一步就是空指针。

// src/main.cpp 主流程(省略参数解析和错误处理) int main(int argc, char** argv) { Options opt = parseArgs(argc, argv); // --grammar --input --trace Grammar grammar; if (!grammar.load(opt.grammarPath)) { // 1. 读 .grm,建立符号分类 std::cerr << "文法文件解析失败\n"; return 1; } GrammarSets sets = computeFirstFollow(grammar); // 2. 求 FIRST / FOLLOW if (opt.trace) printSets(sets); // --trace 时打印中间结果 ParseTable table = buildTable(grammar, sets); // 3. 填预测分析表 if (table.hasConflict()) { // 冲突直接报告,不硬跑 table.reportConflicts(std::cerr); return 2; } if (opt.trace) printTable(table); Lexer lexer(opt.inputPath); // 4. 词法分析 std::vector<Token> tokens = lexer.tokenize(); if (lexer.hasError()) { for (auto& e : lexer.errors()) std::cerr << e << "\n"; return 3; } Parser parser(grammar, table, tokens); // 5. 语法分析 auto root = opt.useTable ? parser.parseByTable() : parser.parseByRecursiveDescent(); if (!root) { std::cerr << "语法分析失败,错误数:" << parser.errorCount() << "\n"; return 4; } printAst(*root); // 6. 输出语法树 return 0; }

这段代码值得看的是它的退出码约定:1 是文法问题、2 是文法冲突、3 是词法错误、4 是语法错误。自动化测试脚本靠退出码就能区分失败原因,比抓stderr文本靠谱得多。我一般会在test/run_all.sh里对每个样例比对退出码和语法树文本,任何一项不一致就报红。

还有一处细节:computeFirstFollow返回一个独立的GrammarSets结构,而不是把集合塞回Grammar里。这样做的好处是Grammar保持只读语义,多个分析器可以共用同一份文法对象,做 LR 改造时也能复用。

3.3 文法怎么表示:文本文件而不是硬编码

不少同学的实验把产生式直接写死在代码里,比如rules[0] = {"E", {"T", "E'"}}。这样确实快,但换文法要改代码再重编译,做对比实验很麻烦。这份工程把文法外置成文本文件,格式简单到可以手写。

# grammar/expr.grm —— # 开头是注释 E -> T E' E' -> + T E' | ε T -> F T' T' -> * F T' | ε F -> ( E ) | id

解析这个文件的逻辑不复杂,但要处理几个边界:|分割的是同一条左部的多个候选式,每个候选式都要生成独立的Production并分配 id;ε要识别成空右部;->前后的空白要容忍多个空格或制表符。常见做法是按行读、先去注释、再按->切成两半,右部先按|切,再按空白切成符号列表。

这里有个隐含约定必须写进文档:所有符号都是字符串,词法分析器输出的lexeme必须能直接和文法里的终结符对上。也就是说文法里写id,词法器就必须把标识符的lexeme归一化成id,而不是原始文本count。这个映射如果做错了,分析表查不到项,现象是「明明文法里有这条产生式却报语法错误」,排查起来很费时间。我的做法是在Lexer里加一个normalize(),把TokenType::ID统一转成字符串"id",把TokenType::NUMBER转成"num",关键字和运算符保留原文,这样文法文件里的终结符和 Token 流就自然对上了。

4. 核心模块拆解:从字符流到语法树的四个关键实现

4.1 Lexer:手写状态机与 Token 归一化

词法分析器是整个前端的入口,也是最容易埋坑的地方。这份工程用的是一个逐字符推进的循环,按当前字符分流到不同分支,而不是正规的正则引擎。

// src/lexer.cpp 主循环骨架 std::vector<Token> Lexer::tokenize() { std::vector<Token> out; while (!atEnd()) { char c = peek(); if (std::isspace(c)) { advance(); continue; } // 空白直接跳过 int line = curLine_, col = curCol_; if (std::isalpha(c) || c == '_') { out.push_back(readIdentifierOrKeyword(line, col)); // 关键字表里命中就归 KEYWORD } else if (std::isdigit(c)) { out.push_back(readNumber(line, col)); } else if (isOperatorStart(c)) { out.push_back(readOperator(line, col)); // 处理 == <= >= 这类双字符算子 } else if (isDelimiter(c)) { advance(); out.push_back({TokenType::DELIM, std::string(1, c), line, col}); } else { advance(); // 非法字符不中断,记录后继续 errors_.push_back("line " + std::to_string(line) + ":" + std::to_string(col) + " 非法字符 " + c); out.push_back({TokenType::ERROR, std::string(1, c), line, col}); } } out.push_back({TokenType::END, "$", curLine_, curCol_}); // 结束哨兵必须有 return out; }

readIdentifierOrKeyword里做两件事:先把连续字母数字下划线拼成完整字符串,再查一张std::unordered_set<std::string>关键字表,命中就返回KEYWORD,否则返回ID。查表而不是写一长串if,是为了后续加关键字只改表不改代码。

readOperator要特别注意双字符运算符。读到=时不能立刻返回,得往前看一位,是=就吃掉两个字符返回==,否则返回=。这类「最长匹配」原则在词法里是通用的,写漏了会导致a<=b被切成a、<、=、b四个 Token,语法分析阶段会报一个看起来毫无道理的错误。

最后那个END哨兵是必须的。表驱动分析靠它做终止判定,递归下降靠它判断是否读到输入末尾,缺了它就只能靠index_ >= tokens_.size()到处散落,非常容易越界。

4.2 FIRST/FOLLOW:不动点迭代怎么写才不出错

FIRST 和 FOLLOW 的求解思路课本上讲得很清楚,但写成代码时最容易犯两个错:一是迭代终止条件写成「跑固定轮数」,二是 FOLLOW 传播时漏掉「右部某个符号之后的串能推导出空」的情况。正确做法是跑到不动点。

// src/first_follow.cpp —— FIRST 迭代,返回本轮是否有集合变大 bool computeFirstOnce(const Grammar& g, FirstMap& first) { bool changed = false; for (const auto& p : g.productions()) { auto& dst = first[p.lhs]; if (p.rhs.empty()) { // 空产生式:左部能推导出 ε changed |= dst.insert(kEpsilon).second; continue; } bool allNullable = true; for (const auto& sym : p.rhs) { if (g.isTerminal(sym)) { // 终结符:加入它自己,然后停 changed |= dst.insert(sym).second; allNullable = false; break; } for (const auto& s : first[sym]) { // 非终结符:把它的 FIRST 搬过来 if (s != kEpsilon) changed |= dst.insert(s).second; } if (!first[sym].count(kEpsilon)) { allNullable = false; break; } } if (allNullable) changed |= dst.insert(kEpsilon).second; } return changed; } FirstMap computeFirst(const Grammar& g) { FirstMap first; for (const auto& t : g.terminals()) first[t] = {t}; // 终结符的 FIRST 是自己 while (computeFirstOnce(g, first)) { /* 跑到不动点为止 */ } return first; }

changed这个标志是整个算法的核心。每轮遍历所有产生式,只有当某个集合真的变大了才继续下一轮,否则退出。最大轮数不会超过「非终结符个数 × 每个集合的元素数」,对实验规模来说毫秒级完成。如果有人把while写成固定跑三轮,遇到A -> B、B -> C、C -> id这种长链就会算漏,表现为分析表某个格子空着。

FOLLOW 的难点在第三条规则:对产生式A -> α B β,如果β能推导出空串,那么FOLLOW(A)要并进FOLLOW(B)。代码里要逐符号从右往左扫,维护一个「后缀是否可空」的布尔量。

// FOLLOW 单轮:从右往左扫右部,记录后缀可空性 bool computeFollowOnce(const Grammar& g, const FirstMap& first, FollowMap& follow) { bool changed = false; for (const auto& p : g.productions()) { for (size_t i = 0; i < p.rhs.size(); ++i) { const std::string& sym = p.rhs[i]; if (g.isTerminal(sym)) continue; bool suffixNullable = true; // 先假设 i 之后的部分可空 for (size_t j = i + 1; j < p.rhs.size(); ++j) { const std::string& nxt = p.rhs[j]; if (g.isTerminal(nxt)) { changed |= follow[sym].insert(nxt).second; suffixNullable = false; break; } for (const auto& s : first[nxt]) if (s != kEpsilon) changed |= follow[sym].insert(s).second; if (!first[nxt].count(kEpsilon)) { suffixNullable = false; break; } } if (suffixNullable) // 后缀可空,把 FOLLOW(左部) 并进来 for (const auto& s : follow[p.lhs]) changed |= follow[sym].insert(s).second; } } return changed; }

follow[开始符号]里要先塞进$,这是整个 FOLLOW 集合的种子,漏了这一句所有 FOLLOW 都是空的,分析表里凡是靠 FOLLOW 填的格子全空。

4.3 LL(1) 分析表:填表规则与冲突检测

分析表本质上是个二维映射:行是非终结符,列是终结符(含$),格子里放产生式 id。填表规则两条:对A -> α,FIRST(α)里每个终结符a对应的格子填这条产生式;如果α可空,那么FOLLOW(A)里每个终结符对应的格子也填它。

// src/table.cpp 填表核心 ParseTable buildTable(const Grammar& g, const GrammarSets& sets) { ParseTable table; for (const auto& p : g.productions()) { std::set<std::string> select = selectSet(g, sets, p); // FIRST(α) 减去 ε if (nullable(g, sets, p.rhs)) // α 可空则并入 FOLLOW select.insert(sets.follow.at(p.lhs).begin(), sets.follow.at(p.lhs).end()); for (const auto& a : select) { auto& cell = table[p.lhs][a]; if (cell.has_value() && cell.value() != p.id) { // 同一格子被两条产生式命中 = LL(1) 冲突 table.addConflict(p.lhs, a, cell.value(), p.id); } cell = p.id; } } return table; }

ParseTable用std::map<std::string, std::map<std::string, std::optional<int>>>实现,optional的空值正好表示「这条产生式不该出现在这个格子里」。冲突不抛异常而是攒在conflicts_里,最后统一打印,这样一次运行就能看到文法里所有冲突点,不用改一次跑一次。

冲突报告要打得出诊断价值,光说「冲突」没用,得说明是哪两条产生式、在哪个终结符上打架。常见的二义性文法(比如E -> E + E | E * E | id)一定会在这里报冲突,这不是 bug,而是提醒你该提左公因子或者重写文法了。

4.4 两套执行器:递归下降与表驱动

表驱动版本用显式栈,逻辑是一个大循环,适合演示算法本质。

// src/parser_table.cpp std::unique_ptr<AstNode> Parser::parseByTable() { std::vector<std::string> stack{"$", grammar_.startSymbol()}; std::vector<std::unique_ptr<AstNode>> nodes; // 与 stack 同步的节点栈 size_t pos = 0; while (!stack.empty()) { std::string top = stack.back(); const Token& look = tokens_[pos]; if (top == "$" && look.type == TokenType::END) return std::move(nodes.back()); if (grammar_.isTerminal(top) || top == "$") { if (top == lexemeOf(look)) { // 终结符匹配成功,同步弹出 stack.pop_back(); nodes.pop_back(); ++pos; } else { reportError(look, top); // 期望 top,实际是 look return nullptr; } } else { auto it = table_.find(top); auto cell = (it != table_.end()) ? it->second.find(lexemeOf(look)) : {}; if (cell == it->second.end() || !cell->second.has_value()) { reportError(look, top); // 表里没项,属于语法错误 return nullptr; } const Production& p = grammar_.production(cell->second.value()); stack.pop_back(); nodes.pop_back(); // 逆序压栈,保证最左符号在栈顶 for (auto rit = p.rhs.rbegin(); rit != p.rhs.rend(); ++rit) { stack.push_back(*rit); nodes.push_back(makeNode(*rit, look.line)); // 节点同步压入 } } } return nullptr; }

逆序压栈是这段代码的关键点,写反了会导致语法树子节点顺序颠倒,现象是表达式a + b被打成b + a,看起来像词法问题,其实是栈操作问题。

递归下降版本则是每个非终结符一个函数,函数内部先查表决定走哪条产生式,再依次调用右部符号对应的函数。

// src/parser_rd.cpp —— 每个非终结符一个成员函数 bool Parser::parseE(AstNode* parent) { // 查表:E 这一行,看当前 lookahead 指向哪条产生式 int pid = tableLookup("E", lexemeOf(tokens_[pos_])); if (pid < 0) return fail("E"); const Production& p = grammar_.production(pid); auto child = makeNode("E", tokens_[pos_].line); AstNode* raw = child.get(); parent->children.push_back(std::move(child)); // E -> T E',按右部顺序逐个匹配 for (const auto& sym : p.rhs) { if (grammar_.isTerminal(sym)) { if (lexemeOf(tokens_[pos_]) != sym) return fail(sym); raw->children.push_back(makeTerminal(tokens_[pos_++])); } else { if (!dispatch(sym, raw)) return false; // 递归调用对应非终结符函数 } } return true; }

dispatch就是一张std::function<bool(AstNode*)>的映射表,按符号名分发到具体函数。这样写的好处是加文法时只需要改表加一个函数,不用动主循环。两套执行器跑同一个输入,输出的语法树文本必须逐字节一致,我在测试脚本里就是拿这个做回归的。

4.5 语法树构造与错误恢复

语法树节点的创建要和栈操作绑定,不能事后补。上面的表驱动版本里,每次压栈都同步压一个节点,弹出时同步弹出,保证节点栈和符号栈永远对齐。这个「同步」是很多实现出问题的地方:节点栈少压一个,最后返回的指针就会指向已释放内存。

错误恢复采用 panic mode,遇到语法错误时跳过 Token 直到碰到 FOLLOW 集合里的同步符号为止。

// 遇到预期外的 Token 时,跳到同步集合里的符号再继续 void Parser::synchronize(const std::set<std::string>& follow) { while (tokens_[pos_].type != TokenType::END && !follow.count(lexemeOf(tokens_[pos_]))) { ++pos_; // 丢弃不属于 FOLLOW 的 Token } // 不消费同步符号本身,留给上层重新尝试匹配 }

同步集合一般直接用出错非终结符的 FOLLOW 集合,也可以再加上语句结束符;和右括号)作为兜底。不消费同步符号这一点很重要:消费掉的话,上层函数重新匹配时会发现符号不见了,错误会连环报。这套机制只能保证「不崩溃、能报出第一个错误」,想要恢复后继续解析出部分语法树,还得在每层函数里加错误节点占位,属于进阶改造。

5. 跑通与排查:从编译报错到错误恢复的实战记录

5.1 编译、运行与 trace 输出

工程本身没有第三方依赖,标准库够用,构建和运行只有两步。

# 方式一:CMake cmake -B build -DCMAKE_BUILD_TYPE=Debug cmake --build build -j # 方式二:直接 g++,改代码后验证更快 g++ -std=c++17 -g -Wall -Wextra -Iinclude src/*.cpp -o syntax # 跑一个样例,打开 trace 看中间结果 ./syntax --grammar grammar/expr.grm --input test/case1.txt --trace

--trace打开后会依次打印四个部分:归一化后的 Token 流、FIRST/FOLLOW 集合、预测分析表、语法树。调文法的时候我基本都开着,因为绝大多数错误都是「分析表某个格子空着」或者「分析表出现冲突」,看一眼表就知道是 FIRST 算漏了还是文法本身有歧义。

如果只想验证表驱动和递归下降是否一致,加个--both参数,程序会跑两遍再比对语法树文本,不一致时打印两份输出到stderr。这个开关是我后来加的,用它抓到过好几次递归下降里分支漏写的 bug。

5.2 用样例核对预期输出

test/目录下的样例按难度分了三档:case1是最简单的id + id * id,case2带括号嵌套,case3故意混入语法错误用来验证错误恢复。每个样例配一个.expected文件存期望语法树,run_all.sh里逐个比对。

#!/usr/bin/env bash # test/run_all.sh —— 比对退出码和语法树文本 fail=0 for input in test/case*.txt; do name="${input%.txt}" ./syntax --grammar grammar/expr.grm --input "$input" > /tmp/out.txt 2>/dev/null code=$? if ! diff -q /tmp/out.txt "${name}.expected" > /dev/null; then echo "FAIL: $name (exit=$code)" diff /tmp/out.txt "${name}.expected" | head -20 fail=1 fi done exit $fail

退出码单独看,是因为有些样例期望失败(case3期望退出码 4),如果只比语法树文本,失败样例输出为空、期望文件也为空,diff 就通过了,等于没测。这种「用空输出通过测试」的假阳性我踩过一次,之后所有失败样例的.expected里都写上了具体的错误提示行。

5.3 踩坑记录:五个真实翻车现场

现象一:程序在id + id上直接段错误,gdb 显示崩在nodes.back()。原因是表驱动循环里弹出栈时只弹了符号栈,没有同步弹节点栈,两栈长度不一致,最后nodes.back()访问空容器。解决是把两个栈封装成一个struct Frame { std::string symbol; std::unique_ptr<AstNode> node; },进出永远成对操作,从结构上就不给不同步的机会。

现象二:文法里明明有E' -> ε,运行时报「表里没有 E' 对)的项」。原因是FOLLOW(E')算空了。追下去发现求 FOLLOW 时先算了 FIRST 再算 FOLLOW,但 FOLLOW 里的种子$只在第一次迭代前塞进去了,而迭代函数在拷贝时把种子丢了。解决是把follow[start].insert("$")放在while循环外面而不是循环体里,并且每轮迭代直接改引用而不是改副本。这类「集合被拷来拷去」的 bug 很玄学,后来的习惯是所有集合一律用引用传参。

现象三:表达式a + b * c生成的语法树优先级颠倒了。这不是集合算错,是表驱动压栈时用了正序遍历右部。栈是后进先出,正序压入会让最右的符号先出栈,等于把右结合性写反了。改成rbegin()/rend()逆序压栈后,树结构立刻对上了。递归下降版本反而是正序调用,因为函数调用天然按顺序执行,两套实现的正序/逆序不一样,改代码时特别容易混。

现象四:词法器在a<=b上切出四个 Token,语法报错位置错得离谱。readOperator里只判断了单字符,没有做最长匹配,把<=切成了<和=。而错误报告指向的是=那个位置,看起来像表达式缺操作数。解决是在读运算符时先试双字符再退单字符,并且把「最长匹配」写进词法器的注释里,避免后来人再踩。

现象五:同样的文法,--use-table和递归下降跑出来的树文本差了空格,回归测试红了一片。语法树打印用的是缩进递归,两套实现的子节点顺序一致,但递归下降在某个分支里多建了一个包装节点(把E'的中间结果又包了一层)。树结构上没差别,打印出来多了一级缩进。解决是统一让makeNode只接受产生式左部标签,禁止手写临时节点名,同时在run_all.sh里加一步「两套实现互相比对」,让这类不一致当场暴露而不是靠人眼。

6. 二次开发:把文法换掉、把算法替掉、把结果画出来

这份工程的扩展点很明确,下面三条改造路径按难度从低到高,都能在半天内看到效果。

换文法是最常见的需求。把grammar/expr.grm换成一份支持if-else和while的语句文法,会发现立刻报冲突,多半是悬空 else 问题。处理方式有两种:改文法消除二义性(把 else 和最近的 if 绑定),或者在冲突报告里看清楚是哪两条产生式打架后手工调整优先级。改完文法后必须重跑--both,因为递归下降里每个非终结符对应一个手写函数,加新符号就得加新函数,很容易漏。我的习惯是在dispatch表里留一个默认分支,遇到未注册的非终结符直接assert(false)报出来,而不是静默返回 true。

换算法是把 LL(1) 换成 LR(0) 或 SLR(1)。表结构不用动,ParseTable还是二维映射,但列从终结符变成「终结符 + 非终结符」,格子内容从「产生式 id」变成「移进/归约动作」。需要新增的是项目集闭包(closure)和状态转移(goto)两个函数,主循环改成维护状态栈。这里有个复用技巧:Grammar和GrammarSets完全不用改,直接拿来算 LR 的 FOLLOW 集合用于归约决策。我试过在原有代码上做这个替换,改动量集中在新增一个lr_table.cpp,其他文件一行没动,这得益于前面把文法数据和执行逻辑拆开了。

改造方向新增文件需改动文件大致工作量
换文法无grammar/*.grm、parser_rd.cpp1 小时
换 LR 算法lr_table.cppmain.cpp加分支半天
加 AST 可视化ast_dump.cppmain.cpp调用点2 小时

把语法树画出来是交付时最讨喜的一步。不用引入图形库,导出一份 DOT 文本,用系统里现成的dot命令转成 PNG 就行。节点 id 用静态计数器分配,边从父指向子。

// src/ast_dump.cpp —— 导出 DOT,配合外部工具渲染 void dumpDot(const AstNode& node, std::ostream& os, int& idCounter) { int myId = idCounter++; os << " n" << myId << " [label=\"" << node.label; if (!node.value.empty()) os << "\\n" << node.value; // 终结符带上词素 os << "\"];\n"; for (const auto& child : node.children) { int childId = idCounter; // 子节点 id 在递归前先占位 os << " n" << myId << " -> n" << childId << ";\n"; dumpDot(*child, os, idCounter); } }

这段代码有个小陷阱:子节点 id 必须在递归前就确定,否则递归里idCounter增了几次你不知道,边就指错节点了。上面先读idCounter存进childId,再递归让它自己分配,正好对得上。

从那以后我每次动文法都会先跑一遍--trace把分析表打出来看一眼,再跑--both比对两套执行器的结果,最后才看语法树。中间任何一步跳过,后面就得花几倍时间回头找。希望这套流程帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/11/2 2:32:17

Vue Devtools 6.6.4 Chrome离线包加载与调试排错指南

简介&#xff1a;面向 Vue.js 3 开发者的 Chrome 调试扩展安装包&#xff0c;版本为 vue-devtools 6.6.4&#xff0c;适合需要深入观察组件运行状态、排查事件与路由问题、提升前端调试效率的开发者使用&#xff0c;也可作为熟悉 Vue 项目结构后进一步掌握调试流程的辅助材料。…

作者头像 李华
网站建设 2026/11/2 2:31:30

Django邮件发送全链路:SMTP配置、异步与排错实战

上周有个朋友半夜发消息问我&#xff0c;说他项目里的注册激活邮件“有时候能发出去&#xff0c;有时候石沉大海”&#xff0c;日志里就一行SMTPServerDisconnected&#xff0c;看得他一脸茫然。其实这个场景我太熟了——Django 配置邮箱服务、实现把信息发送到指定邮箱&#x…

作者头像 李华
网站建设 2026/11/2 2:31:26

AIX 7.1/7.2安装介质获取、校验与装机避坑指南

前几天夜里&#xff0c;一个做运维的老哥给我发消息&#xff0c;说手头一台跑了好几年的业务机系统盘出了问题&#xff0c;业务要赶紧拉起来&#xff0c;结果翻出手里那份 AIX 7.1 的安装镜像&#xff0c;校验一看是坏的&#xff0c;于是开始满世界找 "AIX 7.2 和 AIX 7.1…

作者头像 李华
网站建设 2026/11/2 2:30:39

CS231n Assignment1深度解析:手写KNN与Softmax的底层原理与工程价值

简介&#xff1a;本资源是斯坦福大学CS231n《计算机视觉中的卷积神经网络》课程Assignment 1的完整实践包&#xff0c;面向深度学习初学者、高校学生及自学研究者&#xff0c;聚焦图像分类核心任务——KNN、SVM与两层全连接网络的实现与调优。压缩包共54个文件&#xff0c;含7个…

作者头像 李华
网站建设 2026/11/2 2:30:28

间断有限元求解声波方程:从源码解析到二次开发实战

简介&#xff1a;面向数值分析与偏微分方程求解方向的学习者&#xff0c;这份资源聚焦二维声波方程的间断有限元&#xff08;DG&#xff09;数值实现&#xff0c;将空间离散与三阶龙格库塔时间推进相结合&#xff0c;适合已具备Matlab基础、希望深入理解间断伽辽金方法的中高级…

作者头像 李华
网站建设 2026/11/2 2:27:55

CoreDNS v1.8.0 源码拆包、插件裁剪与 k8s DNS 替换排错

简介&#xff1a;面向 Kubernetes 集群运维与容器网络调试场景&#xff0c;这份 CoreDNS v1.8.0 离线镜像包解决的是集群内 DNS 服务组件在无外网或受限网络环境下的部署与升级问题&#xff0c;适用于正在搭建 k8s v1.21.2 集群、需要固定组件版本的开发与运维人员。压缩包共 8…

作者头像 李华