简介:面向计算机专业编译原理课程实验的语法分析程序设计与实现文档,适合正在完成词法分析后续实验、需要把扫描器输出单词串接入语法检查与结构分析的学生参考。内容围绕算术表达式简化子集展开,给出BNF文法及LL(1)改写形式,说明预测分析表构造、分析栈与剩余串的输出方式,并以C++源程序演示“RIGHT/ERROR”判定及中间结果展示。压缩包共1个doc文件,约127KB,篇幅紧凑,涵盖实验目的、基本内容与要求、实验步骤、问题分析及源程序、结论等模块,可作为实验报告撰写和程序调试的对照材料。已有143人学习,说明该实验题目在课程中较常见。读者可据此梳理流程图与数据结构,理解LL(1)分析表填充、产生式结构体定义、合法与错例测试设计,并借鉴排错思路完成自己的语法分析程序。
1. 语法分析程序设计与实现:为什么我建议先别急着贴那份 C++ 源码
互联网上能搜到不少实验二文档,标题都叫“语法分析程序设计与实现”,真正把 LL(1) 分析表、词法输出和分析栈串起来的却不多。这份 .doc 的价值不在代码本身——它给的是算术表达式 G2[E] 的 BNF、改写后的 LL(1) 文法、预测分析表结构和一个能跑的主循环骨架;问题也在这里:原文产生式、分析表填充和空产生式处理有几处硬伤,直接复制大概率翻车。它适合正在做编译原理实验、需要把实验一扫描器接到语法分析器上的同学,也适合想快速复现 LL(1) 表驱动分析的开发者。下面按“先改文法、再写词法、再跑主循环、最后排查错例”的顺序拆。
2. 把 G2[E] 改成 LL(1) 文法:左递归消除、FIRST/FOLLOW 与预测表填充
2.1 原始 BNF 的左递归问题与改写策略
原始文法 G2[E] 写成:
E -> T | E+T | E-T T -> F | T*F | T/F F -> i | (E)这个文法直接拿来做 LL(1) 是不行的,E -> E+T和T -> T*F都是左递归。LL(1) 要求对每个非终结符的候选式,用当前输入符号就能唯一决定选哪条产生式;左递归会让分析器在E上原地打转,根本推不动。常见做法是消除左递归,把表达式改写成右递归形式:
E -> T G G -> + T G | - T G | ε T -> F S S -> * F S | / F S | ε F -> i | ( E )这里G负责处理加减法尾部,S负责处理乘除法尾部,ε表示空产生式。原文里有一段写成F->-TG、F->/FS、S->^FS,这是明显的排版串行:-TG和+TG应该属于G,*FS和/FS应该属于S,^是空串占位符。如果照抄原文那几行,分析表会整体错位,输入i+i#可能在第一步就查不到产生式。下面用结构体替代原文那一串type e,t,g,g1...对象,查表时按左部和右部精确匹配,后期改文法不用再加变量。
#include <string> #include <vector> struct Production { char lhs; // 产生式左部,单个非终结符 std::string rhs; // 产生式右部,空串用 "^" 表示 }; // 与改写后的 LL(1) 文法一一对应,顺序不重要,查表按 lhs + rhs 匹配 std::vector<Production> grammar = { {'E', "TG"}, {'G', "+TG"}, {'G', "-TG"}, {'G', "^"}, {'T', "FS"}, {'S', "*FS"}, {'S', "/FS"}, {'S', "^"}, {'F', "(E)"}, {'F', "i"} };这段代码的逻辑很直接:lhs只存一个字符,是因为 LL(1) 分析表的行就是非终结符;rhs用字符串存,逆序入栈时从后往前遍历即可;空产生式统一写成^,输出分析过程时显示G->^,压栈时跳过。参数上要注意,rhs里不要留空格,否则length和入栈顺序都会错;lhs不要写成"E"这种字符串,不然后面查表用char比较会对不上。原文用char array[5]和int length也能跑,但每次加产生式都要手工赋值,调试时很容易漏改length。
改完文法后先别写代码,手算一遍 FIRST 和 FOLLOW。FIRST(E)={i,(},FIRST(G)={+,-,ε},FIRST(T)={i,(},FIRST(S)={*,/,ε},FIRST(F)={i,(}。FOLLOW(E)={# ,)},FOLLOW(G)=FOLLOW(E)={# ,)},FOLLOW(T)={+,-,#,)},FOLLOW(S)=FOLLOW(T)={+,-,#,)},FOLLOW(F)={*,/,+,-,#,)}。判断 LL(1) 的关键是:G的候选式首符集{+,-}与FOLLOW(G)={# ,)}不相交;S的候选式首符集{*,/}与FOLLOW(S)={+,-,#,)}不相交;F的i和(也不相交。三条都满足,这张表才是合法的 LL(1) 表。很多实验代码跑不起来,不是 C++ 语法问题,而是这一步根本没验证,表填错了还在调主循环。
2.2 预测分析表怎么填:行是 E/G/T/S/F,列是七个终结符
把非终结符按E、G、T、S、F排成行,终结符按i、+、-、*、/、(、)、#排成列,得到的预测分析表如下。空单元格表示出错,G->^只填在)和#两列,S->^填在+、-、)、#四列,这是 FOLLOW 集决定的,不是随便填。
| 行\列 | i | + | - | * | / | ( | ) | # |
|---|---|---|---|---|---|---|---|---|
| E | E->TG | E->TG | ||||||
| G | G->+TG | G->-TG | G->^ | G->^ | ||||
| T | T->FS | T->FS | ||||||
| S | S->^ | S->^ | S->*FS | S->/FS | S->^ | S->^ | ||
| F | F->i | F->(E) |
原文分析表里用e、g、g1、g2、s、s1、s2、f、f1这些对象填充,列索引写成C[0][0]=e; C[0][5]=e;这种形式,可读性差,而且C[1][1]=g; C[1][2]=g1; C[1][6]=g2; C[1][7]=g2;一旦列顺序记错,G->^就会跑到*或/下面。更稳的写法是用二维指针表,空单元格统一为nullptr,查表失败直接走错误分支。
#include <array> #include <stdexcept> Production* findProd(char lhs, const std::string& rhs) { for (auto& p : grammar) { if (p.lhs == lhs && p.rhs == rhs) return &p; } throw std::runtime_error("production not found"); } // 行:E=0,G=1,T=2,S=3,F=4;列:i=0,+=1,-=2,*=3,/=4,(=5,)=6,#=7 std::array<std::array<Production*, 8>, 5> table{}; void buildTable() { // 先全部置空 for (auto& row : table) row.fill(nullptr); table[0][0] = findProd('E', "TG"); table[0][5] = findProd('E', "TG"); table[1][1] = findProd('G', "+TG"); table[1][2] = findProd('G', "-TG"); table[1][6] = findProd('G', "^"); table[1][7] = findProd('G', "^"); table[2][0] = findProd('T', "FS"); table[2][5] = findProd('T', "FS"); table[3][1] = findProd('S', "^"); table[3][2] = findProd('S', "^"); table[3][3] = findProd('S', "*FS"); table[3][4] = findProd('S', "/FS"); table[3][6] = findProd('S', "^"); table[3][7] = findProd('S', "^"); table[4][0] = findProd('F', "i"); table[4][5] = findProd('F', "(E)"); }逻辑说明:findProd按lhs和rhs精确匹配,返回grammar中对应产生式的地址;table里存指针,空指针就是错误入口,不需要再搞一个origin='N'的哨兵对象。参数上,行索引和列索引必须和终结符数组严格对应,建议把列顺序写成一个常量字符串const std::string terminals = "i+-*/()#";,查列号时用terminals.find(a),比手写0..7更不容易错。原文用C[m][n].origin != 'N'判断是否为空,前提是初始化时每个单元格都赋过'N',漏赋一个就会读到未初始化内存,这是典型的玄学崩溃来源。
注意:
G->^和S->^不是“随便填在行末”,它们只出现在 FOLLOW 集对应的列。填表前先把 FOLLOW 写纸上,填完再用错例i+#、(i+i#各跑一遍,能提前暴露大半错误。
2.3 从手算到代码:用一个小脚本校验 FIRST/FOLLOW 是否漏项
手算 FIRST/FOLLOW 容易漏,尤其是FOLLOW(F)要把*、/以及外层表达式的结束符都并进来。我一般会写一个几十行的 Python 脚本做交叉校验,不参与最终 C++ 提交,只用来对照手算结果。脚本输入就是上面那组产生式,空串用^表示,输出每个非终结符的 FIRST 和 FOLLOW。
# 仅用于校验 FIRST/FOLLOW,不参与 C++ 主程序 grammar = { 'E': ['TG'], 'G': ['+TG', '-TG', '^'], 'T': ['FS'], 'S': ['*FS', '/FS', '^'], 'F': ['(E)', 'i'], } nonterms = set(grammar.keys()) terms = set('i+-*/()#') first = {n: set() for n in nonterms} follow = {n: set() for n in nonterms} follow['E'].add('#') changed = True while changed: changed = False for lhs, prods in grammar.items(): for rhs in prods: if rhs == '^': continue for ch in rhs: if ch in terms: if ch not in first[lhs]: first[lhs].add(ch) changed = True break if ch in nonterms: before = len(first[lhs]) first[lhs] |= (first[ch] - {'^'}) if '^' not in first[ch]: break if len(first[lhs]) != before: changed = True else: first[lhs].add('^')这段脚本的关键在for ch in rhs里的break:遇到终结符就把该终结符加入 FIRST 并停止;遇到非终结符就并入它的 FIRST,如果该非终结符不能推出空串,也停止;如果整个右部都能推出空串,才把^加进去。参数上,terms要把#算进去,因为FOLLOW(E)初始就含#;grammar的右部不要写空格。跑完后重点看FOLLOW(G)是否等于FOLLOW(E),FOLLOW(S)是否等于FOLLOW(T),FOLLOW(F)是否包含*和/。如果脚本结果和手算不一致,以脚本为准,再回去查产生式有没有抄错。
3. 词法+语法串起来:从扫描器 token 串到分析栈的 C++ 主循环
3.1 把实验一输出映射成七终结符和一个 i
实验一扫描器输出的常见形式是UCON,PL,UCON,MU,ID,其中UCON是无符号常数,ID是标识符,PL是加号,MU是乘号。语法分析器只认i、+、-、*、/、(、)、#这八个符号,所以中间必须有一层映射。映射表不要散落在代码里,固定成一张表,后面换 token 名只改这一处。
| 词法 token | 含义 | 语法终结符 |
|---|---|---|
| UCON | 无符号常数 | i |
| ID | 标识符 | i |
| PL | 加号 | + |
| MI | 减号 | - |
| MU | 乘号 | * |
| DI | 除号 | / |
| LP | 左括号 | ( |
| RP | 右括号 | ) |
| END | 输入结束 | # |
如果实验一输出的是逗号分隔串,先按逗号拆开,再逐个映射,末尾补#。下面这个函数只做映射,不负责拆串,拆串放在主函数里,方便单独测试每一个 token。
#include <string> #include <stdexcept> char mapToken(const std::string& tok) { if (tok == "UCON" || tok == "ID") return 'i'; if (tok == "PL") return '+'; if (tok == "MI") return '-'; if (tok == "MU") return '*'; if (tok == "DI") return '/'; if (tok == "LP") return '('; if (tok == "RP") return ')'; if (tok == "END") return '#'; throw std::runtime_error("unknown token: " + tok); } std::string tokensToInput(const std::string& raw) { std::string input; std::string tok; for (char ch : raw) { if (ch == ',') { if (!tok.empty()) { input.push_back(mapToken(tok)); tok.clear(); } } else if (ch != ' ' && ch != '\t' && ch != '\n') { tok.push_back(ch); } } if (!tok.empty()) input.push_back(mapToken(tok)); if (input.empty() || input.back() != '#') input.push_back('#'); return input; }逻辑说明:mapToken是纯函数,遇到未知 token 直接抛异常,避免把非法字符悄悄当成i;tokensToInput负责去空白、按逗号切分、末尾补#。参数上,raw不要带中文逗号,实验一输出如果是UCON,PL,先用英文逗号替换;input里不要留空格,因为主循环是按字符位置推进的,空格会被当成非法终结符。原文主程序用cin >> ch逐字符读,读到#停止,这种方式对i+i#可以,对UCON,PL,UCON,MU,ID这种 token 串就不行了,必须先在词法层转成单字符终结符串。
3.2 分析栈和剩余串怎么对齐输出
LL(1) 分析过程要打印三样东西:分析栈、剩余输入串、所用产生式。原文用char A[30]和char B[30],top指向栈顶,b指向当前输入位置;print()里for(a=0; a<=top+1; a++) cout<<A[a];这个循环上界多了一个+1,在某些输入下会读到栈外的脏字符,输出看起来像“分析栈末尾多一个乱码”。用std::vector<char>和std::string可以避开这个坑,栈底固定放#,栈顶用back()取。
#include <iostream> #include <string> #include <vector> void printStack(const std::vector<char>& st) { for (char c : st) std::cout << c; std::cout << "\t\t"; } void printRest(const std::string& input, size_t pos) { // 用空格对齐到当前输入位置,再输出剩余串 std::cout << std::string(pos, ' ') << input.substr(pos); std::cout << "\t\t"; }逻辑说明:printStack从栈底到栈顶原样输出,不做逆序,因为分析栈的语义就是“左边栈底、右边栈顶”;printRest用pos个空格对齐,让读者一眼能看出当前正在比较哪个字符。参数上,pos是当前输入串下标,必须和主循环里的pos同步更新;input.substr(pos)在pos == input.size()时会返回空串,但我们的输入一定以#结尾,pos最多走到#的位置,不会越界。原文print1()里for(j=0;j<b;j++) cout<<" ";然后for(j=b;j<=l;j++) cout<<B[j];思路一样,只是数组边界容易写错。
3.3 主循环:匹配、查表、逆序压栈、接受与报错
主循环是整个程序的核心。用自然语言说:取栈顶符号x和当前输入符号a;如果x是终结符,比较x和a,相等就弹栈并前进,不等就报错;如果x是非终结符,查table[x][a],查到产生式就弹栈,把右部逆序压栈,空产生式不压;如果x和a同时是#,接受。下面这个版本把输出、查表、错误上报都放在循环里,出错时能看到当前栈和剩余串,不会像原文那样直接exit(1)把中间结果吞掉。
#include <iostream> #include <string> #include <vector> bool isTerminal(char c) { return std::string("i+-*/()#").find(c) != std::string::npos; } int rowOf(char c) { switch (c) { case 'E': return 0; case 'G': return 1; case 'T': return 2; case 'S': return 3; case 'F': return 4; default: throw std::runtime_error("bad nonterminal"); } } int colOf(char c) { return std::string("i+-*/()#").find(c); } bool parse(const std::string& input) { std::vector<char> st = {'#'}; st.push_back('E'); size_t pos = 0; int step = 0; std::cout << "步骤\t分析栈\t\t剩余字符\t\t所用产生式\n"; while (true) { char x = st.back(); char a = input[pos]; std::cout << step++ << "\t"; printStack(st); printRest(input, pos); if (x == '#' && a == '#') { std::cout << "acc!\n"; return true; } if (isTerminal(x)) { if (x == a) { std::cout << "匹配 " << x << "\n"; st.pop_back(); ++pos; } else { std::cout << "出错:栈顶终结符 " << x << " 与输入符 " << a << " 不匹配\n"; return false; } } else { int r = rowOf(x); int c = colOf(a); if (c < 0 || table[r][c] == nullptr) { std::cout << "出错:查表 M[" << x << "," << a << "] 为空\n"; return false; } Production* p = table[r][c]; std::cout << p->lhs << "->" << p->rhs << "\n"; st.pop_back(); if (p->rhs != "^") { for (int i = static_cast<int>(p->rhs.size()) - 1; i >= 0; --i) { st.push_back(p->rhs[i]); } } } } }逻辑说明:st初始化成{'#','E'},#在栈底,E在栈顶;每次循环先打印当前步骤,再判断接受、终结符匹配或非终结符展开。非终结符分支里,table[r][c]为空表示当前输入符不可能出现在该非终结符的 FOLLOW 集或首符集里,直接报错并保留现场。逆序压栈是重点:E->TG的右部是T后跟G,但栈顶要先处理T,所以从rhs.size()-1往0压,先压G再压T。参数上,input必须以#结尾,否则colOf('#')不会触发接受分支;rowOf只接受五个非终结符,传错会抛异常,比返回-1后越界访问安全。原文用x=A[top--]先弹栈再判断,遇到#时靠finish标记退出,逻辑能跑但错误分支不打印中间结果;这里改成先判断后弹栈,出错时栈和剩余串还在,排查方便得多。
4. 避坑与排查:LL(1) 实验代码最容易翻车的 5 个点
4.1 现象:输入 i+i# 却提示“输入串中有非法字符”
现象很常见:终端里输入i+i#,程序立刻输出“输入串中有非法字符”并退出。原因通常不是文法错,而是读入方式把换行、空格或中文符号当成了字符。原文用cin >> ch逐字符读,do { cin>>ch; if ((ch!='i')&&(ch!='+')...) { cout<<"输入串中有非法字符\n"; exit(1); } B[j]=ch; j++; } while(ch!='#');,这段在用户输入i + i#带空格时,空格会直接触发非法字符分支;如果用户从实验一复制UCON,PL过来,逗号和字母也会触发。解决方法是先整行读入,过滤空白,再逐字符检查;如果输入是 token 串,先走 3.1 的映射函数转成终结符串,不要直接塞进语法分析。
std::string raw; std::getline(std::cin, raw); std::string input; for (char ch : raw) { if (ch == ' ' || ch == '\t' || ch == '\r' || ch == '\n') continue; input.push_back(ch); } if (input.empty() || input.back() != '#') input.push_back('#');这段代码的逻辑是:getline读一整行,循环里跳过空白字符,最后确保以#结尾。参数上,raw可能为空,所以要先判断input.empty();如果输入里本来就有#,不要重复追加。注意中文全角括号(、)和全角加号+不是合法终结符,过滤空白不能解决,必须在输入前替换成半角,或者在词法映射层直接报“非法字符”并指出位置。
4.2 现象:分析表查空后程序直接退出,看不到中间结果
现象:输入一个错例,程序只打印“出错”两个字,没有分析栈、没有剩余串,也不知道是在哪一步查表失败的。原因在原文的错误分支:else { print(); print1(); cout<<"出错"<<endl; exit(1); }虽然打印了栈和剩余串,但exit(1)会立刻终止进程,如果输出被缓冲或者终端滚动,很容易只看到最后一行;更麻烦的是C[m][n].origin != 'N'这个判断,如果初始化漏了某个单元格,origin是随机值,程序可能走到未定义行为。解决方法是把错误上报封装成函数,统一打印M[非终结符, 输入符]、当前栈、剩余串和步骤号,然后return false,让主函数决定是否继续跑下一个用例。
void reportError(const std::vector<char>& st, const std::string& input, size_t pos, char x, char a, int step) { std::cout << "步骤 " << step << " 出错\n"; std::cout << "分析栈:"; for (char c : st) std::cout << c; std::cout << "\n剩余串:" << input.substr(pos) << "\n"; std::cout << "当前栈顶:" << x << ",当前输入:" << a << "\n"; if (!isTerminal(x)) { std::cout << "查表 M[" << x << "," << a << "] 为空\n"; } else { std::cout << "终结符不匹配\n"; } }逻辑说明:reportError不负责退出,只负责把现场打全;step用来定位是第几步出错,方便和合法用例的步骤表对照。参数上,st传引用避免拷贝,input和pos传值或传引用都可以,x和a是当前栈顶和输入符。注意不要在reportError里再调用exit,否则批量测试脚本跑一个错例就停了,后面用例全部跳过。
4.3 现象:产生式逆序入栈后符号顺序反了
现象:输入i+i#,分析栈里出现G T或者S F,然后下一步拿G去和i匹配,直接报错。原因是压栈顺序写反了。原文的正确写法是for(j=(cha.length-1); j>=0; j--) A[++top]=cha.array[j];,但很多人改成“从左到右压栈”for(j=0; j<cha.length; j++),以为这样栈里显示顺序更顺眼。实际上 LL(1) 分析栈的栈顶在右边,E->TG展开后要先处理T,所以T必须在栈顶,也就是最后压入。正确顺序是先压G,再压T。
// 正确:逆序压栈,rhs = "TG" 时先压 G,再压 T st.pop_back(); for (int i = static_cast<int>(p->rhs.size()) - 1; i >= 0; --i) { st.push_back(p->rhs[i]); } // 错误:正序压栈,T 在栈底,G 在栈顶,下一步会先处理 G // for (int i = 0; i < p->rhs.size(); ++i) st.push_back(p->rhs[i]);逻辑说明:st的back()是栈顶,push_back是压栈,所以右部字符串要从后往前遍历。参数上,p->rhs不能为空串,空产生式用^单独判断;如果右部长度是 1,正序逆序都一样,所以这个 bug 在E->TG、T->FS这种长度为 2 的产生式上才会暴露。排查时直接打印每一步的st,看到TG变成GT基本就能确认。
4.4 现象:空产生式 ^ 被压栈,分析栈里出现 ^ 后死循环
现象:输入i#,分析到G时用了G->^,然后分析栈里出现^,程序既不弹栈也不报错,循环几次后输出一堆重复步骤。原因是空产生式被当成普通字符压进了栈。原文用A[++top]=cha.array[j]; if(A[top]=='^') top--;先压再弹,只减一次top,如果^后面还有字符,后面的字符会把^顶到别的位置;更常见的是判断条件写成if (A[top] == '^') top--;但top已经指向新压入的^,弹完没有恢复flag,下一次循环继续读^。解决方法是压栈前判断rhs != "^",空产生式只弹左部,不压任何东西。
st.pop_back(); // 弹出非终结符 if (p->rhs != "^") { for (int i = static_cast<int>(p->rhs.size()) - 1; i >= 0; --i) { st.push_back(p->rhs[i]); } } // 空产生式:只弹不压,栈顶自然变成下一个待处理符号逻辑说明:G->^的语义是“G推导出空串”,所以G从栈里消失,后面的符号接着处理。参数上,^只是文法表示,不能作为终结符出现在输入串里;如果输入串里真的出现^,colOf('^')会返回-1,主循环会走查表为空的分支报错。排查时看分析栈里有没有^,有就说明空产生式处理写错了。
4.5 现象:词法输出 UCON,PL,UCON,MU,ID 与语法输入对不上
现象:实验一输出UCON,PL,UCON,MU,ID,直接粘进语法分析器,程序报“未知 token”或者把U当成标识符i,后面全乱。原因是词法 token 名和语法终结符没有统一:UCON和ID都该映射成i,但PL、MU、DI、LP、RP这些名字在不同实验里可能叫ADD、MUL、DIV、LPAREN、RPAREN。解决方法是固定一张映射表,词法输出直接产终结符,或者在语法分析前加一层适配。适配层要严格,遇到未知 token 不要默认成i,否则错误输入会被当成合法表达式。
| 常见词法名 | 语法终结符 | 备注 |
|---|---|---|
| UCON、ID、NUM | i | 常量和变量都归为运算对象 |
| PL、ADD、PLUS | + | 加号 |
| MI、SUB、MINUS | - | 减号 |
| MU、MUL、STAR | * | 乘号 |
| DI、DIV、SLASH | / | 除号 |
| LP、LPAREN | ( | 左括号 |
| RP、RPAREN | ) | 右括号 |
| END、EOF | # | 输入结束 |
排查时先把映射后的input打印出来,比如i+i*i#,再跑语法分析。如果打印出来是i+i*i没有#,说明末尾补#的逻辑没走;如果是i+i i#,说明 token 之间混进了空格;如果是i+i*i##,说明重复补了#。这张表看起来简单,但血泪经验是:不要一边改词法一边改语法,先把映射后的字符串稳定成i+i*i#这种形式,再调分析表。
5. 测试用例设计:RIGHT/ERROR 之外还要盯住分析栈的每一步
5.1 合法用例:从 i# 到 (i+i)*i# 的期望步数
合法用例不能只测一个i#,要覆盖加减、乘除、括号和混合优先级。下面这组用例可以直接抄进实验报告,也可以作为批量脚本的输入。期望结果列只写RIGHT不够,关键要看分析栈是否按E->TG、T->FS、F->i的顺序展开,以及G->^、S->^是否在正确的位置出现。
| 输入串 | 期望结果 | 关键检查点 |
|---|---|---|
| i# | RIGHT | 第一步E->TG,G->^,S->^ |
| i+i# | RIGHT | G->+TG后继续展开T |
| i-i# | RIGHT | G->-TG分支 |
| i+i*i# | RIGHT | S->*FS在+之后出现 |
| (i+i)*i# | RIGHT | F->(E)和S->*FS都要命中 |
| i/i-i# | RIGHT | 先/FS再-TG,优先级正确 |
以i+i#为例,期望的分析过程大致是:初始栈#E,输入i+i#;E->TG,栈变#GT;T->FS,栈变#GSF;F->i,栈变#GSi,匹配i后输入前进到+;S->^,栈变#G;G->+TG,栈变#GT+;匹配+后栈#GT;T->FS,栈#GSF;F->i,匹配第二个i;S->^,G->^,最后栈#与输入#接受。这个过程中只要有一处产生式顺序不对,比如G->+TG压成GT+,下一步就会拿+去匹配i,直接报错。把这张步骤表和程序输出对照,比只看最后RIGHT更能定位问题。
提示:合法用例至少要有一个不带括号的、一个带括号的、一个混合乘除的。只跑
i#会漏掉G和S的递归分支,分析表填错也不一定暴露。
5.2 错例:括号不匹配、连续运算符、非法结尾
错例不是随便输一个abc就算,要能区分“词法错”和“语法错”。下面这些用例都在语法层,输入串只含合法终结符,但结构不合法,程序应该输出ERROR并给出出错步骤、分析栈和剩余串。
| 输入串 | 期望结果 | 出错位置 |
|---|---|---|
| i+# | ERROR | G看到+后展开+TG,T需要i或(,遇到#查表为空 |
| (i+i# | ERROR | 缺少),F->(E)展开后E结束后栈顶)无法与#匹配 |
| i i# | ERROR | 第一个i归约后,G看到i没有产生式 |
| i*# | ERROR | S->*FS后F遇到#查表为空 |
| i)# | ERROR | G看到)会走G->^,然后#与)不匹配 |
以i+#为例,出错点不在第一个i,而在G->+TG之后T遇到#。如果程序在第一步就报错,说明分析表列索引错了,可能把#映射到了i列。以(i+i#为例,程序会先把括号内的i+i分析完,然后栈顶剩下),输入是#,终结符不匹配。这个错例能检验F->(E)的右括号是否真的被压栈。原文要求“显示分析至此所得的中间结果,如分析栈、符号栈中的信息”,所以错例输出不能只有ERROR一行,至少要有步骤号、栈、剩余串和出错说明。
5.3 用脚本批量跑用例并比对输出
手工一个个输用例太慢,而且容易漏。写一个 bash 脚本把输入喂给程序,把输出重定向到文件,再人工看关键步骤。|| true是必须的,因为程序遇到错例会返回非零,set -e会让脚本提前退出。
#!/usr/bin/env bash set -u cases=( "i#" "i+i#" "i+i*i#" "(i+i)*i#" "i+#" "(i+i#" "i*#" ) for s in "${cases[@]}"; do echo "===== input: $s =====" echo "$s" | ./parser || true echo done逻辑说明:cases数组里放输入串,for循环逐个喂给./parser;|| true保证错例返回非零时脚本继续跑下一个。参数上,./parser要提前用g++ -std=c++17 -O2 parser.cpp -o parser编译好;如果程序从标准输入读整行,echo "$s"会带上换行,主循环里的getline能正常处理。跑完后重点看每个错例的“查表为空”还是“终结符不匹配”,这两种错误对应不同的排查方向:查表为空先查分析表,终结符不匹配先查压栈顺序和括号匹配。
6. 把实验代码拆成可复用模板:分析表、词法器与错误恢复各留一个接口
这份实验文档最值得留下来的不是那两百行 C++,而是三个可以拆开的接口。第一个是分析表接口:把table[5][8]换成std::map<std::pair<char,char>, Production>,加产生式时不用再数行列,查表失败返回end(),比空指针更直观。第二个是词法器接口:不要让语法分析器直接读cin,让词法器输出std::vector<Token>,每个Token带类型和原文,语法层只认终结符,这样实验一的扫描器可以原样接进来。第三个是错误恢复接口:现在遇到错例直接return false,进阶做法是 panic mode,跳过输入直到+、-、*、/、)、#中的一个,再弹栈尝试继续,这样一次运行能报多个错误。
#include <map> #include <utility> std::map<std::pair<char, char>, Production> tableMap; void put(char lhs, char terminal, const std::string& rhs) { tableMap[{lhs, terminal}] = *findProd(lhs, rhs); } // 查表时: // auto it = tableMap.find({x, a}); // if (it == tableMap.end()) { /* 报错 */ } // Production p = it->second;这段代码的逻辑是用pair<非终结符, 终结符>做键,值存产生式副本;put封装了findProd,填表时
本文还有配套的精品资源,点击获取