程序员不得不会的计算机科班知识——编译原理篇(上)

632 阅读28分钟

计算机科班知识整理专栏系列文章:

【1】程序员不得不会的计算机科班知识——操作系统篇(上)
【2】程序员不得不会的计算机科班知识——操作系统篇(下)
【3】程序员不得不会的计算机科班知识——数据库原理篇(上)
【4】程序员不得不会的计算机科班知识——数据库原理篇(下)
【5】程序员不得不会的计算机科班知识——数据结构与算法篇(上)
【6】程序员不得不会的计算机科班知识——数据结构与算法篇(下)
【7】程序员不得不会的计算机科班知识——软件工程篇(上)
【8】程序员不得不会的计算机科班知识——软件工程篇(中)
【9】程序员不得不会的计算机科班知识——软件工程篇(下)
【10】程序员不得不会的计算机科班知识——编译原理篇(上)
【11】程序员不得不会的计算机科班知识——编译原理篇(中)
【12】程序员不得不会的计算机科班知识——编译原理篇(下)
【13】程序员不得不会的计算机科班知识——计算机网络篇(上)
【14】程序员不得不会的计算机科班知识——计算机网络篇(中)
【15】程序员不得不会的计算机科班知识——计算机网络篇(下)

第一章 引论

1.1 一些概念

  • 翻译程序:把某一种语言程序(源语言程序)等价地转换成另一种语言程序(目标语言程序)的程序。

  • 编译程序(compiler): 把某一种高级语言程序等价地转换成另一种低级语言程序(如汇编语言或机器语言程序)的程序。其结果可以单独成立。

  • 解释程序:把源语言写的源程序作为输入,但不产生目标程序,而是边解释边执行源程序本身。其结果需要解释器辅助,兼容性差

例如:

  • 诊断编译程序:是一种编译程序,用于检测源代码中可能存在的语法或语义错误,并生成相应的错误报告。诊断编译程序的主要作用是帮助程序员在编写代码的过程中及时发现错误,以便及时进行修改。

  • 交叉编译程序:是一种编译程序,可以在一个操作系统上编译为另一个操作系统所需的程序。例如,在Windows系统上编译为Linux系统所需的程序。交叉编译程序的主要作用是方便开发人员在不同平台之间移植程序。

  • 优化编译程序:是一种编译程序,可以使用各种技术和算法来优化目标代码的性能。例如,使用适当的寄存器分配、指令调度等技术,可以使生成的目标代码更快、更小、更高效。

  • 可变目标编译程序:是一种编译程序,可以针对不同的目标硬件生成不同的目标代码。这意味着在编译同一份源代码时,可以针对不同的硬件进行优化,以获得更好的性能。

这些编译程序之间的区别在于它们的功能和使用场景不同。例如,诊断编译程序主要用于发现错误,交叉编译程序主要用于移植程序,而优化编译程序和可变目标编译程序则主要用于生成具有更高性能和更小体积的目标代码。

1.2 编译过程

编译过程一般分为以下阶段:

  1. 词法扫描(输出是单词(token)序列。)
  2. 语法分析(输出是语法树。)
  3. 语义分析(输出是附加了类型和语义信息的语法树。)
  4. 中间代码生成(输出是中间代码。)
  5. 优化(输出是优化后的中间代码。)
  6. 目标代码生成(输出是目标机器代码或汇编代码。)
  • 编译前端:与源语言有关,如词法分析,语法分析,语义分析与中间代码产生,与机器无关的优化。
  • 编译后端:与目标机有关,与目标机有关的优化,目标代码产生。

1.2.1 词法扫描

  • 任务:输入源程序,对构成源程序的字符串进行扫描和分解,识别出一个个单词记号(token)。
  • 依循的原则:词法规则
  • 描述工具:正则表达式和有限自动机
  • 例子:

1.2.2 语法分析

任务:在词法分析的基础上,根据语言的语法规则把单词符号串分解成各类语法单位(主谓宾)。

依循的原则:语法规则

描述工具:上下文无关文法

通常输出为语法树

例子:

1.2.3 语义分析

  • 任务:使用语法树和符号表中的信息来检查源程序是否和语言定义的语义一致。同时也收集类型信息等。
  • 类型检查是一个重要组成部分。
  • 通常输出为注释树。

1.2.4 中间代码生成

  • 任务:对各类不同语法范畴按语言的语义进行初步翻译。

  • 中间代码:三元式,四元式,树形结构等。

  • 例子:

1.2.5 优化

  • 任务:对于前阶段产生的中间代码进行加工变换,以期在最后阶段产生更高效的目标代码。
  • 主要包括:公共子表达式的提取、循环优化、删除无用代码等等。
  • 优化贯穿多个阶段,分为源代码优化、中间代码优化和目标代码优化。不同的编译器所做的优化差别相当大。

1.2.6 目标代码生成

  • 任务: 把中间代码变换成特定机器上的目标代码。
  • 依赖于硬件系统结构和机器指令的含义。
  • 目标代码三种形式:
    1. 绝对指令代码: 可直接运行
    2. 可重新定位指令代码: 需要连接装配
    3. 汇编指令代码: 需要进行汇编

1.2.7 表格和表格管理

常见的表格:符号名表,常数表,标号表,入口名表,过程引用表。

格式:

1.2.8 出错处理

出错处理程序:发现源程序中的错误,把有关错误信息报告给用户。

  • 词法分析阶段处理的错误:非法字符、单词拼写错误等。
  • 语法分析阶段处理的错误:标点符号错误、表达式中缺少操作数、括号不匹配等有关语言结构上的错误。
  • 静态语义分析阶段(即语义分析阶段)处理的错误:运算符与运算对象类型不合法等错误。本题选择语义错误。
  • 目标代码生成(执行阶段)处理的错误:动态语义错误,包括陷入死循环、变量取零时做除数、引用数组元素下标越界等错误等。

1.2.9 例子

一个赋值语句的翻译:

1.3 T形图

  • 基本图形:用I语言描述的,将S语言翻译成T语言的编译程序 简称:I语言写的S语言的编译程序。
  • 自展技术:用将要编译的语言编写编译器是很常见的一种方法。
  • 自展方法:用机器语言编写一个简单版的编译器,用来编译用自己写的编译程序源码生成新的更完善的编译器。

例1:(交叉编译)

已知在A机器上有L语言的编译程序AC,希望在B机器上实现L语言的编译程序BC,而这是利用L语言在A机器上的编译程序AC实现的。

解:

进一步解释:

前提:编译程序C是用L语言实现的,能够将L编译成B;编译程序AC是用A语言实现的,能够将L编译成A。

所以此时若用AC编译C,相当于实现一个编译程序ABC(用A语言实现的,能够将L编译成B)。

又因为编译程序C是用L语言实现的,能够将L编译成B,则此时用ABC去编译C,则即可得到所需结果。

例2:(本机编译程序的利用)

设A机器上已经有语言L的编译程序AC-L,希望构造A机器上另一种语言L'的编译程序AC-L'。

解:

例3:

设A机器上有语言L的编译程序,可以用它来编制B机器上的语言L'的编译程序,试用T形图进行表示。

解:

第二章 词法分析

2.1 概述

  • 词法分析的任务:从左至右逐个字符地对源程序进行扫描,产生一个个单词符号(token)。

  • 词法分析器(Lexical Analyzer) 又称扫描器(Scanner):执行词法分析的程序。

  • 词法扫描器输入的是源代码,本质是字符串;输出的是单词符号串。

  • 程序源代码中可能出现的语法元素:

    • 关键字:如 while,if, int...
    • 标识符——表示各种名字:如变量名、数组名、标号名、过程名、函数名等等。
    • 常数:各种类型的常数
    • 运算符:+,-,*,/,括号...
    • 界符:逗号,分号,冒号...
    • 空白符:空格、制表符、换行符...(可以考虑在词法扫描阶段剔除它们。)
    • 注释。(可以考虑在词法扫描阶段剔除它们。)
  • 在词法扫描器的处理过程中需要预读一些字符来进行单词记号的判断。通用的做法是使用输入缓冲区。词法扫描器可以从缓冲区取出字符,也可以把字符放回去。

  • 词法扫描器的结构:

  • 单词记号的表示形式:

    • 常见的单词符号的表示形式:(单词种别,单词自身的值)

    • 单词种别通常用整数编码表示(枚举)。

    • 若一个种别只有一个单词符号,则种别编码就代表该单词符号。一般关键字、运算符和界符都是一符一种。

    • 若一个种别有多个单词符号,则对于每个单词符号,给出种别编码和自身的值:

      • 标识符单列一种;标识符自身的值表示成按机器字节划分的内部码。
      • 常数按类型(整、实、布尔等等)分种;常数的值则表示成标准的二进制形式。
    • 例如:

  • 需要输出的单词记号:(在涉及到标识符和算符的结束判定时通常采用最长子串原则。)

    • 关键字识别: 程序语言规定的保留字
    • 标识符识别: 记录标识符的字符串。
    • 常数识别: 识别出算术常数并将其转变为二进制内码表示。
    • 算符和界符的识别:记录类别,主要涉及把多个字符复合而成的算符和界符拼合成一个单一单词符号。:=, ++,--,>=。
  • 关于字母表:

    • 字母表∑是一个有穷符号集合
    • 符号:字母、数字、 标点符号、 …
    • 字母表中每一个元素称为一个字符
    • 字母表上的字(也叫字符串)是指由字母表中的字符所构成的一个有穷序列
      • 不包含任何字符的序列称为空字,记为ε
      • 用∑*表示∑上的所有字的全集,包含空字ε
      • 例如: ∑ = {a, b},则 ∑* = {ε, a, b, aa, ab, ba, bb, aaa,...}
  • 关于语言上的运算:

    • ∑*的子集U和V的并运算定义为U ∪ V={ α | α∈U 或 α∈V }

    • ∑*的子集U和V的连接(积)定义为UV={ αβ | α∈U & β∈V }

    • V自身的n次积记为Vn=VV…V。规定V^0={ε},令 V*=V^0∪V^1∪V^2∪V^3∪… ,称V是V的闭包;记录V+=VV ,称V+是V的正闭包。

    • V^n就是字母表上的长度为n的符号串构成的集合。V*就是字母表上的所有字的全集合。

    • 例子:

    例如: V = {0, 1},则V^3 ={000, 001, 010, 011, 100, 101, 110, 111}。

    例如:V = {a, b, c, d},那么V* = {ε, a, b, c, d, aa, ab, ac, ad, ba, bb, bc, bd, aaa, aab, aac, aadbcd, abbaaaabc, …},V+ = {a, b, c, d, aa, ab, ac, ad, ba, bb, bc, bd, aaa, aab, aac, aadbcd, abbaaaabc, …}

2.2 正则表达式

  • 正则表达式(Regular Expression, RE )是一种用来描述正则语言的更紧凑的数学表示方法。

  • 一个字集合是正则集当且仅当它能用正则式表示。

  • 一个正则表达式能表示的正则集称为它能描述的语言。

  • 编程语言的词法基本上都可以用正则表达式描述。

  • 正则式和正则集的递归定义:对给定的字母表∑

    • ε和∅都是∑上的正则式,它们所表示的正则集为{ε}和∅;
    • 任何a∈∑ ,a是∑ 上的正则式,它所表示的正则集为{a} ;
    • 假定e1和e2都是∑ 上的正则式,它们所表示的正则集为L(e1)和L(e2),则:
      • (e1|e2)为正则式,表示的正则集为L(e1)∪L(e2)
      • (e1.e2)为正则式,表示的正则集为L(e1)L(e2)
      • (e1)为正则式,表示的正则集为(L(e1))
    • 仅由有限次使用上述三步骤而定义的表达式才是∑ 上的正则式,仅由这些正则式表示的字集才是∑ 上的正则集。
  • 正则运算的优先级:

    • 闭包运算优先级最高
    • 连接运算次之
    • 选择运算优先级最低
    • 括号可以改变优先级顺序
    • 元字符: ε ∅ | ( ) *
  • 例子:

令 ∑ = {a, b},则

L(a|b) = L(a)∪L(b) ={a}∪{b} = {a, b}

L((a|b)(a|b)) = L(a|b) L(a|b)={a, b}{a, b}= { aa, ab, ba, bb }

L(a*) = (L(a))= {a}= { ε, a, aa, aaa, . . . }

L((a|b)) = (L(a|b)) = {a, b}*= { ε, a, b, aa, ab, ba, bb, aaa, . . .}

L(a|a*b) = { a, b, ab, aab, aaab, . . .}

  • 若两个正则式所表示的正则集相同,则称这两个正则式等价。如b(ab)=(ba)b,(ab)=(a|b)
  • 对正则式,下列等价成立:
    • e1|e2 = e2|e1 交换律
    • e1 |(e2|e3) = (e1|e2)|e3 结合律
    • e1(e2e3) = (e1e2)e3 结合律
    • e1(e2|e3) = e1e2|e1e3 分配律
    • (e2|e3)e1 = e2e1|e3 e1 分配律
    • eε = εe = e e1e2 <> e2 e1

正则表达式的扩展:

一个或多个重复:r+(表示匹配一个或多个字符 r)

字母表里面任意一个字母:.b.(可以匹配包含字符串 "b" 的任何字符串)

可选字母的范围里的一个字母:[0-9], [a-zA-Z]

非可选范围内的一个字母:(a|b|c),[^abc]

可选,零个或者一个:r?

2.3 正则表达式到NFA&DFA的转化

2.3.1 状态机引入

通过前面正则表达式的介绍,我们已经实现了把满足特定要求词法Token利用正则表达式表示出来,比如说我们可以很轻松地表示c语言的标识符如下:

    letter -> a|b|...z|A|B|...|Z|
    digit -> 0|1|...|9
    identifier -> letter(letter|digit)*

那么现在的问题是咱们学会了这个有啥用?因为咱们构建词法扫描最终的目的是实现第一章提到的要求,那就是识别出源代码文本的的Token并将其输出,这需要用程序来实现。因此,我们发现正则表达式是无法满足我们的要求,所以我们需要进一步地引入状态机来编写程序,实现识别Token。

而状态机分为两种,一种是非确定有限自动机(NFA),一种是确定有限自动机(DFA)。

2.3.1.1 NFA定义

这里我们给出NFA定义如下:

看定义很复杂,其实很好理解。它就是用图的方式来表示正则表达式能表示的词法。它需要一个初态点和终态点以及两者中间若干的转化状态结点,同时需要箭头弧和其上面的字符来表示不同状态结点之间是如何转化的。

为了方便理解,举个简单的NFA图如下:

2.3.1.2 DFA定义

这里我们给出DFA的定义如下:

这里可以像理解NFA一样去理解DFA,它们是相似的,但却又有重要的差异。

2.3.1.3 NFA & DFA 的差异

两者的定义比较如下:

可以看出,第3条状态转化函数有着细微的区别。因此我们有:

  • DFA要求转化函数单值部分映射,而NFA则只需要部分映射。这意味着DFA的状态给定,输入的字符给定,那末他的下一个状态一定是确定的,而不像NFA
  • DFA不允许𝜀出现在弧上
  • DFA要求弧上的输入必须是单个字符,而NFA的输入可以是一个字(而不强求是单个字符)

综上,我们知道DFA其实是一种特殊NFA。

2.3.1.4 小结

通过前面的介绍,我们了解了两个状态机NFA和DFA以及它们的区别。还记得我们引入状态机的目的吗?

因为正则表达式表示的词法我们无法用程序去实现,所以我们引入状态机。

现在,请思考一个问题,前面介绍的DFA和NFA我们选择哪种来转化?也就是说假如我现在已经有了一个关于C语言标识符的正则表达式,我将其最终转化成DFA还是NFA才能用程序编写实现呢?

答案是DFA。为什么?因为程序不能有二义性,我们需要一个给定状态和输入字符就能到达下一个唯一的状态,而这只有DFA才能办到。

但是在实际转化中,因为由正则表达式一步转成DFA是比较困难的,因此我们常常先将正则表达式转成NFA,然后再由NFA转成DFA。

2.3.2 正则表达式转NFA

2.3.2.1 转化规则

正则表达式转化成NFA的三条基础规则如下:

上述的核心是:要记住表达式中积、选择和闭包运算如何转化成对应的图。

2.3.2.2 练习

举个简单的例子,若有正则表达式如下,试画出其NFA图:

    (ab|a)*

画法如下:

  • 画出初态和终态

  • 利用如下规则替换闭包

得到:

  • 利用如下规则替换选择运算

得到:

  • 利用如下规则替换连接运算

得到:

  • 状态编号,得到NFA如下:

正则表达式转成NFA是相对简单的,按照规则一步一步替代就行。

2.3.3 NFA转DFA---子集法

通过前面的转化,我们得到了一个NFA图,但是我们说过我们最终要的是DFA图,因此我们还要将NFA转成DFA。也就是要:

  1. 去除导致二义性的多重转化
  2. 去除𝜀

NFA转DFA,这里我们介绍一种常用的方法--子集法。

2.3.3.1 𝜀-闭包

这里我们先引入一个𝜀-闭包概念为子集法做铺垫。定义如下:

定义看起来很绕,其实很好理解,什么是 状态集I的𝜀-闭包(也就是𝜀-closure(I))呢?两个点:

  • 任何属于I中状态结点都在𝜀-closure(I)中
  • 从任何属于I中的状态结点经过任意条输入字符为𝜀的弧能到达的结点都在𝜀-closure(I)中

以上面我们求出的NFA为例:

试着找出其中𝜀-closure({1,2})。

根据上面解释的两个点来找:

  • 任何属于I中状态结点都在𝜀-closure(I)中,故1,2是
  • 从任何属于I中的状态结点经过任意条输入字符为𝜀的弧能到达的结点都在𝜀-closure(I)中

因为1∈{1,2}:从结点1走一条空弧到达2,从结点1走两条空弧到达4,故有 2,4

因为2∈{1,2}:从结点2走一条空弧到达4

综上:𝜀-closure(I) = {1,2,4}

2.3.3.2 子集法定义

通过前面的铺垫,我们有子集法的定义如下:

也就是:

(1)从M=初态结点S0开始,构建S = 𝜀-closure({S0})

(2)先定义一个新的运算:

因此,对任意∀a∈∑执行上述定义的新运算

(3)从上述新运算的结果中选择一个不曾出现在M中的集合的𝜀-closure令其为S。

重复(2)直到没有新结果不曾出现在M中

(4)对每一个S重新定义一个状态,每个状态之间的连接字符很容易看出,这样就可以得到一个新的DFA图(注意包含原终态的新状态都是DFA的终态)

定义给人的感觉很绕,不好理解,这是必然的,因此我们通过几个例子来帮助理解。

2.3.3.3 子集法举例

我们提供如下几个NFA图,请将其转化成DFA图。

2.3.3.3.1 练习1

根据上述算法:

(1)找到M = 初态结点1,构建S = 𝜀-closure({1}) = {1,2,4}

(2)执行新运算,过程如下:

对1∈S,1没有a输入的转换函数

对2∈S,2有a输入的转换函数,2经过输入a到达3

对4∈S,4没有a输入的转换函数

而字母表中只有a,所以算法的步骤2结束;

对于上述过程,我们有记录表格如下:

(3)从新的运算结果中选取不曾在M中出现的集合计算𝜀-closure并复制为S。这里因为新的运算结果只有{3},且没有在M中出现过,所以选择{3}计算𝜀-closure赋值为S,重复执行(2)则有:S = 𝜀-closure({3}) = {2,3,4}

执行新运算,过程如下:

对2∈S,2有a输入的转换函数,2经过输入a到达3;

对3∈S,3没有a输入的转换函数;

对4∈S,4没有a输入的转换函数。

对于上述过程,我们有记录表格如下:

此时转到(3),从新的运算结果中选取不曾在M中出现的集合计算𝜀-closure并复制为S。而我们发现全部新的运算结果{3}在M中都曾出现,也就是说没有新的状态产生。则跳出重复,执行(4)

(4)对每一个S重新定义一个状态,不妨令:

    A = {1,2,4}
    B = {2,3,4}

每个状态之间的连接字符很容易看出,这样就可以得到一个新的DFA图。也就是说我们通过可以看出从A经过输入字符a可以到达3对应的状态B,从B经过字符a可以到达3对应的状态B。我们在算法中说了,包含包含原终态的新状态都是要构建的DFA的终态。而上述结果A、B都包含了原来的终态4,因此A、B都是终态(同心圆表示)。所以我们可以作上述NFA对应的DFA如下:

2.3.3.3.2 练习2

同理,根据上述算法:

(1)找到M= 初态结点1,构建S = 𝜀-closure({1}) = {1,2,6}

(2)执行新运算,注意这里字母表包含a,b。因此我们要执行

(2-1)执行,过程如下:

对于1∈S,1没有a输入的转换函数

对于2∈S,2有a的输入转换函数,经过a到达3

对于6∈S,6有a的输入转换函数,经过a到达7

记录表格如下:

(2-2)执行,过程如下:

对于1∈S,1没有b输入的转换函数

对于2∈S,2没有b输入的转换函数

对于6∈S,6没有b输入的转换函数

记录表格如下:

(3)在新的运算结果中选出不曾在M中出现的集合{3,7},计算 𝜀-closure({3,7}) = S,记录表格如下:

转到(2)步骤去执行新运算,得到结果如下:

再经过(3)挑选出{5},计算𝜀-closure({5}) = S,再次跳转到(2)执行新的运算,得到结果如下:

新的运算结果为空集,故终止(3),进入(4)

(4)重新命名状态(注意终态的存在)

可以看出:

A经过a到达B,B经过b到达C,终态为B、C

故有DFA如下:

2.4 DFA最小化

2.4.1 为什么要最小化和什么是最小化?

通过前面的介绍,我们已经实现了由正则表达式到NFA,NFA到DFA的转化。现在我们手里拿着DFA,下一步应该是构建程序了。

确实如此,但是这里为什么要引入DFA最小化呢?这是因为前面我们通过子集法构建的DFA存在冗余的状态。举个例子,对于a* 来说,我们可以构建如下两个DFA:

显然,我们更倾向于第二个状态更少的DFA,因为这样我们可以简化我们的程序(状态越多,程序就越会复杂)。

因此我们给出最小化的定义如下:

寻找一个状态数比M少的DFA M’,使得L(M)=L(M’)

意思就是找一个状态数少的DFA,但是它表达的词法特点是不会变的。

2.4.2 状态的等价和可区别

前面我们介绍了为什么需要DFA最小化,这里为了构建最小化我们引入一些概念来辅助。

2.4.2.1 状态等价

假设s和t为M的两个状态,满足以下条件则称s和t等价:如果从状态s出发能读出某个字a而停止于终态,那么同样,从t出发也能读出a而停止于终态;反之亦然。

2.4.2.2 状态可区别

两个状态不等价,则称它们是可区别的。

2.4.3 如何构建最小化DFA?

2.4.3.1 最小化DFA思路

构建最小化DFA的思路是这样的:

把待简化的DFA状态集划分为一些不相交的子集,使得任何两个不同子集的状态是可区别的,而同一子集的任何两个状态是等价(等价也就是意味着你找不出一个字使得划分的不同子集中的状态接受后某些到达终态,而某些没有。否则你的最小化DFA就和原来的DFA不一致,这就有问题)。最后,让每个子集选出一个代表,同时消去其他状态。

2.4.3.2 最小化DFA步骤

  1. 将非终态和终态划分成不同的子集
  2. 从对于字母表中的每个字符,让非终态中的每个状态去走一遍,按他们到达的状态属于的子集进行划分,切割成不同的子集,然后对每个新生成的子集重复2
  3. 从对于字母表中的每个字符,让终态中的每个状态去走一遍,按他们到达的状态属于的子集进行划分,切割成不同的子集,然后对每个新生成的子集重复3
  4. 形成最终不同的子集,重新编号(注意:含有原初态的子集为简化DFA的初态,含有原终态的子集为简化DFA的终态)

2.4.3.3 练习

练习1

该DFA只有终态集,故首次划分{1,2,3}。

对于字符a,状态1经过a可达状态2,而状态2,3均无法识别a;

所以按其到达状态的子集 可将{1,2,3}切割成{1}、{2,3}

二次划分得到{1}、{2,3}

对于字符b,{2,3}中:

状态2经过b可达状态3,而状态3属于子集{2,3}

状态3经过b可达状态3,而状态3属于子集{2,3}

到达状态子集相同,因此b也无法切割

因此我们得到最终的简化DFA:{1}(终态)、{2,3}(终态)。作图如下:

练习2

划分初态集{0,1,2} 终态集{3,4,5,6}

对于初态集{0,1,2}:

对于字符a,

状态0接受a到达状态1,而状态1属于{0,1,2}

状态1接受a到达状态3,而状态3属于{3,4,5,6}

状态2接受a到达状态1,而状态1属于{0,1,2}

所以按其到达状态的子集 可将{0,1,2}切割成{1}、{0,2}

二次划分得到{1}、{0,2}。同理{1}已经无法切割,扫描字母表下一个字符,我们继续切割{0,2}

对于字符b,

状态0接受b到达状态2,而状态2属于{0,2}

状态2接受b到达状态5,而状态5属于{3,4,5,6}

所以按其到达状态的子集 可将{0,2}切割成{0}、{2}

三次划分得到{0}、{1}、{2}。初态集切割完成,开始切割终态{3,4,5,6}。

对于初态集{3,4,5,6}:

对于字符a:

状态3接受a到达状态3,而状态3属于{3,4,5,6}

状态4接受a到达状态6,而状态6属于{3,4,5,6}

状态5接受a到达状态6,而状态6属于{3,4,5,6}

状态6接受a到达状态3,而状态3属于{3,4,5,6}

因为其到达状态的子集都属于同一个子集无法切割,则继续用b字符切割

对于字符b:

状态3接受b到达状态4,而状态4属于{3,4,5,6}

状态4接受b到达状态5,而状态5属于{3,4,5,6}

状态5接受b到达状态5,而状态5属于{3,4,5,6}

状态6接受b到达状态4,而状态4属于{3,4,5,6}

因为其到达状态的子集都属于同一个子集无法切割

因此我们得到最终的子集:A= {0}(初态)、D = {2}、C = {1}、B = {3,4,5,6} (终态)

故DFA如下:

2.4 程序实现DFA

经过大量的介绍铺垫,我们终于来到程序员最感兴趣的地方,那就是用程序去实现我们千辛万苦得到的DFA,从而识别出给定字符中特定的Token。这里主要有三种:状态转换图实现、双层case实现和表驱动实现。这里的重点是后两种。

2.4.1 状态转换图实现

所谓的状态转换图,其实就是直接根据DFA就开始编写程序。其思想就是大量的if 和while来实现每一种情况判断。

比如对于上面的DFA,我们有伪代码如下 :

    //初态开始
    if  the next character is a letter then
        advance the input;//从缓冲区中取出字符,下面的同是如此
        while the next character is a letter or a digit do       
            advance the input; 
        end while;
        accept; 
    else
         { error or other cases }
    end if; 

状态转换图实现如果遇到状态较多的情况实现较为复杂,容易出错。

2.4.2 双层case实现

双层case就是用一个变量state来记录当前状态 ,外层case关注状态转换 ,内层case关注输入字符 其嵌套深度固定为2。

对上述DFA,伪代码书写思路:

(1)初始化状态state为初态

    state = 1

(2)设置一个循环,当状态为除终态以外的正常状态时一直循环(终态或者错误状态会跳出)

    while state = 1 or 2 do:
        双层case
    end while
    if state = 3 then accept else error

(3)在循环中双层嵌套case,外部case关注状态,内部case关注输入字符

    while state = 1 or 2 do:
        case state of:
        1:case input character of:
            letter:advance the input;//从缓冲区读取字符
                state = 2;
            other:
                state = error;
            end case;
        2:case input character of:
            letter,digit:advance the input;
                state = 2;
            other:
                state = 3;
            end case;
        end case;
    end while
    if state = 3 then accept else error

2.4.3 表驱动实现

表驱动法步骤如下(仍以上述DFA举例):

  • 将状态图转换成一张二维表:

  • 考虑:
    • 二维数组T记录表格中的转换
    • 二维数组Advance记录是否需要更新输入,考虑字符回退问题
    • 一维数组Accept表示是否为接受状态
  • 程序如下:
    state = 1
    ch = next input character
    while not Accept[state] and not Error[state] do:
        newstate = T[state,ch]//这里为什么不直接赋值 state,因为我们还要利用原来的state来判断
                              //是否回退字符
        if Advance[state,ch]:
            ch = next input character;
        state = newstate
    end while
    if Accept[state] then accept;

2.5 小结

从自然语言到正则表达式、正则表达式到NFA、从NFA到DFA、DFA化简、三种程序实现,我们从头到尾梳理了手工构造词法分析器所用到的理论知识和相关流程,但是注意所有的简述只是针对一个词法特点的,那如何结合多个词法特点的呢?也就是说我们现在已经很轻松地可以构建一个识别C语言标识符的词法扫描器,那如何加入识别C语言的常量、运算符呢?很简单,流程如下:

(1)对每一个词法特点构建各自的NFA

(2)构建一个新的初态X,用𝜀将各个NFA和新的初态X连接起来形成一个新的NFA

(3)NFA转DFA,然后最小化DFA

(4)编码写程序