用C语言手搓一个表达式语法检查器:从文法定义到算符优先表实战
用C语言手搓一个表达式语法检查器从文法定义到算符优先表实战在编译原理的学习中语法分析器是连接词法分析和语义分析的关键桥梁。而表达式语法检查作为最基础也最典型的应用场景是理解自底向上分析方法的绝佳切入点。本文将带你用C语言从零实现一个完整的算符优先分析器不仅能够检测表达式语法错误还能在过程中完成简单的算术运算。1. 环境准备与基础架构任何编译器的实现都始于对文法的定义。我们采用经典的算术表达式文法作为起点// 文法规则定义 const char* grammar_rules[] { E→ET, E→E-T, E→T, T→T*F, T→T/F, T→F, F→(E), F→i };这个文法清晰地表达了加减乘除和括号的优先级关系。为了处理这个文法我们需要设计几个核心数据结构// 终结符与非终结符集合 char terminals[] {, -, *, /, (, ), i, #}; char non_terminals[] {E, T, F}; // 优先关系表 typedef enum {LESS, EQUAL, GREATER, NONE} Precedence; Precedence precedence_table[8][8]; // 对应terminals数组大小关键点#作为表达式边界标记被加入终结符集合这在后续分析中至关重要。优先关系表将使用二维数组实现每个单元格存储两个终结符之间的优先级关系。2. 构建FirstVT和LastVT集合算符优先分析的核心在于确定运算符之间的优先级关系而FirstVT和LastVT集合正是计算这些关系的基础。2.1 FirstVT集合计算算法FirstVT(P)定义为非终结符P能推导出的最左终结符集合。计算过程遵循以下规则若有产生式 P→a... 或 P→Qa...则 a ∈ FirstVT(P)若有产生式 P→Q...则 FirstVT(Q) ⊆ FirstVT(P)实现代码示例void compute_firstvt() { // 初始化 for(int i0; isizeof(non_terminals); i) { firstvt[i][0] \0; } // 应用规则1 for(int i0; isizeof(grammar_rules)/sizeof(char*); i) { char left grammar_rules[i][0]; char first_char grammar_rules[i][3]; if(is_terminal(first_char)) { add_to_firstvt(left, first_char); } } // 应用规则2需要多次迭代直到不再变化 bool changed; do { changed false; for(int i0; isizeof(grammar_rules)/sizeof(char*); i) { char left grammar_rules[i][0]; char first_non_term grammar_rules[i][3]; if(is_non_terminal(first_non_term)) { if(merge_firstvt(left, first_non_term)) { changed true; } } } } while(changed); }2.2 LastVT集合的实现LastVT的计算与FirstVT对称关注的是最右终结符。实现时只需调整扫描方向void compute_lastvt() { // 类似FirstVT但从右往左扫描 for(int i0; isizeof(grammar_rules)/sizeof(char*); i) { int len strlen(grammar_rules[i]); char left grammar_rules[i][0]; char last_char grammar_rules[i][len-1]; if(is_terminal(last_char)) { add_to_lastvt(left, last_char); } else if(len 4) { // 处理 Qa 情况 char before_last grammar_rules[i][len-2]; if(is_terminal(before_last)) { add_to_lastvt(left, before_last); } } } // 迭代传播规则... }3. 构造算符优先关系表有了FirstVT和LastVT集合我们就可以填充优先关系表了。关系确定规则如下情况关系实现方法a ba ≐ b直接相邻的终结符a ba ⋖ bb ∈ FirstVT(N), N是a后的非终结符a ba ⋗ ba ∈ LastVT(N), N是b前的非终结符对应的表格生成代码void build_precedence_table() { // 初始化所有关系为NONE memset(precedence_table, NONE, sizeof(precedence_table)); // 处理关系 for(int i0; isizeof(grammar_rules)/sizeof(char*); i) { char* rule grammar_rules[i]; for(int j3; rule[j1]; j) { if(is_terminal(rule[j]) is_terminal(rule[j1])) { set_relation(rule[j], rule[j1], EQUAL); } } } // 处理关系 (a FirstVT(N)) for(int i0; isizeof(grammar_rules)/sizeof(char*); i) { char* rule grammar_rules[i]; for(int j3; rule[j]; j) { if(is_terminal(rule[j]) is_non_terminal(rule[j1])) { char N rule[j1]; for(int k0; firstvt[N][k]; k) { set_relation(rule[j], firstvt[N][k], LESS); } } } } // 处理关系 (LastVT(N) a) // 类似上面方向相反... }调试技巧在开发过程中可以添加一个打印优先关系表的函数方便验证结果是否正确void print_precedence_table() { printf( ); for(int i0; terminals[i]; i) printf(%3c, terminals[i]); printf(\n); for(int i0; terminals[i]; i) { printf(%3c, terminals[i]); for(int j0; terminals[j]; j) { char rel ; switch(precedence_table[i][j]) { case LESS: rel ; break; case EQUAL: rel ; break; case GREATER: rel ; break; } printf(%3c, rel); } printf(\n); } }4. 实现分析算法与错误处理算符优先分析的核心是一个基于栈的移进-归约算法。我们使用两个栈一个符号栈存储语法符号一个值栈用于实际计算。4.1 分析器主循环typedef struct { char data[100]; int top; } Stack; void parse_expression(const char* input) { Stack symbol_stack {{#}, 1}; Stack value_stack {{0}, 0}; int pos 0; while(1) { char a input[pos]; char top_term get_top_terminal(symbol_stack); Precedence rel get_relation(top_term, a); switch(rel) { case LESS: case EQUAL: // 移进 push(symbol_stack, a); if(isdigit(a)) { push(value_stack, a-0); } pos; break; case GREATER: { // 归约 char rhs[100]; int rhs_len find_reducible_sequence(symbol_stack, rhs); if(rhs_len -1) { printf(Error: No reducible sequence found\n); return; } // 执行归约和计算 reduce(symbol_stack, rhs_len); int result compute_value(value_stack, rhs_len); push(value_stack, result); break; } case NONE: if(a # top_term #) { printf(Accept! Result: %d\n, pop(value_stack)); return; } else { printf(Error: Invalid operator precedence\n); return; } } } }4.2 常见的错误处理场景在实际应用中我们需要处理各种可能的语法错误括号不匹配通过检查栈中是否有对应的开括号运算符缺失如2 3这样的表达式非法字符不在终结符集合中的字符优先级冲突关系表中未定义的关系错误检测代码示例void check_for_errors(const Stack* stack, char next_char) { if(next_char ) !has_matching_parenthesis(stack)) { printf(Error: Unmatched )\n); exit(1); } if(is_operator(stack-data[stack-top-1]) is_operator(next_char)) { printf(Error: Consecutive operators\n); exit(1); } }5. 性能优化与扩展思路基础的算符优先分析器完成后我们可以从几个方向进行优化和扩展5.1 内存优化原始实现中使用了固定大小的数组可以改进为动态扩容的栈结构typedef struct { char* data; int top; int capacity; } DynamicStack; void init_stack(DynamicStack* s, int initial_capacity) { s-data malloc(initial_capacity); s-top 0; s-capacity initial_capacity; } void push(DynamicStack* s, char c) { if(s-top s-capacity) { s-capacity * 2; s-data realloc(s-data, s-capacity); } s-data[s-top] c; }5.2 支持更多运算符要扩展新的运算符如指数运算^需要在文法中添加新规则更新终结符集合调整优先关系表确保新运算符有正确的优先级5.3 错误恢复机制更健壮的实现应该包含错误恢复能力而不是遇到第一个错误就退出。基本的恢复策略包括恐慌模式跳过输入直到找到同步字符短语级恢复尝试局部修正如插入缺失的括号错误产生式在文法中预定义常见错误模式void error_recovery(Stack* stack, const char** input) { // 跳过输入直到找到同步字符 while(**input !is_sync_char(**input)) { (*input); } // 弹出栈中内容直到找到同步非终结符 while(stack-top 0 !is_sync_nonterminal(stack-data[stack-top-1])) { stack-top--; } }6. 测试与验证策略完善的测试是确保分析器正确性的关键。我们应该设计多种测试用例void run_tests() { struct TestCase { const char* input; bool should_accept; int expected_result; // 对于可接受的表达式 } test_cases[] { {12*3#, true, 7}, {(12)*3#, true, 9}, {12#, false, 0}, {12)#, false, 0}, {12*#, false, 0}, // 更多测试用例... }; for(int i0; isizeof(test_cases)/sizeof(test_cases[0]); i) { printf(Testing: %s\n, test_cases[i].input); bool accepted try_parse(test_cases[i].input); assert(accepted test_cases[i].should_accept); if(accepted test_cases[i].should_accept) { assert(get_result() test_cases[i].expected_result); } } }对于更复杂的表达式可以考虑使用脚本生成随机但合法的测试用例void generate_random_tests(int count) { const char* ops -*/; srand(time(NULL)); for(int i0; icount; i) { char expr[100]; int pos 0; // 生成随机表达式 int len 5 rand() % 10; for(int j0; jlen; j) { if(j % 2 0) { expr[pos] 0 rand() % 10; // 数字 } else { expr[pos] ops[rand() % 4]; // 运算符 } } expr[pos] #; expr[pos] \0; printf(Testing random expression: %s\n, expr); try_parse(expr); } }7. 从理论到实践的思考在实现过程中有几个关键点值得特别注意边界条件处理特别是栈底和栈顶的#处理很容易出错归约时机的判断需要准确识别最左素短语的边界错误处理的完备性要考虑各种可能的非法输入情况性能与可读性的平衡教学代码可以更注重清晰性而非极致优化一个常见的坑是忘记处理连续两个非终结符的情况如T→TF这会导致优先关系计算错误。解决方法是在构建关系表时仔细检查文法中所有可能的相邻符号组合。另一个实际开发中的经验是在实现优先关系表时先用小规模的文法如只包含加减法测试核心逻辑验证正确后再扩展到完整的文法。这种渐进式开发可以大大降低调试难度。