编译原理实验二 《预测分析法设计与实现》

本文介绍了如何使用C++和STL实现预测分析法的语法分析器,包括规则文件的处理、数据结构的设计、FIRST/FOLLOW集的求解、预测分析表构建以及错误处理。通过实例展示了分析过程和结果,以及实验中的关键点和优化建议。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

编译原理实验二 《预测分析法设计与实现》

一、实验目的

加深对语法分析器工作过程的理解;加强对预测分析法实现语法分析程序的掌握;能够采用一种编程语言实现简单的语法分析程序;能够使用自己编写的分析程序对简单的程序段进行语法翻译。

二、实验内容

用预测分析法编制语法分析程序,语法分析程序的实现可以采用任何一种编程语言和工具。

三、实验方法

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集和FOLLOW集结果

由于函数具体实现过长,此处不再展示代码,仅简要对实现的算法进行描述。

  • FIRST: 简单来说就是,某个串(非终结符)的FIRST集就是这个串能推导出的式子的第一个终结符的集合(该终结符必须在最左边)。

要求A的FIRST集,首先找到所有左部为A的产生式,然后遍历这些产生式的右部:

  1. 如果右部第一个符号是终结符α,那么把α加入到A的FIRST集中;
  2. 如果右部是ε,把ε加入到A的FIRST集中;
  3. 如果右部第一个符号是非终结符α,将该非终结符α的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后面的第一个符号:

  1. 文法开始符号的FOLLOW集包含#(#是终止符,代表一个句子的末尾,可以看做EOF,在有的材料中是$)
  2. 如果A后第一个符号是终结符α,那么把这个终结符α加入到A的FOLLOW集中;
  3. 如果A后第一个符号是非终结符B,将该非终结符B的FIRST集(去除ε)加入A的FOLLOW集中,但需要注意的是,如果B的FIRST集存在ε,要加上下一个符号C的FIRST集(去除ε),如果FIRST(C)还有ε就继续向后推演,以此类推,如果直到最后一个符号的FIRST集还包含ε,要将该产生式左部非终结符E的FOLLOW集加入A的FOLLOW集;
  4. 如果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查预测分析表:

  1. 如果栈顶A是非终结符,Table[A][a]为空,则说明分析错误,该句子不符合语法规范;
  2. 如果栈顶A是非终结符,Table[A][a]有多个产生式,就说明该文法不是LL(1)文法;
  3. 如果栈顶A是非终结符,只有一个产生式,就利用该产生式进行推导,即:符号栈弹出A符号(产生式左部),将该产生式右部逆向入栈(因为是最左推导)。
  4. 如果栈顶A是终结符,则比较符号栈顶A和缓冲区最左符号a是否匹配,如果匹配,符号栈和缓冲区均弹出相匹配的符号;如果不匹配,那么说明语法错误。

五、实验结果

将上述做好的文法输入rule.txt,并将测试的正确输入串i*i+i输入test_02.txt,运行程序得到分析过程与结果如下:(symbol为符号栈,production这里笔误了属于是,为当前剩余的输入串,operation为当前步骤所用的产生式)

正确句子的分析过程

可见实验结果是准确的。
将测试的有错误的输入串(i)*ki**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和写项目都比较熟悉;另一方面,我认为在现代流行语言的各种“导包”范式之外,从早期的程序设计理念当中或许能更深刻体会所谓的程序本质,这是本科上学时我们应该借助所谓“老掉牙”的屠龙术课程真正去做的事情。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值