用C++ 11手撸一个预测分析器:从FIRST/FOLLOW集到完整语法分析(附避坑指南)
用C11实现预测分析器从理论到实战的完整指南编译原理课程中最令人头疼的环节之一就是语法分析器的实现。当面对FIRST集、FOLLOW集这些抽象概念时许多同学都会感到无从下手。本文将带你用C11一步步构建一个完整的预测分析器不仅解释核心算法更重要的是分享如何将这些理论转化为可运行的代码。1. 环境准备与基础设计在开始编码前我们需要明确几个关键点。预测分析器主要适用于LL(1)文法这类文法要求每个非终结符的各个产生式的FIRST集互不相交且如果某个产生式能推导出空串那么它的FIRST集和FOLLOW集也不能有交集。开发环境配置编译器GCC 8.3.0或更高版本构建工具CMake推荐或直接使用IDE如CLionC标准C11或更高基础数据结构设计#include unordered_set #include unordered_map #include vector #include string // 产生式规则左部非终结符 → 右部字符串 using ProductionRule std::pairchar, std::string; // 文法定义 struct Grammar { std::unordered_setchar non_terminals; // 非终结符集合 std::unordered_setchar terminals; // 终结符集合 std::vectorProductionRule rules; // 产生式规则 char epsilon $; // 空字符表示 char start_symbol; // 开始符号 };2. FIRST集的计算实现FIRST集的计算是预测分析器的核心之一。我们需要为每个符号终结符和非终结符计算其FIRST集。终结符的FIRST集就是它自身而非终结符的FIRST集则需要通过迭代计算得到。计算步骤初始化所有符号的FIRST集对于每个产生式按照规则更新FIRST集重复步骤2直到所有FIRST集不再变化关键代码实现using SymbolSet std::unordered_setchar; struct FirstFollowSet { SymbolSet first; SymbolSet follow; bool has_epsilon false; }; using SymbolTable std::unordered_mapchar, FirstFollowSet; void computeFirstSets(Grammar grammar, SymbolTable symbol_table) { bool changed; do { changed false; for (const auto rule : grammar.rules) { char lhs rule.first; const std::string rhs rule.second; size_t i 0; bool all_has_epsilon true; while (i rhs.size() all_has_epsilon) { char symbol rhs[i]; // 记录原始大小以检测变化 size_t original_size symbol_table[lhs].first.size(); // 如果是终结符或空字符 if (grammar.terminals.count(symbol) || symbol grammar.epsilon) { symbol_table[lhs].first.insert(symbol); if (symbol grammar.epsilon) { symbol_table[lhs].has_epsilon true; } else { all_has_epsilon false; } } // 如果是非终结符 else { // 将非终结符的FIRST集(除ε外)加入 for (char ch : symbol_table[symbol].first) { if (ch ! grammar.epsilon) { symbol_table[lhs].first.insert(ch); } } // 如果该非终结符的FIRST集包含ε继续检查下一个符号 if (!symbol_table[symbol].has_epsilon) { all_has_epsilon false; } } // 检查是否发生了变化 if (symbol_table[lhs].first.size() original_size) { changed true; } i; } // 如果所有符号都能推导出ε则将ε加入FIRST集 if (all_has_epsilon !symbol_table[lhs].has_epsilon) { symbol_table[lhs].has_epsilon true; symbol_table[lhs].first.insert(grammar.epsilon); changed true; } } } while (changed); }常见陷阱忘记处理产生式右部所有符号都能推导出空串的情况在迭代过程中没有正确判断何时停止检查后续符号没有正确处理终结符和空字符的特殊情况3. FOLLOW集的计算技巧FOLLOW集的计算比FIRST集更为复杂因为它需要考虑产生式右部符号之间的关系以及递归依赖。计算时需要特别注意顺序通常需要多次遍历所有产生式直到结果稳定。计算算法要点将结束符#加入开始符号的FOLLOW集对于每个产生式A→αBβ将FIRST(β)中除ε外的元素加入FOLLOW(B)如果β能推导出ε将FOLLOW(A)加入FOLLOW(B)对于产生式A→αB将FOLLOW(A)加入FOLLOW(B)实现代码void computeFollowSets(Grammar grammar, SymbolTable symbol_table) { // 初始化开始符号的FOLLOW集 symbol_table[grammar.start_symbol].follow.insert(#); bool changed; do { changed false; for (const auto rule : grammar.rules) { char lhs rule.first; const std::string rhs rule.second; for (size_t i 0; i rhs.size(); i) { char B rhs[i]; // 只处理非终结符 if (grammar.non_terminals.count(B) 0) continue; // 检查B后面是否有符号 if (i 1 rhs.size()) { char beta rhs[i1]; // 将FIRST(beta)中除ε外的元素加入FOLLOW(B) size_t original_size symbol_table[B].follow.size(); if (grammar.terminals.count(beta)) { // beta是终结符 symbol_table[B].follow.insert(beta); } else { // beta是非终结符加入其FIRST集(除ε外) for (char ch : symbol_table[beta].first) { if (ch ! grammar.epsilon) { symbol_table[B].follow.insert(ch); } } } // 如果beta能推导出ε需要将FOLLOW(A)加入FOLLOW(B) bool beta_has_epsilon grammar.terminals.count(beta) ? false : symbol_table[beta].has_epsilon; if (beta_has_epsilon) { for (char ch : symbol_table[lhs].follow) { symbol_table[B].follow.insert(ch); } } if (symbol_table[B].follow.size() original_size) { changed true; } } // B是产生式最后一个符号将FOLLOW(A)加入FOLLOW(B) else { size_t original_size symbol_table[B].follow.size(); for (char ch : symbol_table[lhs].follow) { symbol_table[B].follow.insert(ch); } if (symbol_table[B].follow.size() original_size) { changed true; } } } } } while (changed); }调试技巧在每次迭代后打印FOLLOW集的变化便于发现问题特别注意那些FOLLOW集应该包含结束符#的非终结符检查是否有非终结符的FOLLOW集始终为空这通常意味着计算有误4. 预测分析表的构建策略有了FIRST集和FOLLOW集后我们就可以构建预测分析表了。预测分析表是一个二维表格行对应非终结符列对应终结符包括结束符#每个单元格指示应该使用哪个产生式。表格构建规则对于每个产生式A→α对于FIRST(α)中的每个终结符a不包括ε将A→α加入M[A,a]如果ε在FIRST(α)中对于FOLLOW(A)中的每个终结符b包括#将A→α加入M[A,b]如果任何表项有多个产生式则文法不是LL(1)文法数据结构选择 由于非终结符和终结符都是字符我们可以使用压缩技术将两个字符组合成一个整数作为哈希键。#include cstdint using PredictTable std::unordered_mapuint16_t, int; uint16_t makeKey(char non_terminal, char terminal) { return (static_castuint16_t(non_terminal) 8) | static_castuint16_t(terminal); } void buildPredictTable(const Grammar grammar, const SymbolTable symbol_table, PredictTable predict_table) { int rule_index 0; for (const auto rule : grammar.rules) { char A rule.first; const std::string alpha rule.second; // 计算FIRST(alpha) SymbolSet first_alpha; bool alpha_has_epsilon true; for (char X : alpha) { if (grammar.terminals.count(X)) { first_alpha.insert(X); alpha_has_epsilon false; break; } else if (X grammar.epsilon) { first_alpha.insert(grammar.epsilon); break; } else { // 加入FIRST(X)中除ε外的符号 for (char a : symbol_table.at(X).first) { if (a ! grammar.epsilon) { first_alpha.insert(a); } } // 如果FIRST(X)不包含ε停止 if (!symbol_table.at(X).has_epsilon) { alpha_has_epsilon false; break; } } } // 对于FIRST(alpha)中的每个终结符a添加表项 for (char a : first_alpha) { if (a ! grammar.epsilon) { uint16_t key makeKey(A, a); if (predict_table.count(key) predict_table[key] ! rule_index) { std::cerr Conflict in predict table: M[ A , a ] has multiple rules std::endl; } predict_table[key] rule_index; } } // 如果α能推导出ε对于FOLLOW(A)中的每个终结符b添加表项 if (alpha_has_epsilon || alpha.find(grammar.epsilon) ! std::string::npos) { for (char b : symbol_table.at(A).follow) { uint16_t key makeKey(A, b); if (predict_table.count(key) predict_table[key] ! rule_index) { std::cerr Conflict in predict table: M[ A , b ] has multiple rules std::endl; } predict_table[key] rule_index; } } rule_index; } }优化技巧使用位运算压缩字符对为整数键提高哈希表效率在构建过程中检测并报告冲突帮助发现文法问题可以为常用终结符预先计算哈希值减少运行时计算5. 总控程序的实现细节总控程序是预测分析器的执行引擎它使用分析栈和输入缓冲区按照预测分析表进行推导。实现时需要注意错误处理和状态跟踪。算法流程初始化栈为开始符号和结束符#读取第一个输入符号循环直到栈为空如果栈顶是终结符且匹配输入弹出栈顶并消费输入如果栈顶是非终结符查表选择产生式进行推导如果出现不匹配或表项为空报告语法错误核心实现#include stack #include iostream void predictiveParser(Grammar grammar, const PredictTable predict_table, const std::string input) { std::stackchar parse_stack; parse_stack.push(#); parse_stack.push(grammar.start_symbol); size_t input_pos 0; char current_input input.empty() ? # : input[input_pos]; std::cout Step\tStack\t\tInput\t\tAction std::endl; int step 0; while (!parse_stack.empty()) { char X parse_stack.top(); // 打印当前状态 std::cout step \t; printStack(parse_stack); std::cout \t; printInput(input, input_pos); std::cout \t; // 栈顶是终结符或# if (X current_input) { parse_stack.pop(); if (current_input ! #) { input_pos; current_input input_pos input.size() ? input[input_pos] : #; } std::cout Match X std::endl; } else if (grammar.terminals.count(X) || X #) { std::cout Error: expecting X but found current_input std::endl; return; } // 栈顶是非终结符 else { uint16_t key makeKey(X, current_input); if (predict_table.count(key)) { int rule_idx predict_table.at(key); const auto rule grammar.rules[rule_idx]; parse_stack.pop(); // 将产生式右部逆序压栈 if (rule.second ! $) { // 如果不是空产生式 for (auto it rule.second.rbegin(); it ! rule.second.rend(); it) { parse_stack.push(*it); } } std::cout Apply rule.first - rule.second std::endl; } else { std::cout Error: no production for X on current_input std::endl; return; } } } if (current_input #) { std::cout Parse successful! std::endl; } else { std::cout Error: extra input after parse std::endl; } }辅助函数void printStack(const std::stackchar s) { std::stackchar temp s; while (!temp.empty()) { std::cout temp.top(); temp.pop(); } } void printInput(const std::string input, size_t pos) { if (pos input.size()) { std::cout #; } else { std::cout input.substr(pos); } }错误处理建议提供详细的错误信息包括位置和预期符号考虑实现错误恢复机制如恐慌模式或短语级恢复可以添加语法错误收集功一次报告所有错误而非遇到第一个错误就停止6. 测试与调试实战经验完成预测分析器实现后测试是确保其正确性的关键环节。好的测试案例应该覆盖各种边界情况和可能的错误模式。测试文法示例E T A F B * ( ) i E TA A TA A $ T FB B *FB B $ F (E) F i测试案例设计合法输入测试ii*i(简单表达式)(ii)*i(带括号)i(最小合法输入)错误输入测试i(不完整表达式)ii(连续运算符)(i(不匹配的括号)i*ii)多余的右括号调试输出示例Step Stack Input Action 0 #E ii*i# Apply E - TA 1 #AT ii*i# Apply T - FB 2 #ABF ii*i# Apply F - i 3 #ABi ii*i# Match i 4 #AB i*i# Apply B - $ 5 #A i*i# Apply A - TA 6 #AT i*i# Match 7 #AT i*i# Apply T - FB 8 #ABF i*i# Apply F - i 9 #ABi i*i# Match i 10 #AB *i# Apply B - *FB 11 #ABF* *i# Match * 12 #ABF i# Apply F - i 13 #ABi i# Match i 14 #AB # Apply B - $ 15 #A # Apply A - $ 16 # # Match # Parse successful!性能优化建议对于大型文法考虑使用更高效的数据结构如std::bitset表示集合缓存中间计算结果避免重复计算对于固定文法可以预计算分析表并序列化保存7. 高级话题与扩展思路掌握了基础实现后可以考虑以下几个方向的扩展错误恢复机制恐慌模式发现错误时跳过输入直到同步符号短语级恢复在错误位置插入/删除/替换符号错误产生式为常见错误添加特殊产生式可视化工具实现分析过程的图形化展示生成分析树或推导过程的动画交互式单步执行调试文法分析增强自动检测文法是否为LL(1)对非LL(1)文法提出转换建议文法优化建议消除左递归、提取左公因子与其他组件集成与词法分析器无缝衔接生成抽象语法树(AST)作为输出添加简单的语义动作实现一个完整的预测分析器是理解编译原理的重要一步。通过这个项目你不仅掌握了LL(1)分析的核心算法还学会了如何将复杂理论转化为实际代码。当看到自己实现的解析器能够正确分析各种输入时那种成就感会让你觉得所有的努力都是值得的。