简介:这份编译原理实验报告面向东北大学秦皇岛分校计算机与通信工程学院修读编译原理课程的学生,围绕PL/0语言词法分析程序的设计与实现展开,适合需要完成课程实验、理解词法分析流程或参考C++实现思路的读者。文档记录了实验目的、内容、环境与提交要求,并给出可运行的词法分析器代码,涵盖字符分类判断、读取源文件、构建单词、匹配关键词表、输出单词与类别二元组以及处理“2A”等非法单词的流程。资源包内含1个doc文件,大小约122KB,篇幅集中,便于直接查阅报告结构与代码细节。目前已有102人学习,读者可获得词法分析器的设计框架、分类判断方法、关键词匹配策略和简单错误检测思路,为后续语法分析等编译环节打下基础。
1. 先别被“编译原理”四个字吓退:它其实是教你当一个“翻译官”
第一次翻开编译原理实验报告的人,多半是这个反应:满屏的E→eBaA、ktt[48][20]、GETSYM,像另一门语言,光标在屏幕上闪了半小时,一个字没敲。其实可以把编译器想成一个翻译官——它拿到你写的 PL/0 源程序,第一件事不是理解意思,而是“认字”:哪些是标点、哪些是名字、哪些是数字。这个动作叫词法分析,是编译器流水线上的第一道工序。这份编译原理实验报告做的就是这件事:用 C++ 写一个把源程序拆成“单词 + 类别”的小程序,再写一个判断句子结构对不对的语法分析器,源码、测试文件和文法描述都在里面。零基础也能跟着跑,你只需要一台 Windows 电脑和一个 C++ 编译器。
2. 词法分析到底在做什么:把一串字符切成有意义的“块”
2.1 一个类比讲透:编译器进门第一件事是“认字”
你写下的CONST A=10;在硬盘上其实不是五个“词”,而是十三个字节:C、O、N、S、T、空格、A、=、1、0、;、换行。机器眼里没有“常量声明”这个概念,只有一串连续的字符码。词法分析器要干的活,就是把这串字符重新切成有意义的块,并且给每一块贴上标签。
这个动作你在小学就做过。语文老师让你“给下面这段话划分词语”,你会把“小明高高兴兴地背着书包去上学”切成“小明 / 高高兴兴地 / 背着 / 书包 / 去 / 上学”。切的时候你其实同时在判断词性:哪个是名词、哪个是动词、哪个是助词。词法分析器干的是同一件事,只不过它切的是程序,贴的标签是“关键字”“标识符”“无符号数”“界符”“运算符”这几类。
为什么非得先切一遍?因为后面所有环节都依赖这一步的成果。语法分析要看的是“这里应该出现一个标识符”,它不关心这个标识符叫A还是叫studentName。语义分析要查符号表,也得先知道哪个位置出现了名字。如果词法这步切错了,后面全是错的,而且错得很隐蔽——这就是为什么实验课总把词法分析放在第一个做,它是整条流水线的地基,地基歪一寸,楼歪一丈。
对零基础的人来说,这一章最值得记住的只有一句话:词法分析器的输入是字符串,输出是单词串,中间那些判断函数只是“这个字符该归哪一类”的规则集合。先把这句话记住,再看代码就不会慌。
2.2 输出为什么是“单词 + 类别”的二元组
实验要求里写得很明白:输出是由“单词”和“类别”组成的二元组序列。还拿CONST A=10;举例,理想输出长这样:
CONST|0 A|21 =|38 10|22 ;|24竖线左边是单词本身,右边是它的类别编号。为什么不能只输出单词?因为关键字和标识符在字符层面长得一模一样——while和whi1e都是字母串,if和iff也分不出来。区分的唯一依据是“它在不在关键字表里”。所以分析器必须一边切词,一边查表,把查到的编号一起吐出来。
类别编号是这份实验里最容易让人犯迷糊的地方,先把它当一张对照表记住就行:
| 编号范围 | 含义 | 举例 |
|---|---|---|
| 0 到 20 | 21 个关键字 | and、begin、const、div、do、else、end、function、if、integer、not、or、procedure、program、read、real、then、type、var、while、write |
| 21 | 标识符 | A、B、studentName |
| 22 | 无符号数 | 10、3.14 |
| 23 到 33 | 常见界符 | ,;:.()[]..++-- |
| 34 到 37 | 四则运算符 | +-*/ |
| 38 到 46 | 关系符、赋值符、括号 | =<><><=>=:={}# |
这张表不是随便排的,它和程序里那张叫ktt的二维字符数组一一对应:数组下标就是类别编号,数组内容就是那个词本身。你把ktt[0]写成"and",那么编号 0 就永远代表and。后面所有strcmp比对、所有输出拼接,都围着这张表转。
提示:初学阶段不要试图重新设计编号顺序。原实验的编号已经被后续语法分析器默认引用,你一动,整条链路全乱。想改成字符串标签,等第一版跑通再说。
2.3 第一个例子怎么跑起来:三个文件、两条命令
新手最容易卡住的地方不是写代码,而是“代码写完了,怎么让它跑起来”。这份实验的程序结构很朴素:读一个叫pl0.txt的文本文件,边读边分析,结果打印到控制台。所以你需要三样东西——一个 C++ 编译器、一份源码、一份测试用的源程序文件。
先准备测试文件。新建一个文本文档,命名为pl0.txt,内容照着下面写(这是实验里那份测试源码,其中2B和2E是故意埋进去的错误,用来验证错误检测能力):
CONST A=10; VAR B,C; 2B PROCEDURE P; VAR D; 2E PROCEDURE Q; VAR X; BEGIN READ(X); D:=X; WHILE X<0 DO CALL P; END; BEGIN WRITE(D); CALL Q; END; BEGIN CALL P; END.然后写一个最小的程序,先别管分析逻辑,只验证“文件能不能读进来”。这一步看着多余,但它能帮你把环境问题和代码问题彻底分开——很多新手后面调半天分析结果不对,最后发现是pl0.txt放错了文件夹。
// demo.cpp:只做一件事,把源文件完整读进来并统计长度 #include <iostream> #include <fstream> #include <string> using namespace std; int main() { ifstream fin("pl0.txt"); // 打开同目录下的源程序文件 if (!fin) { // 打开失败必须立刻报错,不要让它静默返回 cout << "pl0.txt 没找到,检查它是不是和 exe 在同一个文件夹" << endl; return 1; } string src, line; while (getline(fin, line)) { // 一行一行读,比逐个 fgetc 更好调试 src += line + "\n"; // 拼成完整源码,保留换行方便后面定位行号 } fin.close(); cout << "读入字符数:" << src.size() << endl; cout << "前 20 个字符:" << src.substr(0, 20) << endl; return 0; }编译和运行在命令行里两步:
g++ -o demo demo.cpp # 编译,生成可执行文件 demo.exe ./demo # Windows 下双击 demo.exe 或输入 demo.exe代码里ifstream fin("pl0.txt")用的是相对路径,意思是“和可执行文件在同一个目录”。如果你在 IDE 里点运行,工作目录可能是工程目录而不是 exe 目录,这时就会读不到文件——把pl0.txt复制到工程目录里,或者干脆用绝对路径,问题就消失了。
getline那一行是整段代码里唯一有技术含量的地方:它按行读,读到换行符停,然后把换行符丢掉。所以拼接时要手动补一个\n,否则源码里所有行会粘成一行,后面分析出错时你根本没法对照原文。这个细节在后面调试时能救命。
如果这一步屏幕上能打出“读入字符数”和一个非零数字,恭喜,环境通了。接下来第 3 章的所有代码,都是在这个骨架上加判断、加分支、加输出。
3. 动手写第一个词法分析器:从五个判断函数搭起
3.1 五个判断函数:整个程序的全部地基
打开实验源码,前三十行全是isLetter、isDigit、isP、isJ、isBlank这五个函数。新手常犯的毛病是觉得它们太简单直接跳过,结果主循环看不懂——因为主循环的每一个分支,都是在问这五个函数其中一个“是还是不是”。
// 判断是不是字母:大写 A-Z 或小写 a-z bool isLetter(char ch) { return (ch >= 'A' && ch <= 'Z') || (ch >= 'a' && ch <= 'z'); } // 判断是不是数字:0-9 bool isDigit(char ch) { return ch >= '0' && ch <= '9'; } // 判断是不是四则运算符:+ - * / bool isP(char ch) { return ch == '+' || ch == '-' || ch == '*' || ch == '/'; } // 判断是不是界符:逗号、分号、括号、等号、井号等 bool isJ(char ch) { string js = ",;.()[]=:<>{#"; return js.find(ch) != string::npos; } // 判断是不是空白字符:空格或制表符 bool isBlank(char ch) { return ch == ' ' || ch == '\t'; }这五个函数的作用,说白了就是把“字符分类”这件事从主循环里拆出去。如果不拆,主循环会变成一团if (ch >= 'A' && ch <= 'Z' || ch >= 'a' ...)的怪物,改一次错一次。拆出来之后,主循环读起来就像自然语言:是字母就走标识符分支,是数字就走常数分支。
isJ我换了一种写法,把界符全塞进一个字符串再用find查。原实验用的是ch==',' || ch==';' || ...一长串或运算,功能一样,但新增一个界符就得改一处长长的条件。用字符串的优点是一次改一处,缺点是每次调用都构造一个string,对这份几百行的实验程序来说完全无所谓,等你以后写真正的编译器再考虑性能。
注意:
isP只判断单字符的+ - * /,像<=、:=、<>这种两字符的符号不在它的管辖范围。这是原实验的设计,别急着“修复”,先按它的分类逻辑跑通,后面第 4 章会说这个设计的代价。
3.2 主循环怎么转:三个变量撑起整个扫描过程
整个词法分析器就靠三个东西在转:一个装全部源码的字符串src、一个指向当前扫描位置的整数下标pos、一个存放当前字符的变量ch。三者的关系是:ch = src[pos++],取当前字符,同时把游标往后挪一格。
主循环的形状是这样的:
string src; // 整份源程序 int pos = 0; // 当前扫描到第几个字符 char ch = src[pos++]; // 取出第一个字符,pos 自动加一 while (ch != '#') { // 遇到井号视为源码结束 if (isDigit(ch)) { // 数字分支 } else if (isLetter(ch)) { // 标识符或关键字分支 } else if (isP(ch)) { // 四则运算符分支 } else if (isJ(ch)) { // 界符分支 } else if (isBlank(ch)) { // 空白分支,直接跳过 ch = src[pos++]; continue; } else { // 认不出来的字符,报错或跳过 ch = src[pos++]; continue; } }pos++是后置自增,先取值再加一,所以ch = src[pos++]拿到的是“当前这个字符”,而pos已经指向下一个了。这个写法在整份代码里反复出现,看不懂它,后面每个分支都会晕。
关键在于:每个分支处理完之后,ch必须已经指向下一个还没处理的字符。数字分支里用while一路吃到非数字为止,退出时ch正好是那个“不是数字的字符”,continue回到循环顶部重新判断,一点不浪费。标识符分支同理。只有运算符和界符分支因为只吃一个或几个字符,需要手动再ch = src[pos++]一次。
这种“游标只前进不后退”的扫描方式叫单遍扫描,是所有词法分析器的标准姿势。它带来的好处是速度极快,代价是没法回溯——遇到<=你必须一次判断对,不能先当成<再回头改。这也是为什么第 4 章要专门讲“最长匹配”的坑。
3.3 标识符和关键字:同一段代码,两种结果
标识符分支是整份代码里信息量最大的地方,因为关键字和标识符在这里被同一个while循环吃进来,只在最后一步才分道扬镳。
else if (isLetter(ch)) { // 字母开头,进入标识符或关键字分支 int i = 0; char str[20]; // 暂存这个词,最多 19 个字符加结尾符 while (isLetter(ch) || isDigit(ch)) { // 字母开头,后面可以跟字母或数字 str[i++] = ch; ch = src[pos++]; // 游标前进,读下一个字符 } str[i] = '\0'; // C 风格字符串必须以 \0 结尾 int j; for (j = 0; j < 21; j++) { // 前 21 项是关键字,逐个比对 if (strcmp(str, ktt[j]) == 0) break; } if (j == 21) j = 21; // 不在关键字表里,归为标识符,编号 21 cout << str << "|" << j << " "; continue; // ch 已经指向下一个字符,直接回到循环顶部 }先说while的条件:isLetter(ch) || isDigit(ch)。第一个字符已经确认是字母了(外层if保证的),所以这个循环允许“字母开头,后面跟字母或数字”,比如student1、A、var2。但它不允许数字开头,所以2A根本进不了这个分支——它会先被isDigit抓走,进数字分支,然后在数字分支里读到A卡住,这就是错误检测的入口。
再说str[i] = '\0'。C++ 的char数组不做越界检查,strcmp靠什么知道字符串在哪结束?靠这个\0。漏写这一行,strcmp会一直往后读内存,读出什么全看运气,程序要么乱比要么直接崩。这是新手翻车率最高的一行,没有之一。
最后那个for循环从 0 循环到 20,正好覆盖 21 个关键字。如果中途strcmp返回 0,说明命中了关键字,j就是它的编号,直接输出。如果循环跑完j变成 21,说明一个都没命中,那它就是普通标识符,编号也正好是 21。这个“循环变量兼作类别编号”的写法很省事,但也很脆——你往关键字表里插一个新词,所有后面的编号全部平移,第 4 章会讲这个坑怎么要人命。
3.4 数字、运算符、界符:三个分支各有一个隐藏陷阱
数字分支看起来最简单,其实藏着这份代码最典型的缺陷:
if (isDigit(ch)) { // 数字开头,进入无符号数分支 int i = 0; char str[20]; while (isDigit(ch) || ch == '.') { // 允许中间出现小数点 str[i++] = ch; ch = src[pos++]; } str[i] = '\0'; cout << str << "|" << 22 << " "; // 22 是无符号数的固定编号 continue; }while (isDigit(ch) || ch == '.')这一行允许小数,但没做任何合法性检查。这意味着1.2.3会被完整吃成一个“数”,10.也会被接受,...开头当然进不来(第一个字符不是数字),但1...会被当成一个数。实验要求里只说了“有一定检查错误的能力”,所以这份代码对数字的宽容是可以理解的,但你自己写完一定要知道它在哪宽容了,不然测试用例一多你就懵。
运算符分支和界符分支的区别值得说清楚。isP管的是+ - * /四个,一次只吃一个字符,吃完手动推进游标:
else if (isP(ch)) { // 四则运算符,一次只吃一个字符 char str[2]; str[0] = ch; str[1] = '\0'; ch = src[pos++]; // 运算符只有一个字符,手动推进 for (int j = 34; j < 38; j++) { // 34 到 37 是 + - * / if (strcmp(str, ktt[j]) == 0) { cout << str << "|" << j << " "; break; } } continue; }isJ管的是界符,但它用了while (isJ(ch)),也就是“连续吃”。这个设计本意是为了处理:=、<=、>=、<>、..这种两字符符号,但副作用很大:连续两个分号;;会被当成一个词;;去查表,查不到就什么都不输出,类别信息直接丢了。这是第 4 章要重点讲的一个坑。
提示:界符分支的正确做法是“最长匹配”——先看两个字符能不能组成合法符号,不行再退回去当一个字符处理。实验代码图省事用了连续吃,你调试时看到输出少了一段,先往这里想。
3.5 错误检测:2A这种串是怎么被抓到的
实验目的里专门提了一句“有一定检查错误的能力,例如发现 2A 这类不能作为单词的字符串”。这句话看着抽象,落到代码上其实很具体。
2A进入扫描时,第一个字符2命中isDigit,进数字分支。while循环吃到2,游标前进,ch变成A。此时isDigit('A')为假,'A' == '.'也为假,循环退出。程序输出2|22,然后continue回到主循环顶部。这时ch还是A,主循环重新判断,A命中isLetter,进标识符分支,输出A|21。
结果就是:2A被拆成了2和A两个合法单词。严格说这不算“检测到错误”,而是“错误被拆解后消失了”。实验里那份测试源码故意写了2B和2E,跑出来的结果就是把它们拆成数字加标识符,语法分析阶段才会报错。
真正意义上的词法错误检测,需要你在数字分支退出后加一个判断:如果ch是字母,说明这个数字后面粘了不该粘的东西,直接报“非法单词”。这个改动只有三行,但能让你的程序从“能跑”变成“能查错”,是这份实验里性价比最高的一个加分动作。要不要加,看你们实验评分标准;加了之后测试用例的输出会变,记得回头对一遍预期结果。
4. 避坑与排查:跑这份实验代码最容易翻车的五个地方
4.1 现象:输出里的类别编号集体错位;原因:动了关键字表的顺序
现象很好认:明明输入的是VAR,输出却是8|7这种驴唇不对马嘴的编号;或者你往ktt里加了一个新关键字,之前所有能跑通的测试用例全废。原因出在那张表的结构上——ktt的下标同时承担两个职责:既决定“第几个关键字”,又决定“输出什么编号”。代码里for (j = 0; j < 21; j++)直接拿循环变量当编号用,表一动,全部平移。
解决办法分两步。第一步,想清楚你是要“加关键字”还是“改顺序”。加关键字就把新词插在 21 个关键字后面、"标识符"前面,然后把循环上界从 21 改成 22,同时把标识符编号从 21 改成 22。第二步,改完立刻跑一遍原有测试用例,对着输出逐个核对,别等到交作业前才发现。更稳妥的做法是把ktt拆成两个数组:一个存词,一个存编号,词和编号解耦,以后随便加。
4.2 现象:连续两个界符吞成一个,输出里少了一段;原因:while (isJ(ch))连续吃
输入A:=1;;,你期待看到A|21、:=|44、1|22、;|24、;|24,实际输出里最后两个分号合成一个;;,查表查不到,什么都不打印,类别 24 凭空消失。原因在界符分支的那个while (isJ(ch)):它不管三七二十一,只要下一个字符还是界符就继续吃,;;于是被拼成一个长度为 2 的字符串,去和,;:=这些合法符号比对,当然比不过。
解决办法是把“连续吃”改成“贪心匹配”:先看当前字符和下一个字符组成的双字符串能不能在表里查到,能就吃两个,不能就只吃当前这一个。具体写法是先构造str[0]=ch; str[1]=src[pos];,拿去查 38 到 46 这一段,命中就pos多推一格,没命中就退回单字符处理。改完之后;;会正确输出两个分号,:=也还是一个整体。
4.3 现象:1.2.3被当成一个完整的数;原因:只判断了isDigit和'.'
现象是输入一行A=1.2.3;,输出里出现1.2.3|22,后面语法分析直接傻眼。原因在数字分支的while (isDigit(ch) || ch == '.')——它把“有小数点”当成了数的唯一特征,既没限制小数点最多一个,也没检查小数点后面必须跟数字。
解决办法是加一个计数器。进循环前int dotCount = 0;,循环里遇到'.'就dotCount++,dotCount > 1立刻报错退出;循环结束后再判断“最后一个字符是不是'.'”,是就报错。这两条加起来不到十行,能让你的分析器拦住绝大多数畸形数字。做完这个改动你会突然理解一件事:词法分析里的“合法”不是一个模糊感觉,而是一条一条能写成if的硬规则。
4.4 现象:长标识符让程序直接崩;原因:char str[20]越界
输入里出现一个超过 19 个字符的标识符,程序有时不报错,有时直接闪退,有时输出一串乱码。原因是暂存用的char str[20]只有 20 个格子,while循环不管长度,一位一位往里写,写到第 20 个就越界,踩坏了旁边的内存。C++ 不会拦你,它只会安静地把别人的数据覆盖掉。
解决办法有两个层次。低成本的改法是把char str[20]改成char str[256],出问题的概率大幅下降,但本质上还是没防护。正确的改法是在while里加边界判断:while ((isLetter(ch) || isDigit(ch)) && i < 19),循环结束后如果ch还是字母或数字,说明这个词超长了,直接报“标识符过长”并跳过剩下部分。第二种改法多写两行,但它把“程序崩溃”变成了“程序告诉你哪里不对”,这是专业和不专业的区别。
4.5 现象:语法分析程序编译不过;原因:While首字母大写了
切换到实验四的递归子程序分析器,g++直接报'While' was not declared in this scope,代码还没开始跑就挂了。原因很朴素:C++ 区分大小写,while是关键字,While不是,编译器把它当成一个没定义过的函数名。
改法就是把While(1)改成while(1)。但改完你会遇到下一个问题:这是个死循环,程序跑起来就停不下来,只能按 Ctrl+C 强杀。正常的写法是加一个退出条件,比如读入失败就break,或者约定输入某个特定字符时结束。这份实验代码里判断句子的方式是“读完一串后看a[s]是不是#”,所以输入的时候记得带上#,同时把循环改成“读取成功才继续”,不然测试的时候会被反复强杀,很挫败。
5. 从词法到语法:递归子程序分析器怎么入门
5.1 文法怎么读:E → eBaA到底在说什么
实验四给了一个不起眼的文法:
G[E]: E → eBaA A → a | bAcB B → dEd | aC C → e | dC四条产生式,八种字符,看着像密码。其实读法很固定:左边是一个“非终结符”(可以继续展开的符号),右边的箭头读作“可以推导出”,竖线读作“或者”。第一条E → eBaA的意思是:一个E可以变成一个e、一个B、一个a、一个A依次排列。
这里有个新手必须建立的概念:终结符和非终结符。e、a、b、c、d这五个小写字母是终结符,意思是“到此为止,不能再展开了,它就是输入串里的一个字符”。E、A、B、C四个大写字母是非终结符,碰到它就得去查表,看它对应哪条产生式,继续展开。整句话的意思就是:从E开始,一路展开,最后能不能变成一个你输入的那串字符。
拿最短的串试一个:E → eBaA,其中B取aC,C取e,得e a e a A;A取a,最终得到eaeaa。所以eaeaa是这个文法的一个合法句子。这个手推过程你在纸上画一棵树会更清楚:E是根,往下长出e、B、a、A四个孩子,B再往下长,一层一层直到全是叶子。
5.2 一个非终结符对应一个函数:递归下降的固定套路
搞懂了文法,代码就好读了。递归下降的核心规则只有一条:每个非终结符写一个函数,函数体就是它那条产生式的右部,从左到右一个符号一个符号处理。
char a[100]; // 存放输入的符号串 int s = 0; // 全局游标,指向当前待匹配的字符 void E(), A(), B(), C(); void E() { // 对应 E -> e B a A if (a[s] == 'e') { // 遇到终结符 e,匹配成功就前进一格 s++; B(); // 遇到非终结符 B,递归调用 if (a[s] == 'a') { // 继续匹配终结符 a s++; A(); // 递归调用 A } } } void A() { // 对应 A -> a | bAcB if (a[s] == 'b') { // 走第一条分支的第一种可能 s++; A(); if (a[s] == 'c') { s++; B(); } } else if (a[s] == 'a') { // 第二个产生式:A -> a s++; } } void B() { // 对应 B -> dEd | aC if (a[s] == 'd') { s++; E(); if (a[s] == 'd') { s++; } } else if (a[s] == 'a') { s++; C(); } } void C() { // 对应 C -> e | dC if (a[s] == 'd') { s++; C(); } else if (a[s] == 'e') { s++; } }E()这个函数就是E → eBaA的逐字翻译:看到e就吃,遇到B就调B(),再看到a就吃,最后遇到A就调A()。s是全局游标,所有函数共享,谁匹配成功谁就把它往前推一格,这就是递归下降里“共享指针”的含义。
A()对应A → a | bAcB,所以函数里有两个分支。开头是if (a[s] == 'b'),说明“如果当前字符是b,就走第一种展开”;else if (a[s] == 'a')是第二种展开。这种“看第一个字符决定走哪条产生式”的策略,前提是同一个非终结符的几条产生式右部第一个字符互不相同——这份文法刚好满足,所以能这么写。如果两条产生式都以a开头,你就得往下多看几位,那就要引入First集,属于下一阶段的内容。
5.3 手推一个串再跑:eaeaa是怎么被接受的
把上一节的推导落成实际运行。先在纸上确认目标串是eaeaa,五个字符,一个不差。然后准备输入。原代码里main的判断是“分析结束后看a[s]是不是#”,所以要把结束符带上:
int main() { cout << "请输入符号串(以 # 结束):"; cin >> a; // 读入一行,遇到空白停止 E(); // 从开始符号 E 启动分析 if (a[s] == '#') { cout << "句子结构正确" << endl; } else { cout << "分析失败,卡在第 " << s + 1 << " 个字符" << endl; } return 0; }输入eaeaa#,程序从E()进去:吃e,调B();B()看到a,走aC分支,吃a,调C();C()看到e,吃e返回;回到B()返回;回到E(),看到a,吃a,调A();A()看到a,走第二种展开,吃a返回;回到E()返回。此时s指向#,输出“句子结构正确”。
整个过程和你手推的唯一区别是:手推时你在纸上左右横跳,程序里是函数调用栈在帮你记路径。这也是递归下降最讨喜的地方——你照着文法抄,代码自动就是对的,几乎不需要动脑设计算法。代价写在下一节。
5.4 它为什么会“静默失败”:这份代码的定位短板
输入一个不合法的串,比如eab,程序的输出是“分析失败”。这句话没错,但它没告诉你错在哪,甚至没告诉你是哪个函数放弃的。原因在于每个函数都是“看到匹配的字符才动,不匹配就直接返回”,返回之后上一层完全不检查子函数是不是真的成功了。
比如E()里调用B()之后,代码紧接着判断a[s] == 'a'。如果B()内部一个字符都没吃、s没动,这里照样往下走,错误就被吞掉,直到最后才发现a[s]不是#。这种“错误被一层层往上传递,最后只剩下一个结论”的结构,是递归下降最典型的短板,行话叫“没有错误恢复”。
改进方向有两个,看你想投入多少时间。轻量做法是给每个函数加返回值:匹配成功返回true,失败返回false,上层用if (!B()) return false;逐层往上报,报错的时候就能打印出“在 B 的 dEd 分支失败”。重量做法是维护一个错误栈,记录每次进入函数的符号和位置,报错时把栈打出来,等于给程序装了个黑匣子。前者二十行就够,做完之后你的程序会从“只会说失败”变成“能说清哪一步失败”,答辩时是明显的加分项。
6. 下一步学什么:从这两份实验代码往哪走
两份实验跑通之后,很多人的状态是“会跑,但不知道接下来该学啥”。我的建议是别急着去啃大部头的编译原理教材,先把手里这两份代码往深里挖一层,挖出来的东西比看三章书都扎实。
第一层是给词法分析器加“状态机”。你现在写的是五个if判断加一堆while,能跑,但改一处要动三处。试着把它重写成一个状态转移表:行是状态(起始、标识符中、数字中、运算符中),列是输入字符类别(字母、数字、界符、空白),表格里填“下一个状态 + 该输出什么单词”。这张表填完,你会发现原本两百行的if-else能压到五十行以内,而且新增一种符号只是加一行。这个练习做一次,以后看任何词法分析器源码都能秒懂。
第二层是给语法分析器补First集和Follow集。你现在靠“看第一个字符”手写分支,能写对是因为文法简单。换一个非终结符有七八条产生式的文法,手写就要命了。学一下怎么从文法自动算出每个非终结符的First集,你就能写出“半自动”的分析器,往上再走一步就是LL(1)分析表。
再往后的路,按这个顺序走比较顺:
| 阶段 | 学什么 | 和这两份实验的关系 |
|---|---|---|
| 1 | 有限状态自动机 | 把词法分析器的if改写成状态表 |
| 2 | 上下文无关文法、推导树 | 把G[E]那四条产生式画出完整语法树 |
| 3 | LL(1)分析与First/Follow集 | 让语法分析器不再靠手写分支 |
| 4 | 算符优先、LR分析 | 处理左递归文法,递归下降搞不定的场合 |
| 5 | 语义分析、符号表、中间代码 | 让程序从“判断对错”变成“能算出结果” |
学习路径里最容易被跳过、但收益最高的一步,是把实验里那个“二元组输出”改成一个真正的Token结构体。现在的输出是A|21,一堆字符串拼来拼去,后面想加行号、列号、原始文本,就得改十几处cout。改成struct Token { string text; int type; int line; int col; };,所有输出走一个函数,你会立刻体会到“数据结构定好了,代码自己就顺了”这句话是什么意思。
最后说一个我自己的教训。第一次做这份实验的时候,我图快,直接把参考代码复制进来,改了两行路径就跑,跑通了,交上去,成绩还行。结果期末换了个文法考,我盯着屏幕两个小时没写出一个函数——因为那些while循环和游标推进我从来没自己写过一遍,脑子里只有“大概是这样”。从那以后我每次做实验,都强制走一遍这个流程:先关掉参考代码,用白纸手写一遍文法推导,把目标串推出来,再照着推导写函数,最后才打开参考代码对答案。
这个习惯看起来慢,实际快得多。手推一遍,你脑子里就有了“程序在干什么”的画面;照着画面写代码,报错的时候你知道该看哪个函数;对着参考代码改,你知道自己哪里想岔了。希望帮到你。
本文还有配套的精品资源,点击获取