编译原理实验二 《预测分析法设计与实现》
一、实验目的
加深对语法分析器工作过程的理解;加强对预测分析法实现语法分析程序的掌握;能够采用一种编程语言实现简单的语法分析程序;能够使用自己编写的分析程序对简单的程序段进行语法翻译。
二、实验内容
用预测分析法编制语法分析程序,语法分析程序的实现可以采用任何一种编程语言和工具。
三、实验方法
1、基于C++ 11标准。
2、开发工具为JetBrains CLion,编译环境1为GCC 8.3.0及以上。Windows下MinGW编译套件下载地址:GCC and MinGW-w64 for Windows
四、实验步骤
1. 定义规则文件的组成规律,方便读取形成语法规则的文法。
规则文件的组成规律:在规则文件第一行、第二行分别编写非终结符Vn、终结符Vt(中间没有间隔符);下面的每一行编写一个产生式的左部和右部,使用空格隔开;终结符中未出现的符号代表空字;并且规则文件中的文法都是已经符合LL(1)文法规则的文法,举例如下。
规则文件rules.txt:
EATBF
+*()i
E TA
A +TA
A $
T FB
B *FB
B $
F (E)
F i
此处用$
号代表空字。
2. 数据结构设计
数据结构仍然使用结构体。
- 文法与产生式规则
// a rule is a Vn(char) -> formular(string)
typedef std::pair<char, std::string> RULE;
typedef struct {
std::unordered_set<char> vns; // non-terminal
std::unordered_set<char> vts; // terminal
std::vector<pred::RULE> rules; // production rules
char epsilon; // ε means empty word
char start; // start symbol (non-terminal)
} syntax_rule;
文法及其产生式规则的一个例子:
pred::syntax_rule my_syntax = {
{'F', 'B', 'T', 'A', 'E'},
{'+', '*', '(', ')', 'i'},
{
{'E', "TA"},
{'A', "+TA"},
{'A', "$"},
{'T', "FB"},
{'B', "*FB"},
{'B', "$"},
{'F', "(E)"},
{'F', "i"},
},
'$',
'E'
};
- 相关集合
typedef struct {
std::unordered_set<char> first_set;
std::unordered_set<char> follow_set;
bool first_has_empty;
} symbol_set;
// for each symbol we have s struct to store 2 kinds of sets
typedef std::unordered_map<char, symbol_set> symbol_sets;
3. 第一步,设计获取各个非终结符的 F I R S T FIRST FIRST 集合、 F O L L O W FOLLOW FOLLOW 集合的函数
最后求出的 F I R S T FIRST FIRST 集和 F O L L O W FOLLOW FOLLOW 集如下,表示算法实现正确。
由于函数具体实现过长,此处不再展示代码,仅简要对实现的算法进行描述。
- FIRST: 简单来说就是,某个串(非终结符)的FIRST集就是这个串能推导出的式子的第一个终结符的集合(该终结符必须在最左边)。
要求A的FIRST集,首先找到所有左部为A的产生式,然后遍历这些产生式的右部:
- 如果右部第一个符号是终结符α,那么把α加入到A的FIRST集中;
- 如果右部是ε,把ε加入到A的FIRST集中;
- 如果右部第一个符号是非终结符α,将该非终结符α的FIRST集加入A的FIRST集中,但需要注意的是,如果α的FIRST集存在ε,向FIRST(A)中添加FIRST(α)的时候就要去掉ε,然后加上下一个符号β的FIRST集,如果FIRST(β)还有ε就继续向后推演,以此类推。
不难发现,我们如果把任意终结符α的FIRST集合定义为{α}、空字ε的的FIRST集合定义为{ε}话,那么上面三步都可以在一个循环体当中搞定。具体说法见下面的伪代码。
1. for A -> ε there is: ε ∈ FIRST(A)
2. for A -> αβ :
if α is terminal symbol
then α ∈ FIRST(A)
if α is non-terminal
then FIRST(α) \ {ε} ⊂ FIRST(A)
In other words,
for A -> α1 α2 α3 ... αn:
loop i from 1 to n:
// FIRST(αi) \ {ε} ⊂ FIRST(A)
FIRST(A) ← FIRST(A) ∪ FIRST(αi) \ {ε}
if ε ∈ FIRST(αi) then i += 1, continue.
else break.
if i >= n then ε ∈ FIRST(A) because the whole right can be ε
- FOLLOW: 引入FOLLOW集这个概念,来记录某个非终结符之后可能出现的第一个终结符。求FOLLOW集的时候主要关注产生式右部的符号。
要求A的FOLLOW集,找到所有产生式右部为存在A的产生式,然后找A后面的第一个符号:
- 文法开始符号的FOLLOW集包含#(#是终止符,代表一个句子的末尾,可以看做EOF,在有的材料中是$)
- 如果A后第一个符号是终结符α,那么把这个终结符α加入到A的FOLLOW集中;
- 如果A后第一个符号是非终结符B,将该非终结符B的FIRST集(去除ε)加入A的FOLLOW集中,但需要注意的是,如果B的FIRST集存在ε,要加上下一个符号C的FIRST集(去除ε),如果FIRST(C)还有ε就继续向后推演,以此类推,如果直到最后一个符号的FIRST集还包含ε,要将该产生式左部非终结符E的FOLLOW集加入A的FOLLOW集;
- 如果A是最后一个符号,要将该产生式左部非终结符E的FOLLOW集加入A的FOLLOW集。
不难发现,也可以简化到一个循环当中。无非就是先找A的位置然后往后面迭代符号,没有遇到ε就并入FIRST-ε后马上退出,一直遇到ε就一直迭代,到了末尾就把左边E的FOLLOW集合并入A的FOLLOW集合。这里只需要注意在求A的FOLLOW前保证E的FOLLOW先求好就行。
for A -> α B β ... :
FIRST(β) \ {ε} ⊂ FOLLOW(B)
for A -> α B β1 β2..., and all of {β1,β2,...} has epsilon:
FOLLOW(A) ⊂ FOLLOW(B)
that means,
while true do
FIRST(β) \ {ε} ⊂ FOLLOW(B) // where β is symbol next to B
if ε not ∈ FIRST(β) then break;
if β is last then FOLLOW(A) ⊂ FOLLOW(B)
β = get_next_symbol_of_β()
done
for A -> α B:
FOLLOW(A) ⊂ FOLLOW(B)
4. 第二步,构造预测分析表
预测分析表可以使用二维数组表示,行标代表要进行推导的非终结符,列标代表要推导出的最左终结符(包括终止符#,不包括空字ε),单元内的数据表示选用的产生式。Table[A][b]代表:对于非终结符A要想推导出最左终结符为b的短语,要采用Table[A][b]中的产生式进行推导。
预测分析表的结构要方便构建而且容易读取。基于语言特性,使用哈希表压缩预测分析表,并且在预测分析表当中以产生式编号(下标)代替产生式本身。
typedef std::unordered_map<uint16_t, int> predict_table;
这里由于直接使用pair<A, b>作为键会对unordered_map本身的哈希函数造成影响报错,因此我决定把两个char类型的字符A和b压缩到一个uint16_t变量当中进行哈希处理。
// 压缩
uint16_t charsToUint16(char first, char second) {
return (static_cast<uint16_t>(first) << 8) | static_cast<uint16_t>(second);
}
// 还原
std::pair<char, char> uint16ToChars(uint16_t value) {
char first = static_cast<char>((value >> 8) & 0xFF);
char second = static_cast<char>(value & 0xFF);
return {first, second};
}
求取的算法实验大纲当中已经给出,此处略。最后得到的结果如下。
5. 编写总控程序完成分析
总控程序的算法较为简单。
初始状态将开始符号S压入符号栈,将待读句子放入缓冲区。
根据符号栈栈顶非终结符A和缓冲区最左符号a查预测分析表:
- 如果栈顶A是非终结符,Table[A][a]为空,则说明分析错误,该句子不符合语法规范;
- 如果栈顶A是非终结符,Table[A][a]有多个产生式,就说明该文法不是LL(1)文法;
- 如果栈顶A是非终结符,只有一个产生式,就利用该产生式进行推导,即:符号栈弹出A符号(产生式左部),将该产生式右部逆向入栈(因为是最左推导)。
- 如果栈顶A是终结符,则比较符号栈顶A和缓冲区最左符号a是否匹配,如果匹配,符号栈和缓冲区均弹出相匹配的符号;如果不匹配,那么说明语法错误。
五、实验结果
将上述做好的文法输入rule.txt,并将测试的正确输入串i*i+i
输入test_02.txt,运行程序得到分析过程与结果如下:(symbol为符号栈,production这里笔误了属于是,为当前剩余的输入串,operation为当前步骤所用的产生式)
可见实验结果是准确的。
将测试的有错误的输入串(i)*k
、i**i+i
等依次输入test_02.txt,运行程序得到分析过程与结果如下:
当输入串为(i)*k
时,中途会上报无法识别文法符号k的错误。
当输入串为i**i+i
的时候,中途会上报无法匹配产生式的错误。
六、实验结论
实验利用自定义的源程序进行测试,结果正确,符合预期结果,测试源码及结果截图和说明如上所示。
七、实验小结
能不能快速写出本实验的代码,关键在于能否理解FIRST和FOLLOW的定义,能否理解预测分析法本身的思想,以及为何需要FIRST和FOLLOW(部分教程还提到了SELECT,但是当时老师没讲);其次是对于所选语言本身的理解。直接上py可以减轻后者负担。
八、附录
过年总算抽了点时间重构了一版。👉传送门👈.
2025.1.30,一点感想。
做这个实验的时候正是对程序运行一毛都不懂的时候,现在回过头去看当时的代码确实是臭不可闻。在LLM大行其道的今天,完成课程任务已比我们当初容易许多,LLM涌现的reasoning能力令人惊叹。
过去一年在cpp当中沉浮,也逐渐明白了程序运行的一些知识,知道了什么是链接什么是动态库什么是静态库,学会了cmake管理大型项目,知道了原来c++开发也可以像web项目一样模块化成多个文件多个目录进行管理,知道了原来不同版本的gcc编译出来的库存在不同的abi会导致不兼容,知道了main作为所谓“程序入口”的意义,知道了在对非web项目需求怎么实现毫无头绪的时候OOP确实是个不错的抽象手段。。。
重构我还是选择c++,原因之一是打leetcode和写项目都比较熟悉;另一方面,我认为在现代流行语言的各种“导包”范式之外,从早期的程序设计理念当中或许能更深刻体会所谓的程序本质,这是本科上学时我们应该借助所谓“老掉牙”的屠龙术课程真正去做的事情。