编译原理之词法分析

版权声明:欢迎转载,注明出处 https://blog.csdn.net/youyou519/article/details/84565192

首先说明这是网易云课堂中国科学技术大学华保健老师的课程《编译原理》听课笔记,大部分内容是ppt资料,为了方便记忆,写了如下笔记。

词法分析器的任务

首先看编译器结构:

前端又可以分为:

语义分析器也叫类型检查器。

词法分析器结构又为

词法分析器将代码切分为单词,下面是例子:

扫描二维码关注公众号,回复: 6026163 查看本文章

 x,y这些就是记号,EOF也是记号,记号是个大集合。

记号的数据结构定义

字符流变记号流,首先实现记号的数据结构,用C语言实现如下

enum kind {IF, LPAREN, ID, INTLIT, …}; //枚举类型,对词法的分类

struct token{

enum kind k; //第一个域,类型

char *lexeme; //具体值

};

例子:

if(x>5)-->

token{k=if,lexeme=0};

token{k=LPAREN,lexeme=0};

token{k=ID,lexeme="x"};

token{k=GT,lexeme=0};

......

词法分析器的任务:字符流到记号流

  • 字符流:
    • 和被编译的语言密切相关(ASCII, Unicode, or …) 
  • 记号流:
    • 编译器内部定义的数据结构,编码所识别出的词法单元

手工构造法

至少两种实现方案:

  • 手工编码实现法
    • 相对复杂、且容易出错
    • 但是目前非常流行的实现方法
      • GCC, LLVM, …
  • 词法分析器的生成器
    • 可快速原型、代码量较少
    • 但较难控制细
  • 我们先讨论第一种实现方案
    • 后面几讲会讨论第二种方案

手工构造主要问题是转移图的概念

如下是字符转记号的转移图:

优化之后:

下面是转移图的算法

token nextToken () 
    c = getChar (); 
    switch (c) 
        case ‘<’: c = getChar (); 
                  switch (c) 
                      case ‘=’: return LE;
                      case ‘>’: return NE; 
                      default: rollback(); return LT; 
        case ‘=’: return EQ; 
        case ‘>’: c = nextChar (); 
                  switch (c): // similar

如何实现rollback

加入你正在实现基于图转移算法一个词法分析器,所用的语言是C语言。分别在以下情况下,请问你该如何实现上面的rollback()函数:

  1. 你的词法分析器所分析的程序是直接从文件中读取的;(FILE *)

  2. 词法分析器所分析的程序已经被从文件中读到了一个数组中,然后从数组中读取字符。

两种做法有什么优劣。

void rollback()
{
    fseek(fp, -1, SEEK_CUR); // 这里文件指针假定是全局的了。
}
 
void roolback()
{
    if (nFileIndex >0)
        --g_nFileIndex; // 假定索引为全局变量
}
从文件:效率比数组要慢。且异常情况可能会对原文件有误操作,但节省内存

从数组:随着文件的增大,消耗的内存也会提高。但,效率高容易操作。

其他符号词法识别

标识符和关键字关系

很多语言中的标识符和关键字有交集

  • 从词法分析的角度看,关键字是标识符的一 部分

以C语言为例:

  • 标识符:以字母或下划线开头,后跟零个或 多个字母、下划线、或数字
  • 关键字:if, while, else, …

识别关键字(以if为例)

第一种方案:

就是把i单独抠出来,当识别到i,从0走到3方向f同理。

第二种方案:

对给定语言中所有的关键字,构造关键 字构成的哈希表 H
对所有的标识符和关键字,先统一按标 识符的转移图进行识别
识别完成后,进一步查表H看是否是关键 字
通过合理的构造哈希表H(完美哈希), 可以 O(1)时间完成

课程中提到了完美哈希(perfect hashing),请结合查询wiki等资料,回答:

什么是(关键字的)完美哈希?

如何构造完美哈希?试列举一到两种算法。

为什么需要构造完美哈希?用关键字单链表是否可以?

  1. 完美哈希函数是没有冲突的的哈希函数,也就是,函数 H 将 N 个 KEY 值映射到 M 个整数上,这里 M>=N ,而且,对于任意的 KEY1 ,KEY2 ,H( KEY1 ) != H( KEY2 ) ,并且,如果 M = = N ,则 H 是最小完美哈希函数(Minimal Perfect Hash Function,简称MPHF)

  2. 打造一个完美的Hash函数,需要针对相应关键字的有限集合来实现,在查阅资料以后发现,先构造两个普通的哈希函数h1(x)和h2(x),还有一个用数组实现的函数g(x)。使得 h(x)=g(h1(x))+g(h2(x))modn ,其中n是参数的总个数,H(x)就是最终的有序最小完美哈希函数了

  3. 编译器需要有更快的速度来完成关键字辨别的速度,构造完美哈希可以提高效率,链式结构的链表不支持随机访问的,而且还要操作指针,效率更低 

 正则表达式

背景:写一个声明式的规范,通过如lex,flex,jlex,变成词法分析器。

对给定的字符集∑={c1, c2, …, cn}
归纳定义:

  • 空串\epsilon是正则表达式
  • 对于任意c\in∑,c是正则表达式
  • 如果M和N是正则表达式, 则以下也是正则表 达式
    • 选择   M | N = {M, N}
    • 连接   MN  = {mn| m\inM, n\inN}
    • 闭包   M*    = {\epsilon, M, MM, MMM, …}//也叫kleene闭包

正则表达式的形式表示

e -> \epsilon 

          |  c

          | e | e

          | e e

          | e*
问题:对于给定字符集={a, b},可以写出哪些正则表达式?

1.\epsilon 

2.a,b

3.\epsilon|\epsilon,a|b,\epsilon|a,\epsilon|b,//选择

4,\epsilona,\epsilonb,ab,\epsilon\epsilon,....a(\epsilon|a),//连接

5.\epsilon*,(a(\epsilon|a)*

引入正则表达式是为了表达程序语言规则,生成词法分析器。下面是如何用正则表达式生成词法分析器的例子。

关键字

C语言中的关键字,例如if,while等

如何用正则表达式表示?

if:i\in∑,f\in∑,所以连接还是正则表达式。

C语言中的标识符:以字母或下划线开 头,后跟零个或多个字母、数字或下划 线。
如何用正则表达式表示?

这个可以分为两个部分,前半部分是字母加下划线,53种情况,后半部分是再加上数字63种情况。

然后两部分连接再有个闭包,也是正则表达式。

C语言中的整型数和浮点数的正则表达式
如何用正则表达式表示?

整数(+|-)?[1-9][0-9]*

浮点数(+|-)? (0|[1-9][0-9]*).[0-9]*

语法糖

图灵机只需要两种运算就能完整所有操作,赋值和跳转,C或者JAVA是为了更方便,所有所有的语句都是对下层

这两种运算的封装。也就是说语法糖不是必须的,只是为了更方便。

可以引入更多的语法糖,来简化构造
[c1-cn]  == c1|c2|…|cn//顺序,表示c1到cn的任意一个
e+        == 一个或多个e
e?         == 零个或一个e
“a*”== a* 自身, 不是a的Kleen闭包
e{i, j}    == i到j个e的连接
.         ==  除‘\n’外的任意字符

有限状态自动机

如果要自动生成一个词法分析器,需要写一个声明式的规范,通过一个词法分析器的自动生成工具(flex),生成一个词法分析器,描述输出是什么,这里就是有限状态自动机(FA)。

就是自动机可以告诉你能不能接收或者识别提供的字符串。可以写成一个元组M,如上图。S是指的有限自动机的状态。q0是自动机一开始的状态,F是结束状态集,转移函数描述怎么动作。

自动机例子

下图黄色是状态

这里∑={a,b},S={0,1,2},q0=0(一般用一个单项箭头代表起始状态),F={2}(一般用双圈代表),\delta如上图,接收就是通过转移函数到最后状态。

自动机第二个例子

这是个非确定的状态机(NFA),就是转移是多元素集合,每次转移是个状态集,多个转移状态。这种情况就比较复杂了,因为接受需要回溯状态。

确定状态有限自动机DFA

  • 对任意的字符,最多有一个状态可以转移
    • \delta:SX∑->S


 非确定的有限状态自动机NFA

  •  对任意的字符,有多于一个状态可以转移
    • \delta:SX(∑\bigcup\epsilon )->\psiS(幂集,子集集合)

DFA的实现


是个有向图,边和节点都是有信息的,就是有向图。

正则表达式到非确 定有限状态自动机

 RE -> NFA:Thompson算法

基于对RE的结构做归纳

  • 对基本的RE直接构造
  • 对复合的RE递归构造

递归算法,容易实现

  • 在我们的实现里,不到100行的C代码


空串和单字符,直接构造,e1e2这种连接的如下构造

选择自动机形式如下,先构造e1,e2在用4个空,构造。,闭包也如下图

示例

a(b|c)*

证明题

这个论断是否成立:由Thompson算法构造出来的任何一个NFA,均只可能包括唯一的起始状态和唯一的接受状态。

若成立,请给出证明;若不成立,请给出一个反例。

成立,证明;

对于根据epsilon和单个字符的正则表达式构造的NFA只有一个起始状态和一个接受状态。

对于两个正则表达式e1,e2,假设它们构造出的NFA只有一个起始状态和一个接受状态。那么,对于e1|e2, e1 e2, e1*这三种情况,其结果也都是只有一个起始状态和接受状态的。

由归纳法可以得出结论成立

NFA转换到DFA

思想就是由起始状态出发,先读入任何一个字符,看能走到哪个节点,然后考虑根据\epsilon看能够扩展到哪个节点,然后这些节点做一个集合,构成一个边界,然后从这个边界再找一个点,看能够通过\epsilon扩展到哪个点。构成一个新的集合。

注意点

也就是有两步,先状态转换,就是先在NFA上能转换到哪个点,再看新集合上能转换成哪个点,然后对集合上每个点求\epsilon闭包。
如下图

子集构造算法

(* 子集构造算法: 工作表算法*)
q0 <- eps_closure (n0) //随便取一个节点,看通过epsiono能走到的点,所以q0={0}
Q <- {q0} //这个点作为初始节点集,加入大Q,Q就是DFA的所有状态机
workList <- q0 //q0加入工作集
while (workList != []) 
    remove q from workList 
    foreach (character c) //对每个点都要循环
        t <- e-closure (delta (q, c)) //看通过每个路径能走到哪个节点,发现这个例子只能走道n1,再看这节点能走epsiono闭包到哪个节点,就是先算一个状态,再算这个状态delta的闭包
        D[q, c] <- t 把t(之前的所有能走到的闭包)加到DFA里
        if (t\not\in Q) 
            add t to Q and workList,如果不在大Q,将新qx加入

代码操作过程就是下面如图: 

叫子集构造算法是因为在不停构造一个个集合,每个节点都是状态机的子集。工作表算法就是有个worklist存放有待计算的节点。

对算法的讨论

不动点算法

  • 算法为什么能够运行终止(循环到worklist为空为止,但是worklist是Q是NFA状态集合,这个n各元素幂集有限,是2^n,所以有限)

时间复杂度

  • 最坏情况O(2N)
  • 但在实际中不常发生
    • 因为并不是每个子集都会出现

\epsilon-闭包的计算:深度优先

复杂度O(n)

\epsilon-闭包的计算:宽度优先

就是把一个点的所有能通过\epsilon走到的点加入一个队列,然后向外扩散

epsilon闭包的计算

有两种方式可以计算epsilon闭包:

  1. 在线方式:在算法计算的过程中,遇到需要计算某个节点的epsilon闭包的时候,临时计算;

  2. 离线方式:在算法执行之前,把NFA中所有节点的epsilon闭包都计算完;这样算法开始执行后可随时使用。

你认为这两种方式哪种比较好?原因是什么?

因为在构造子集的过程中,获取某些节点的Epsilon闭包多次,如果每次需要的时候都计算,这样大大降低了程序效率。所以为了避免重复计算,离线方式才是最好的。但同时也可能一些闭包从未被使用过,有利有弊吧。

DFA的最小化

Hopcroft算法

// 基于等价类的思想 
split(S) //切分
    foreach (character c) 
        if (c can split S) //c能够切分开一个子集S
            split S into T1, …, Tk
hopcroft () 
    split all nodes into N, A //先将上面切分的状态分为接收状态和非接收状态,2个集合
    while (set is still changes) //如果两个状态能切分再切
        split(S)

 注意hopcroft通过字符看能不能划分成子集是接受个字符会不会从这个集合出去。

下面两个示例,第一个示例,划分为N和A,之后通过字符不可能把A切分成开(比如通过b,c)

DFA的代码表示


概念上讲, DFA是一个有向图

 实际上,有不同的DFA的代码表示

  • 转移表(类似于邻接矩阵)
  • 哈希表(表示关系)
  • 跳转表(跳转代码实现)
  • 。。。

取决于在实际实现中,对时间空间的权衡

转移表

构造个二维数组,x就是DFA的集合数量,y为字符数(如256个的ASCII表),这个就是转移表,还需要词法分析的驱动代码,根据表示表项做控制。代码如下:

nextToken() //每调用一次,返回识别的串
    state = 0 //目前自动机走到的那个地方
    stack = [] //为了实现最长匹配
    while (state!=ERROR) 
        c = getChar() 
        if (state is ACCEPT) 
            clear(stack) 
        push(state) 
        state = table[state][c]
    while(state is not ACCEPT) 
        state = pop(); 
        rollback();

跳转表

nextToken() 
    state = 0 
    stack = [] 
    goto q0
q0: 
    c = getChar() 
    if (state is ACCEPT) 
        clear (stack) 
    push (state) 
    if (c==‘a’) 
        goto q1:
q1: 
    c = getChar() 
    if (state is ACCEPT) 
        clear (stack) 
    push (state) 
    if (c==‘b’||c==‘c’) 
        goto q1

从拓扑结构来说,跳转表跟自动机结构一样如下图

跳转表基本实现是把每个状态变成一段代码,把边的转移变成显式的跳转,每个代码负责识别每个字符的跳转。

所以跳转表不用维护数组。性能高一些。

参考资料

网易云课堂课程:编译原理(中国科学技术大学 华保健)

猜你喜欢

转载自blog.csdn.net/youyou519/article/details/84565192