编译原理笔记01:词法分析

        编译原理学习笔记。参考:

        编译原理(中科大)

        编译原理(哈工大)

        笔记是根据书+网课+本校课程按照理解过程记录,和书或网课的顺序不一致。

        github持续更新。

第一章 引论

1.语言处理器

  • 编译器

  • 解释器

2.编译器的结构

  • 词法分析:将语素转换为词法单元

  • 语法分析:使用词法单元创建树形的中间表示,常用语法树。

  • 语义分析:检查源程序是否和语言定义的语义一致,并进行类型检查。

  • 中间代码生成

  • 代码优化

  • 代码生成:以源程序的中间表示形式作为输入,映射到目标语言。

3.程序设计语言的发展历程

4.编译器相关科学

5.编译技术应用

6.程序设计语言基础

  • 静态和动态的区别:使用的策略可处理编译时刻可决定的问题,则为静态策略;一个只允许在运行程序时作出决定的策略则为动态策略。

  • 声明的作用域:仅阅读程序就可以确定一个声明的作用域,则该语言使用的是静态作用域。

  • 环境:名字到存储位置的映射。

  • 状态:内存位置到值的映射。

  • 块结构:{}界定一个块。

  • 静态作用域规则:如果名字x的声明D属于B块,那么D的作用域包含整个B,但是以任意深度嵌套在B中,重新声明了x的所有块C不在此作用域中。

  • 动态作用域:一个作用域策略依赖于一个或多个在程序执行时刻才能知道的因素,就是动态的。

//动态作用域例:
#define a (x+1)
int x = 2;
void b() { int x =1; printf("%d\n",a);}
void c() {printf("%d\n",a);}
void main(){b();c();}>();<span style="color:#000000">c</span>();}</span></span>
  • 参数传递:参数可以通过值或引用的方式从调用过程传递给被调用过程。通过值传递传递大型对象时,实际被传递的是指向这些对象的引用。

  • 别名:当参数被以引用传递方式传递时,两个形式参数可能会指向同一个对象,这会造成一个变量的修改改变了另一个变量的值。

第二章 词法分析

        在开始词法分析之前,需要了解一些基本概念。

1.词法&语法分析基本概念

字母表

        字母表是有穷符号的集合。两个字母表可以进行乘积,幂,正闭包,克林闭包运算。

  • 乘积:{0,1}{a,b} = {0a,0b,1a,1b}

  • 幂:{0,1}^3 = {0,1}{0,1}{0,1}

  • 正闭包:{a,b,c,d}+ = {a,b,c,d,aa,ab......}

  • 克林闭包:{a,b,c,d}* = {a,b,c,d}+ ε(空串)

    串是字母表中符号的一个有穷集合,串s的长度通常记作|s|。串的运算为连接和幂,串x=str1,y=str2,则xy=str1str2,幂运算同理。

文法

文法是用来描述语法结构(语言规则)的。一个自然语言的文法例子如下:

  • <句子> -> <名词短语><动词短语>

  • <名词短语> -> <形容词><名词短语>

  • <名词短语> -> <名词>

  • ...其他规则

  • <形容词> -> {...}

  • <名词> -> {...}

        文法的形式化定义:G=(Vt,Vn,P,S)。

  • Vt:终结符集合。终结符是文法所定义语言的基本符号。例如Vt={difficult,parse,course.....}

  • Vn:非终结符集合。非终结符是表示语法成分的符号,也被称为语法变量。例:{<句子>,<名词短语>,<动词>......}

  • P:产生式集合。产生式描述了终结符(基本符号)和非终结符(语法成分)组成串的方式。一般形式为:α→β。例如:<句子>→<动词短语><名词短语>。另外,同一个非终结符(语法成分)的产生式可以组合在一起表示,用|分隔: list → list + digit,list → list-digit可以合并成list → list+digit | list-digit。(|的优先级最低)

  • S:开始符号,文法中最大的语法成分。例:S=<句子>

        有了语言规则,就可以判断一个词串是否是一个语言的句子了。句子可以进行推导,也可以进行规约,分别对应着生成语言和识别语言的角度,下面是自然语言的例子:

2.词法分析和词法分析器

        词法分析是编译的第一个阶段,任务是把源程序的输入字符(词素)转换为已知语言的成分(词法单元序列)。转换后的词法单元序列会交给语法分析器进行语法分析。通常语法分析起有一个getNextToken命令让词法分析器读取输入字符,直到词法分析器能识别一个词素,并将该词素生成为词法单元交给语法分析器。实现一个词法分析器有两种方式,第一种是手动用代码实现(需要画状态转移图辅助),另一种是使用词法分析器生成工具(需要使用正则表达式描述出词素的模式)。

下面是词法分析使用的术语:

  • 词法单元:由一个词法单元名和一个可选属性值组成。词法单元名是表示词法单位的抽象符号,属性值表示了词法单元的相关信息。比如一个词法单元“id”表示程序设计语言的标识符,那么属性值就是指向这个具体的标识符信息(在符号表里)的指针;一个词法单元“number”表示数字常量,那么属性值就是这个数字常量的具体值。

  • 模式:描述了一个词法单元的词素可能具有的形式。

  • 词素:源程序的一个字符序列,和一个词法单元的模式匹配,可以被识别为一个词法单元的实例。

例:

3.输入缓冲

        识别输入流中的词素之前,首先要读入输入流。为了处理输入的字符,需要使用两个缓冲区。每个缓冲区容量都是N个字符,通常N是一个磁盘块的大小。处理读入字符流时有两个指针,lexemeBegin指针指向当前词素的开始处,forward指针一直扫描,直到发现某个模式匹配为止。如果forward已经扫描到了EOF,就再读入N个字符到另一个缓冲区,这样只要词素的长度不大于N,就不会在识别到词素之前覆盖掉缓冲区中的词素。EOF也被称为哨兵标记,输入的末尾和缓冲区的末尾都有该字符。

*4.正则表达式

        正则表达式是用来描述语言的一种方式,可以描述字母表上的符号通过并,连接,闭包这些运算而得到的语言。用符号给正则表达式命名,然后进行定义,写法与文法很相似,例如用正则表达式表示无符号数(5280,0.01234,6.336E4,1.89E-4)的串number:

digit ——→ 0|1|...|9
digits ——→ digit digit*
optionalFraction ——→ . digits | e
optionalExponent ——→ (E(+|-|e)digits) | e
number ——→ digits optionalFraction optionalExponent

        正则表达式还有一些扩展的写法,如后缀+表示语言的及其正闭包,后缀?表示零个或一个出现,字符类可以用[a-z]表示a|b|...|z。

5.状态转换图

​        构造一个状态转换图可以体现词素转换的过程,每识别一个字符,就更新到下一个状态,如果已经找到了词素对应的词法单元,当前的字符不是上一个词法单元中的一部分,则要让forward指针回退一个位置。下面这个例子清晰的表现了状态转换图是怎么体现词素识别过程的。

        对于保留字(if,else)的识别,通常为这些保留字建立单独的状态图。也可以将其先填入符号表中,识别该词法单元后,如果符号表中没有该符号,就说明不是保留字,添加到符号表,并返回条目指针作为属性。

6.词法分析工具Lex

        开始介绍词法分析时,提到了将词素转换为词法单元有两种方式:第一种是手动用代码实现(需要画状态转移图辅助),另一种是使用词法分析器生成工具(需要使用正则表达式描述出词素的模式)。画状态转移图的方式已经介绍过了。而Lex就是另一种方式的工具,即词法分析器生成工具。

        Lex中,使用正则表达式描述词法单元的模式,Lex编译器将输入的模式转换成一个状态转换图,并生成相应的实现代码,存放到lex.yy.c文件中。Lex编译器编译的内容是由Lex语言表示的。因此Lex的使用方式如下:

        Lex程序结构如下:

<span style="background-color:#f3f3f3"><span style="color:#555555">声明部分
%%
转换规则
%%
辅助函数</span></span>
  • 声明部分:包括变量和明示常量(如一个词法单元的名字)。

  • 转换规则:形式为:模式{动作},模式为正则表达式,动作则是代码片段。

  • 辅助函数:各个动作需要的辅助函数。

        仅看上面的结构很难理解Lex程序到底是怎么构造的,需要结合下面这个例子来理解各个部分:

%{
	/*明示常量:LT,LE,NE,...,ID,NUMBER...*/
    /*这部分(%{%})里面的内容会直接复制到C代码中*/
    #define LT 10000
    ......
%}

/* 一些正则表达式 */
delim   [ \t\n]
ws 	    {delim}+
letter	[a-zA-Z]
digit	[0-9]
id		{letter}({letter}|{digit})*
number	{digit}+(\.{digit}+)?(E[+-]?{digit}+)?						//\.表示‘.’
%%
    
{ws}	{}
if		{return(IF);}												//保留字先被列出
then	{return(THEN);}
else	{return(ELSE);}
{id}	{yyval = (int)installID();return(ID);}
{number}{yyval = (int)installNum();return(NUMBER);}
"<"		{yyval = LT;return(RELOP);}
......
%%
    
int installID(){
    /*
    将找到的词素放到符号表中
    返回一个指向符号表的指针到yyval,这是个全局变量,语法分析器或编译器等后续组件可以使用
    把词法单元名返回到语法分析器。
    */
}
...

        最后,如果Lex遇到了冲突,即一个词素可以被识别为多种词法单元,按照以下规则处理:

  • 选择最长前缀(例如选择<=,而不是<)。

  • 有多个模式可匹配,选择在Lex中先被列出的。

        书中还提到了关键字不是保留字的情况,这里不讨论。

*7.有穷自动机

​       以上已经说明了Lex的使用,下面说明Lex是如何将输入程序变成一个词法分析器的。抓换的核心是被称为有穷自动机(FA)的表示方法。自动机本质上是与状态转换图类似的图,由许多状态构成,不同状态之间通过有向的标号(标记)可以转换。
  • 有穷自动机是识别器,只能对可能的输入串回答'是'或'否'。

  • 有穷自动机分为两类:

    • 不确定的有穷自动机(NFA):对其边上的标号没有限制,可以是空串,且一个符号可以标记离开一个状态的多条边。(一个状态接收到一个符号后,可能到达不同的状态)

    • 确定的有穷自动机(DFA):对于每个状态及自动机输入字母表中的每个符号,有且只有一条离开该状态,以该符号为标号的边。(一个状态接收一种符号,只到达一种下一状态)

7.1 不确定的有穷自动机(NFA)

        一个NFA由以下几个部分组成:

        不管是NFA还是DFA,实际上都可以用转换图来表示,但是NFA与状态转换图的不同在于:

  • 同一个符号可以标记从同一状态到多个目标状态的边。(一个符号可以转换到不同状态)

  • 标号不仅是字母表中的符号,还可以是空符号串ε

        下面是一个识别正则表达式(a|b)*abb的NFA的转换图:

        上面的状态3表示串被接收,所有该FA接受的串构成的集合记为L(M),称为被该FA定义(或接收)的语言。从状态0接收到‘a’,可能到达状态0,也可能到达状态1,因此这个FA是不确定有穷自动机NFA。除了状态转换图以外,转换表也可以表示NFA,以下是上图的NFA对应的转换表:

        上面已经看到NFA接收一个符号,可能转换到不同状态的特点了,下面的例子展现了另一个特点,空串也可以作为状态转换的标号:

7.2 确定的有穷自动机(DFA)

        DFA是NFA的特例,特点是:

  • 没有输入ε的转换动作。

  • 每个状态s和符号a,只有一条标号为a的边离开s。

        DFA相对于NFA识别更加具体和简单,因为一个符号只能导致从一个状态转换到另一个特定的状态(而不是不确定的多个状态),由于每个NFA都可以转变为一个接收相同语言的DFA,实际上我们模拟和实现的都是DFA。

*8.正则表达式到自动机

        介绍自动机是要说明Lex词法分析的过程。自动机本质其实就是状态机,可以说Lex的工作就是根据自动机的状态变化产生词法分析代码。我们输入Lex的是正则表达式,因此Lex要先把这些正则表达式转化为自动机。通常先把正则表达式转换为NFA,再把NFA转换为DFA(因为直接从正则表达式构建DFA比较复杂)。

8.1 正则表达式到NFA

        对正则表达式进行NFA的构造只要按照以下规则:

8.2 NFA到DFA

        有了NFA后,需要将NFA转换为DFA。这个过程采用的是子集构造法。基本思想是:DFA的每个状态是NFA的状态集合(例如NFA中的某个状态A,得到字符x后可能回到A,可能到B或C,那么转换到DFA后,ABC就是为一个状态集合,表示DFA中的一个状态。)。下面直接看两个例子:

 

8.3 DFA最小化

        对于同一个语言,存在多个识别该语言的DFA。使用DFA来实现词法分析器,总是希望使用的DFA状态数最少。因此需要进行DFA最小化。DFA最小化通常使用的是Hopcroft算法,也称为基于等价类的算法。

        该算法的原理是,假设有一个状态集合S,如果有一个字符c,可以将S切割,那么这个S就被切割成S1,S2。不断的进行切割,直到不可切割时,每个集合S1,S2...中的状态就是可以合并的。字符c可以切割S这样解释,假设状态1,2,3一开始都在集合S中,状态1和状态2接收到字符c都转换为状态4,而状态3接收到c不转换到状态4,那么就说字符c可以切割集合S,集合S被切割成S1(状态1,状态2)和S2(状态3)。最开始的时候,现将所有状态分成两个集合,接收状态的集合和非接受状态的集合。该算法的描述如下:

split(S)
    foreach (character c)
    	if(c can split S)
            split S int T1,...,Tk
hopcroft()
    split all nodes into N,A
    while (set is still changes)
    	split(S)

9.DFA到词法分析器代码

        能够从正则表达式构建NFA,将NFA转换到DFA并最小化,最后一步就是将DFA转变成词法分析器的代码。DFA是一个有向图,可以表示为转移表,哈希表等不同的表示,具体的表示取决于在实际实现中对时间空间的权衡。

        先看一下转移表的表示:

状态\字符abc
01
111

        状态的变化可以直接查表,再加上驱动代码就可以构成词法分析器:

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();
    }
    if(state is ACCEPT) return ACCEPT_TOKEN;
}

        还有使用跳转表的代码实现,使用goto语句进行状态跳转,if语句进行字符判断并状态跳转。这样实现的方式优点是不需要存储状态表,当状态表很大,状态非常多的时候,这样能节省许多空间。

  • 1
    点赞
  • 20
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值