ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

PL/0词法分析与LL(1)递归子程序语法分析器实现

PL/0词法分析与LL(1)递归子程序语法分析器实现

简介:这份东北大学秦皇岛分校编译原理课程实验报告,面向计算机相关专业学生及需要理解编译器前端实现的学习者,围绕 PL/0 语言词法分析程序的设计与实现展开,可用于课程实验复现、代码研读和词法分析原理梳理。压缩包内共 1 个 doc 文件,大小约 122KB,内容以实验报告正文和 C++ 源码为主,涵盖实验目的、环境配置、PL/0 词法分析流程、关键词表匹配、单词与类别二元组输出及 2A 等非法单词检测思路。报告还给出 isLetter、isDigit、isP、isJ、isBlank 等字符判断函数与 main 主程序实现,便于读者对照理解文件读取、逐字符扫描、标识符与常数识别、错误检查等环节。目前已有 98 人学习下载,适合用作编译原理实验参考和词法分析器实现练手材料。

1. 从一段 PL/0 源程序说起:两份实验报告真正要交的东西

把一段 PL/0 源程序输入程序,输出一串由(单词、类别码)组成的二元组,遇到2A这种数字后面跟字母的拼接串要能报错——这是编译原理实验报告里词法分析那一半的全部需求;另一半是把文法 G[E] 的四个产生式原样翻译成四个互相调用的函数,判断任意输入串是不是该文法的句子。两份实验看着独立,其实是同一条流水线上前后相接的两道工序:前一道把字符流压成记号流,后一道在记号流上做推导。这套方案适合正在写编译原理实验报告、课程设计的人,也适合想手写一遍 PL/0 词法分析器和递归子程序语法分析器、把 LL(1) 那套纸面判断落进代码里的人。下面按需求、设计、实现、验收四段走完,代码骨架可以直接改写复用。

2. 总体设计:两道工序的职责边界与 47 个类别码的分段

2.1 谁负责切分,谁负责判合法性

词法分析只回答一件事:这一段字符构成什么记号,它不关心这些记号排在一起合不合法。2A在词法层就该报错,因为2起头的无符号数后面不允许紧跟字母;但BEGIN END在词法层完全合法,那是语法层该拒绝的东西。很多第一版代码把这两件事混在一起,在扫描函数里塞if判断语句结构长什么样,结果词法部分讲不清、语法部分又依赖字符下标,改一个地方两处崩。

我一般会在两者之间放一个明确的接口:词法输出vector<Token>,Token里带text、code、line三个字段,语法分析只认code,连原始字符都不再看。这么切有三个直接好处——两个实验可以拿同一份记号流做联调;语法分析的输入长度不再受固定字符数组限制;出错时能直接报出行号,而不是只打印一句"分析失败"让人猜。原始提交里语法分析用scanf("%s", a)读进一个char a[10],长度一超就是内存踩踏,这类问题在接口切成记号流之后自然消失。

还有一层边界要说清楚:实验四的文法字母表是a b c d e五个单字符终结符,和实验二的 PL/0 关键字表根本对不上。硬把两套塞进同一张表,查表范围就会打架。稳妥的做法是给实验四单独建一张五项小表,词法骨架复用,表换掉,两个实验各跑各的,互不污染。

2.2 类别码表的分段设计:21 + 2 + 24

类别码的排法决定了后面所有查表代码长什么样。原料里给的那张表总共 47 项,顺序是:21 个关键字、标识符、无符号数、24 个界符与运算符。这个顺序本身就是编码,索引即类别码,所以顺序一旦定下就不能随便调整,加一个关键字只能往段尾追加,不能插在中间。

区段类别码范围内容数量
关键字0 – 20and begin const div do else end function if integer not or procedure program read real then type var while write21
标识符21用户自定义名字1
无符号数22整数与小数1
界符与运算符23 – 46,;:.()[]..++--+-*/=<><><=>=:={}#24

这张表最容易被写坏的地方,是把"标识符""无符号数"这两个中文说明也塞进字符串数组,然后靠for(j=0;j<21;j++)这种数格子的方式跳过它们。加一个关键字,就要同步改三处循环上界,漏一处就是越界或者错位,而且编译期一声不响。改成命名常量分段之后,表长交给编译器算,循环边界只写常量名:

// 类别码分段:名字有意义,边界不再靠数格子 namespace sym { constexpr int KW_BEGIN = 0; // 关键字区起点 and constexpr int KW_END = 20; // 关键字区终点 write constexpr int IDENT = 21; // 标识符 constexpr int NUMBER = 22; // 无符号数 constexpr int DELIM_BEGIN = 23; // 界符区起点 "," constexpr int DELIM_END = 46; // 界符区终点 "#" } // 表的下标就是类别码,顺序不可随意调整,新增项一律追加到段尾 static const char* kSymbolTable[] = { "and","begin","const","div","do","else","end","function","if","integer", "not","or","procedure","program","read","real","then","type","var","while", "write", "标识符","无符号数", ",",";",":",".","(",")","[","]","..","++","--","+","-","*","/", "=","<",">","<>","<=",">=",":=","{","}","#" }; static const int kSymbolCount = sizeof(kSymbolTable) / sizeof(kSymbolTable[0]);

kSymbolCount这个写法值得保留,它让"表里到底有几项"变成编译期事实。后面所有遍历都写i < kSymbolCount,再不会出现表有 47 项、循环写到 46 的错位。查表函数只做区间内的线性比对,命中返回下标,未命中返回 -1,一个函数同时服务关键字、运算符、界符三种查询。

提示:kSymbolTable里那两格中文只是给人看的占位符,永远不要拿去和输入串做strcmp。标识符和无符号数走的是常量sym::IDENT、sym::NUMBER,根本不进查表路径。

还有一个隐蔽的坑:PL/0 的源程序习惯全大写(CONST、VAR、BEGIN),而表里的关键字是小写。直接比对的结果是所有关键字都被识别成标识符,输出看起来"没报错",其实全错。我的做法是查表前先把待查单词统一转成小写副本再比,输出的text保留原文不变。这样CONST和const都能命中类别码 2,结果打印出来还是源程序里的原样大小写。

2.3 工程文件与运行方式

整个方案落在三个文件上:pl0.txt存待分析的源程序,lexer.cpp是词法与记号输出,parser.cpp是四个递归子程序。两个实验分开编译,谁出问题就单独调谁,别为了"看起来完整"硬合成一个巨型main。

# 编译词法分析器,C++17 足够,不需要额外库 g++ -std=c++17 -O2 -o lexer lexer.cpp ./lexer pl0.txt

在 Windows 上跑要注意一件事:fopen("pl0.txt","r")里的相对路径是相对当前工作目录,不是相对 exe 所在目录。从 IDE 里点运行能读到,双击 exe 就读不到,fp为NULL之后fgetc直接崩溃。这是血泪经验里最典型的一个——凡是从文件读输入,fp判空必须写在第一行:

FILE* fp = fopen("pl0.txt", "r"); if (!fp) { // 读不到就明确报出来,不要让它崩在后面 fprintf(stderr, "打不开 pl0.txt,检查工作目录\n"); return 1; }

文件读完之后统一在末尾补一个#当结束哨兵。哨兵的好处是扫描循环的终止条件变成看字符而不是看EOF,语法分析那边的输入串也能用同一套约定,两处逻辑对齐,少一类边界 bug。

3. 词法分析器实现:字符分类、最长匹配与二元组输出

3.1 五个字符判定函数的职责与边界

判定函数只回答"是不是",绝不推进任何状态。这是扫描器最基本的一条纪律,破坏了它,代码很快就会变成"读到哪儿算哪儿"的黑匣子。原料里的isLetter、isDigit、isP、isJ、isBlank五个函数思路是对的,但isP和isJ的划分方式会让后面很难受:isP只认+ - * /四个,isJ把剩下的界符和两字符运算符全包了,于是"两字符优先匹配"这件事在函数层面就无处安放。

我更习惯按用途拆成下面这一组,边界清楚,后面加符号不用动结构:

// 字符分类:只回答"是不是",不碰下标 inline bool isLetter(char c) { return (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z'); } inline bool isDigit(char c) { return c >= '0' && c <= '9'; } inline bool isIdentStart(char c) { return isLetter(c) || c == '_'; } inline bool isIdentPart(char c) { return isLetter(c) || isDigit(c) || c == '_'; } inline bool isBlank(char c) { return c == ' ' || c == '\t' || c == '\r'; }

isIdentStart和isIdentPart分开的意义在于:标识符首字符不能是数字,这条规则如果用isLetter || isDigit混在一个函数里,就只能靠在循环外面再补一个判断,容易漏。分开之后"首字符"和"后续字符"各判各的,规则和文法描述一一对应,写报告时也容易讲。

isBlank里把\r加进去是个小改动,作用不小。Windows 下的文本文件行尾是\r\n,如果只把 和\t当空白,\r会掉进"非法字符"分支,输出里冒出一堆莫名其妙的报错。这类问题不涉及任何算法,纯粹是平台差异,但足够让人在调试时怀疑人生。

3.2 主扫描循环:从字符串到 (单词, 类别) 二元组

主循环的结构是三段式:跳空白、按首字符分流、把一段连续字符收成一个记号。分流只有四条路——数字开头走无符号数、标识符首字符走标识符或关键字、其余走运算符与界符、都不匹配就是非法字符。关键在于每条分支结束都continue回到循环顶,绝不在分支内部再往下读第二个记号。

struct Token { std::string text; int code; int line; }; struct LexError { int line; std::string text; std::string msg; }; // 在表的指定区间内查类别码,未命中返回 -1 int lookup(const std::string& w, int from, int to) { for (int i = from; i <= to; ++i) if (w == kSymbolTable[i]) return i; return -1; } std::vector<Token> scan(const std::string& src, std::vector<LexError>& errs) { std::vector<Token> out; size_t i = 0, n = src.size(); // src 末尾已补哨兵 '#' int line = 1; while (i < n && src[i] != '#') { char ch = src[i]; if (ch == '\n') { ++line; ++i; continue; } if (isBlank(ch)) { ++i; continue; } if (isDigit(ch)) { // 无符号数(含小数) size_t s = i; bool dot = false; while (i < n && (isDigit(src[i]) || (src[i] == '.' && !dot))) { if (src[i] == '.') dot = true; ++i; } // 收完数字立刻看一眼后继:2A、12x 这类拼接串在这里判死 if (i < n && isIdentStart(src[i])) { size_t s2 = i; while (i < n && isIdentPart(src[i])) ++i; errs.push_back({line, src.substr(s, i - s), "数字后紧跟字母,不是合法单词"}); continue; } out.push_back({src.substr(s, i - s), sym::NUMBER, line}); continue; } if (isIdentStart(ch)) { // 标识符或关键字 size_t s = i; while (i < n && isIdentPart(src[i])) ++i; std::string raw = src.substr(s, i - s); std::string low = raw; for (char& c : low) c = (char)tolower((unsigned char)c); int code = lookup(low, sym::KW_BEGIN, sym::KW_END); out.push_back({raw, code < 0 ? sym::IDENT : code, line}); continue; } // ………运算符与界符分支见 3.3……… } return out; }

数字分支里那个"收完之后回头看一眼前继"的动作,是整个词法分析器里最关键的一段检查。如果只是把连续数字收成一串就continue,2A会被切成2和A两个合法记号,程序照常往下跑,一个字都不报——这正是答辩时最容易被追问翻车的地方。判断时机必须放在"已经收完一段"之后,因为要判的是"数字串后面紧跟着什么",中间隔一个空白就不再是非法串,10 A是两个记号,2A是一个错误。

标识符分支里的low就是 2.2 提到的大小写归一。注意转小写只发生在查表用的副本上,Token::text存的是raw,输出保持源程序原样。这个细节看着小,但报告里的结果截图如果全是小写,和源程序对不上,验收时又要解释半天。

3.3 运算符与界符:双字符优先,最左最长匹配

24 个界符里有 7 个是两字符的:..++--<><=>=:=。它们和单字符符号存在前缀关系,:是:=的前缀,.是..的前缀,<是<>和<=的前缀。所以匹配顺序只能是先试两字符、再退一字符,反过来就会把:=拆成:和=,把<>拆成<和>。

// 两字符运算符必须先于单字符尝试,否则前缀关系会导致误拆 static const char* kTwoCharOp[] = { "..","++","--","<>","<=",">=",":=" }; int matchOp(const std::string& src, size_t& i) { if (i + 1 < src.size()) { std::string two = src.substr(i, 2); for (const char* op : kTwoCharOp) { if (two == op) { i += 2; // 命中两字符,游标一次走两格 return lookup(two, sym::DELIM_BEGIN, sym::DELIM_END); } } } int code = lookup(std::string(1, src[i]), sym::DELIM_BEGIN, sym::DELIM_END); if (code >= 0) ++i; // 单字符命中走一格 return code; // -1 交给上层报非法字符 }

matchOp把"试两字符—退一字符"这个顺序封装进函数,主循环里只剩一句int code = matchOp(src, i);,干净且不容易写错。返回值 -1 表示既不是两字符也不是单字符,主循环拿到 -1 就构造一条非法字符错误、游标前进一格继续扫。游标一定要前进,否则遇到非法字符就死循环——这是扫描器里第二种典型翻车方式。

原料代码里界符分支写的是while(isJ(ch)),只要还是界符就继续收。这条循环的后果是连续的界符被一次性吞成一个长串:);会被收成);,然后拿整串去表里比对,全不命中。表面上看只是输出少了几项,实际上是扫描粒度错了,后面的语法分析拿到的东西完全对不上。这类问题不要靠"再加个 if"去补,正确做法就是回到"一次只认一个记号"的模型,最长匹配的长度上限由表决定,而不是由循环决定。

3.4 错误检测与输出格式

错误不应该中断扫描。一份源程序里可能同时有2B和2E两处拼接错误,只报第一个,剩下那处要再跑一遍才能发现,来回折腾。所以我把错误收进一个vector<LexError>,扫描跑完统一打印,一次把问题全列出来。

输出格式建议两段式:先逐行打印二元组,再单独打印错误清单。二元组直接写成单词|类别码,一行一个记号,行号按源程序换行递增,方便对照。

CONST|2 A|21 =|38 10|22 ;|24 VAR|18 B|21 ,|23 C|21 ;|24 2B 数字后紧跟字母,不是合法单词(第 3 行)

拿原料里那份测试源程序跑一遍,PROCEDURE、BEGIN、WHILE、CALL这些都会正常落到关键字区,2B和2E会在错误清单里各占一行。这里要留意CALL:它不在 21 个关键字表里,会被识别成标识符CALL|21。这不是程序错了,是 PL/0 的语法描述里本来就没有CALL这个关键字,报告里写清楚这一点,比硬把它塞进关键字表要负责得多。

注意:错误清单里的字符串要带上原始拼接内容(2B而不是2),否则读者无法判断是"数字后接字母"还是别的错误类型。

4. 递归子程序分析器:把文法 G[E] 的四个产生式翻译成四个函数

4.1 先判 LL(1):FIRST 集不交叉就不用回溯

动手写函数之前先把文法过一遍。G[E] 的四条产生式是E → eBaA、A → a | bAcB、B → dEd | aC、C → e | dC。要判两件事:有没有左递归,同一非终结符的多个候选 FIRST 集有没有交集。

非终结符产生式FIRST 集结论
EeBaA{ e }单候选,直接匹配
Aa/bAcB{ a } / { b }不相交,看首符分支
BdEd/aC{ d } / { a }不相交,看首符分支
Ce/dC{ e } / { d }不相交,看首符分支

四条产生式都没有直接或间接左递归(没有哪个非终结符能推出以自己开头的串),三组候选的 FIRST 集两两不交,最左推导的每一步都能靠当前符号唯一确定用哪条产生式。也就是说这是一份 LL(1) 文法,不需要消除左递归、不需要提取公共左因子、不需要回溯机制。这一点写进报告比贴代码更有说服力——它解释了"为什么可以一个候选配一个 if 分支",而不是"老师给的模板就这么写"。

反过来说,如果哪个候选的 FIRST 集撞上了(比如A → a和A → ab),那就得先做左因子提取再写函数,否则代码里必然出现"试一下不行再退回来"的回溯逻辑,游标一退错位置,整个分析就废了。判断顺序永远是:先去左递归、再提左因子、最后才是写递归子程序。

4.2 E / A / B / C 四个函数的骨架与全局游标

递归子程序的写法几乎是文法描述的直译:每个非终结符对应一个函数,产生式右部的终结符用match吃掉,非终结符用函数调用展开。全局状态只需要两样——记号数组和一个指向当前待匹配位置的游标。

static std::vector<Token> toks; // 由词法部分产出的记号流,末尾带哨兵 static size_t pos = 0; // 当前待匹配位置 static bool ok = true; // 一旦置 false 就不再往下推进 static std::string why; // 第一条错误信息,保留现场 const Token& cur() { return toks[pos]; } void match(int code, const char* what) { if (ok && pos < toks.size() && toks[pos].code == code) { ++pos; return; } if (ok) { ok = false; why = "第" + std::to_string(cur().line) + "行期望 " + what; } } void fail(const char* expect) { if (ok) { ok = false; why = "第" + std::to_string(cur().line) + "行期望 " + expect; } }

match里那个if (ok)是关键。出错之后所有后续的match立刻变成空操作,游标停在出错位置不动,第一个错误信息也不会被后面的连锁错误覆盖。没有这个短路,一个语法错误会引发十几个"期望某某"的报错,真正原因被埋在中间,读的人只能干瞪眼。

四个函数本体按产生式逐条对:

void E() { // E -> e B a A if (!ok) return; match(T_E, "e"); B(); match(T_A, "a"); A(); } void A() { // A -> a | b A c B if (!ok) return; if (cur().code == T_A) { match(T_A, "a"); return; } if (cur().code == T_B) { match(T_B, "b"); A(); match(T_C, "c"); B(); return; } fail("a 或 b"); } void B() { // B -> d E d | a C if (!ok) return; if (cur().code == T_D) { match(T_D, "d"); E(); match(T_D, "d"); return; } if (cur().code == T_A) { match(T_A, "a"); C(); return; } fail("a 或 d"); } void C() { // C -> e | d C if (!ok) return; if (cur().code == T_E) { match(T_E, "e"); return; } if (cur().code == T_D) { match(T_D, "d"); C(); return; } fail("d 或 e"); }

对照看A和B的顺序差异,这是最容易被写反的地方。A → bAcB展开是b、A、c、B四个符号,收c的位置在两次递归之间;B → dEd展开是d、E、d,两个d分居递归两侧。原料里那一版把E写成"吃e、调B、吃a、调A",但吃完e之后如果当前不是a就直接return了,没有任何错误标记——只有末尾检查a[s]=='#'这一道防线在兜着。这种"靠终点判对错"的写法在正例上跑得通,一旦输入是eb...这种前半截对、后半截缺的串,报出来的永远是笼统的"分析失败",说不出缺在哪个符号。

4.3 从字符流升级到 token 流

原版的语法分析直接在字符数组上跑:scanf("%s", a)读到char a[10],然后在a[s]上比'e'、'a'。这条路能跑通正例,但有三处硬伤:缓冲区定长,长串直接溢出;字符和记号混为一个概念,e既是终结符又是可能出现在标识符里的字符;结束位置靠检查a[s]=='#',而#又必须由用户在输入里手动敲进去,稍微漏一个就永远等不到结束。

切成记号流之后这三处一起消失。输入串先过一遍 3.2 的scan,把每个字符转成Token,末尾补一个END哨兵(类别码取 99,不与任何真实类别冲突),游标越界时cur()返回的就是这个哨兵,match自然失败并报"期望 X",不会越界读内存。

// 把输入串转成记号流:实验四只认 a~e 五个终结符 enum { T_A = 0, T_B, T_C, T_D, T_E, T_END = 99 }; static int termCode(char c) { return (c >= 'a' && c <= 'e') ? c - 'a' : -1; // 不在字母表里就返回 -1 } bool analyze(const std::string& input, std::string& msg) { toks.clear(); pos = 0; ok = true; why.clear(); for (size_t i = 0; i < input.size(); ++i) { int c = termCode(input[i]); if (c < 0 && input[i] != '#') { // 字母表外的字符当场拒绝 msg = "第" + std::to_string(i + 1) + "个字符不在字母表内"; return false; } if (input[i] == '#') break; // 手动敲的 '#' 直接截断 toks.push_back({std::string(1, input[i]), c, 1}); } toks.push_back({"#", T_END, 1}); // 哨兵,保证 cur() 永不越界 E(); if (!ok) { msg = why; return false; } if (pos + 1 != toks.size()) { msg = "输入串未被完全识别,剩余部分从第 " + std::to_string(pos + 1) + " 个记号开始"; return false; } return true; }

最后那句pos + 1 != toks.size()是整个分析器判"对"的唯一出口。E()只是把能匹配的部分推完,剩下没吃掉的记号说明输入长了——比如eaeaaa,E推完eaeaa就返回了,末尾那个a还在缓冲区里,只有靠这句才能判出来。少了它,所有"合法前缀 + 垃圾后缀"的输入都会被判成正确,这是递归下降实现里最常见的漏判。

analyze返回布尔值加一条消息,主程序套一层while反复读输入即可,退出条件交给用户输入空串或者quit,不要再写while(1)那种一口气跑到底的结构。原料里的主函数写成While(1)(大写 W)编译不过,改的时候顺手把退出条件补上,别只改大小写就交。

4.4 测试用例设计:正例、边界例与必错例

只跑通一个正例就交报告,验收时随便换一个串就原形毕露。我一般会准备三组用例,每组都有明确的考察点,写进报告里也显得思路清楚。

编号输入串期望结果考察点
1eaeaa#正确最短合法串:E→eBaA,B→aC,C→e,A→a
2eadeaa#正确C 走dC再走e,验证 C 的递归分支
3edeaeadaa#正确B 走dEd,嵌套一层 E,验证跨层递归
4e#失败B 缺失,报"期望 a 或 d"
5eaeaaa#失败前缀合法、后缀多余,验证完全识别检查
6abc#失败首符就错,验证 E 的首终结符匹配
7eaeaX#失败字母表外字符,验证输入过滤

第 3 条那个串是手推出来的:B → dEd,其中内层E → eBaA取B → aC、C → e、A → a,得到eaeaa,套进B得deaeaad,再套最外层E → eBaA(A取a)得e+deaeaad+a+a,一共 10 个字符。手推一遍再对着程序跑,比盲目试串快得多,也是报告里"测试设计"那一节最实在的素材。

5. 避坑与排查:五个让实验报告返工的错误

5.1 词法侧:类别码与匹配顺序相关的问题

现象一:源程序里明明写的是CONST,输出却是CONST|21,所有关键字都变成了标识符。原因在于表里存的是小写,输入是大写,strcmp一次都没命中,全部落进"未命中就是标识符"的兜底分支。这种情况最坑的地方是程序不报错、不崩溃,输出看起来还挺整齐,只有对着类别码表一个个核才发现全错。解决办法就是在查表前把单词转成统一大小写再比,原始文本单独保留用于输出,两件事分开做。

现象二:2A输进去,程序输出2|22 A|21,一句错误都没报。原因在数字分支的收尾动作——收完连续数字就直接continue,根本没有回头看后继字符。这一类错误的本质是把"扫描"和"校验"当成了两件分开的事,而词法层的错误恰恰只能在扫描的瞬间判。解决办法是把检查点放在数字串收完之后、构造记号之前:如果下一个字符是字母或下划线,就把整段2A当作一个非法单词报出去,游标跳过整段,不产生任何记号。

现象三:);这种连续界符被合并成一项,后面所有类别码整体错位。原因是用while(isJ(ch))之类的循环去吞界符,把"一个记号"和"一段连续同类字符"混为一谈了。数字和标识符确实该连续收,因为它们本来就是多字符记号;界符和运算符每一项都是定长的(一字符或两字符),收多了就是错。解决办法是删掉这类循环,改成一次只匹配一项,两字符优先、单字符兜底,匹配完游标推进固定格数。

5.2 语法侧:缓冲区、退出条件与错误定位

现象四:输入稍长一点程序就崩,或者跑出乱码结果。根子在定长字符数组。char a[10]配scanf("%s", a),输入 12 个字符就直接踩到栈上别的变量,表现可能是崩溃,也可能是"结果莫名其妙但程序没死",后者更难查。这类问题没有后悔药可吃,只能从根上换掉:改用std::string承接输入,用vector<Token>承接记号流,长度由容器自己管,同时给记号流末尾补哨兵,让越界读变成一次可控的匹配失败。

现象五:报错只报"分析失败",说不清错在哪。原因是递归函数里没有任何错误记录,全靠末尾检查结束符位置来反推,位置信息早就丢了。解决办法是把"当前游标位置"作为唯一的现场信息保留下来:match失败时立刻记下toks[pos].line和期望的记号名,置ok = false,之后所有递归调用入口第一句都是if (!ok) return;,把错误冻结在第一个出错点。改完之后报错会变成"第 1 个记号处期望 a 或 d",答辩时直接把这条打印出来,比任何口头解释都管用。

注意:短路标记ok一定要在每个递归函数的入口检查,漏一个函数,错误之后它还会继续往下推游标,第一个错误位置就被覆盖了。

6. 验收与答辩:十分钟里把两份实验讲清楚的顺序

答辩不是把代码从头念一遍,评委看的顺序和你写代码的顺序不一样。他们先看需求立没立住,再看设计有没有依据,最后才看代码跑不跑。所以讲述顺序我建议按"输出格式 → 类别码表 → 最短匹配 → 文法判断 → 测试用例"走,每一步都拿屏幕上能看见的东西说话。

第一步,直接把一段源程序的输出贴出来,指着CONST|2说清二元组里两个分量各是什么,再指一行错误清单说2B为什么非法。这一步两分钟,作用是让评委知道你的程序输入端、输出端都通了,后面才愿意听细节。第二步,翻到类别码表那一页,说明 47 项为什么分成 21 + 2 + 24 三段,边界用常量而不是数字硬编码。这一步是在答"你的表为什么这么排",很多人被问到这里答不上来,只能说"老师给的"。第三步,讲那个"数字收完看一眼后继"的检查点,这是词法部分唯一有设计感的地方,值得单独讲三十秒。

语法部分的开场不要贴代码,先讲 FIRST 集那张表。把四条产生式、三组候选、两两不交这个结论摆出来,然后一句话收口:"所以不需要消除左递归、不需要回溯,一个候选一个 if 分支。"评委听到这句就知道你是先分析再动手,而不是照着模板抄。之后再展示A和B两个函数,重点讲bAcB和dEd里终结符与递归调用的相对位置为什么不同——这个对比最能体现你是"按产生式直译"而不是"凭感觉写"。

常见追问和对应的回答要点,提前准备好:

追问回答要点
你的词法分析器能处理浮点数吗数字分支里用dot标记只允许一个小数点,第二个点会被切走当作..或报错
为什么CALL是标识符不是关键字PL/0 的 21 个关键字里没有CALL,它

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

返回列表