Go 编译原理系列 2(词法分析 & 语法分析基础)
前言
获取 pdf 版,请评论区留言
在前一篇编译原理的文章中,并没有介绍词法分析是如何将源文件中的字符转换成一个个的词法单元,中间用到了哪些技术或工具。也没有详细的介绍语法分析阶段中的一些常见的文法及语法分析方式。所以,本文你可以了解到:
词法分析器是如何将我们的源文件中的字符翻译成词法单元的(不确定有穷状态机 &确定有穷状态机)
有哪些常见的词法分析器?他们是如何工作的?
上下文无关文法
Go 语言中的一些文法的规则
抽象语法树的生成
语法分析的一些方法(自顶向下、自底向上)
<aside>💡 Tips:下边的内容可能会比较抽象,特别是语法分析基础部分涉及到的文法以及处理文法的两种方法。但是我都会通过表格或者图的方式,将每一步都描述清楚,相信您坚持看完,一定会有所收获</aside>
词法分析基础
Token
在前一篇编译原理的文章中可以知道,词法分析的任务是从左往右逐字符扫描源程序内容,识别出各个单词,确定单词的类型。将识别出的单词转换成统一的机内表示———词法单元(token)形式
比如识别出这些单词是关键字、标识符、常量、界限符、还是运算符等。因为在任何一门编程语言中,这些类型是可以枚举的,所以一般会给定义好,比如 Go 语言中用_Name
来表示标识符,用_Operator
来表示操作符
像_Name
、_Operator
就是我们常说的 Token,最终被编译的源文件中的内容,都会被词法分析器解析成这样的 Token 形式
那词法解析器是如何识别出源文件中各个单词,并判断单词是关键字?还是运算符?还是界限符的?这就用到了确定有穷自动机
不确定有穷自动机 & 确定有穷自动机
这里不详细介绍有穷自动机里边的字母表、句子、符号等抽象的概念,主要弄明白它是怎么工作的即可,它的实现不是本文的重点,对有穷自动机感兴趣的可以看《编译原理》的第三章
有穷自动机分为两类,即不确定的有穷自动机(NFA)和确定的有穷自动机(DFA)。下边依次介绍这两个有穷自动机是如何工作的
不确定有穷自动机(NFA)
其实我们用一门编程语言编写的代码,可以看做是一个长字符串,词法分析过程要做的就是识别出这个长字符串中,哪些是关键字、哪些是运算符、哪些是标识符等
如果让我们来做这样一件事情,最容易想到的就是用正则表达式。其实词法分析器进行解析的过程,也是利用正则,通过正则将长字符串进行分割,分割出来的字符串(可能是标识符、可能是运算符等)去匹配到相应的 token
假设说有一个正则表达式(a|b)*abb,然后给你一个字符串,判断这个字符串是否满足这个正则表达式?
如果你对回溯算法比较熟悉的话,我们知道它可以通过简单的回溯算法来实现。但是这里用到另一种方法来实现,就是不确定有穷自动机(NFA)
根据前边给的正则表达式,可以画出如下的有穷自动机:
红色圆中的数字表示的是状态
当在 0 这个状态的时候遇到字符 a 或者 b,则状态迁移到本身
0 这个状态遇到 a 也可以迁移到状态 1
1 这个状态遇到 b,则迁移到状态 2
2 这个状态遇到 b,则迁移到状态 3(3 是最终状态)
该状态机一共有四个状态,其中 0、1、2 这三个状态是一个单层的圆表示的,3 这个状态是用两层的圆表示的。单层的圆表示的是状态机的中间状态,双层的圆表示的是状态机的最终状态。箭头和其上方的字符,表示的是每个状态遇到不同的输入,迁移到另一个状态
你可以把以下几个字符串作为上边这个状态机的输入,看是否能够走到最终状态,如果能走到,说明可以匹配,如果不能走到,说明不能匹配
从上边可以看出来,NFA 能够解决匹配源文件中字符串目的,这样看好像我们只要针对标识符、关键字、常量等写出相应的正则表达式,然后通过自动机就能分割出源文件中各个字符串,然后匹配相应的 token
但是它有一个缺陷,比如拿 abb 去上边的状态机去匹配,0 状态遇到 a 可能还是 0 这个状态,0 状态再遇到 0,还是 0 这个状态,再遇到 b 还是 0 这个状态,这样它又不能匹配了,实际上 abb 是能满足(a|b)*abb 这个正则表达式的。原因就是在 0 状态的时候遇到 a,它转移的状态是不确定的,所以它叫不确定有穷自动机
为了解决上边的问题,就出现了确定有穷自动机
确定有穷自动机(DFA)
还是上边那个正则表达式:(a|b)*abb,画出它的有穷自动机就是下边这样
0 这个状态遇到 a,则迁移到状态 1
0 这个状态遇到 b,则还是迁移到本身状态 0
1 这个状态遇到 a,则还是迁移到本身状态 1
1 这个状态遇到 b,则迁移到状态 2
2 这个状态遇到 a,则迁移到状态 1
2 这个状态遇到 b,则迁移到状态 3(3 是最终状态)
3 这个状态遇到 a,则迁移到 1 状态
3 这个状态遇到 b,则迁移到 0 状态
0、1、2 是中间状态,3 是最终状态。与不确定有穷自动机的区别就是,它的每一个状态遇到的输入,都会有一个确定的状态迁移。你可以用前边给的字符串来验证一下这个有穷自动机
这样通过 DFA 就可以解决我们的问题。但是,如果这样的话,要对一个源文件进行词法分析,我们就需要写很多的正则表达式,并且需要手动的为每一个正则表达式写有穷状态机的实现
为了解决这个问题,就出现了很多的词法解析器的工具,能让我们避免手动的去实现一个有穷自动机,下边就简单介绍两个常见的词法解析器的工具
词法分词器
re2c
我们可以编写符合 re2c 规则的文件,然后通过 re2c 生成.c 的文件,再去编译执行这个.c 文件
如果你没有安装re2c,需要先安装一下(点击下载)。下载完成之后,安装过程
下边就是编写一个 re2c 的源文件。假设我要识别一个数字是二进制的还是八进制的还是十六进制的,看一下用 re2c 是如何编写的(re2c 的源文件是.l 的文件)
说明:如果你将代码粘过去不能正常使用,尝试把我写的注释去掉
然后去处理这个.l 文件
你也可以试一下其它的进制数,都可以正常得到它是哪种进制的数
现在你可以打开刚才我们生产的 integer.c 文件,它的内容如下:
这段代码其实就是实现了上边说的确定有穷自动机。代码很简单,你可以拿示例中的 0b10 作为这段代码的输入,看一下通过这个是否能得到它是 binary
所以,我们其实只需提供一些正则表达式,re2c 就可以帮我们实现一个确定有穷自动机(DFA)。这样就可以轻松的做一些词法解析的事情,我们就提供正则表达式即可
lex
lex 这个词法分析器生成工具在《编译原理》这本书的第三章有介绍,我这里仅简单的介绍一下,更详细的内容可以去对应章节看
lex 跟 re2c 原理其实是一样的,按照 lex 的规则编写它的源代码(也是以.l 结尾的),然后将其生成.c 文件,生成的.c 文件其实就是将.l 文件中编写的正则匹配转换成有穷状态机的 C 语言实现
我这里参考王晶大佬的《面向信仰编程》中编译原理相关博客中的一段 lex 代码,它通过这段代码可以对简单的 go 源文件进行词法解析
这里边其实就是定义了一些关键字的正则,去匹配 go 源文件中的关键字、数字、标识符等。同样通过 lex 命令将上边这个.l 文件编译成.c 文件,.c 文件其实就是实现了一个有穷的状态机(根据.l 文件中提供的正则),能够匹配.l 中定义的一些符号
假设有这么一段 go 的代码
经过词法分析器处理以后就会变成这个样子
有了以上这些基础的东西,后边看 Go 的词法分析源码的时候就轻松许多。因为 Go 语言的词法分析和语法分析其实是连在一起的,所以这里顺便也整理一下语法分析,需要了解哪些基础的东西能否帮助我们去看 Go 的语法分析部分的源码
语法分析基础
文法
在设计语言时,每种程序设计语言都有一组精确的规则来描述程序的语法结构。比如在 C 语言中,一个程序由多个函数组成,一个函数由声明和语句生成,一个语句由表达式组成等等。程序设计语言构造的语法可以使用上下文无关文法或 BNF(巴科斯范式)表示法来描
文法给出了一个程序设计语言的精确易懂的语法规约
对于某些类型的文法,我们可以自动地构造出高效的语法分析器,它能够确定一个源程序的语法结构 3.一个正确设计的文法给出了一个语言的结构。该结构有助于把源程序翻译为正确的目标代,也有助于检测错误
这里简单的再描述一下语法分析器在编译过程中扮演的角色和作用(在上一篇文章中有详细描述),方便后边的理解
语法分析器从词法分析器获得一个由词法单元组成的串,并验证这个串可以由源语言的文法生成。对于良构的程序,语法分析器构造出一棵语法分析树,并把它传递给编译器的其他部分进一步处理。实际上,并不需要显式地构造岀一棵语法分析树,因为,对源程序的检查和翻译动作可以和语法分析过程交替完成。因此语法分析器和前端的其他部分可以用一个模块来实现
处理文法的语法分析器大体上可以分为三种类型:通用的、自顶向下的和自底向上的。像 Cocke-Younger-Kasami 算法和 Earley 算法这样的通用语法分析方法可以对任意文法进行语法分析。但是些通用方法效率很低,不能用于编译器产品
下边详细的介绍一下上下文无关文法以及两种处理文法的类型
上下文无关的文法
下边的描述可能很抽象,没关系,看下边的示例就明白了(我们要理解,越通用的东西往往就越抽象)
一个上下文无关文法(简称文法)由终结符号、非终结符号、一个开始符号和一组产生式组成
终结符号是组成串的基本符号。通常我们所说的词法单元,你就可以把它理解成是终结符号,比如 if、for、)、(等,都是终结符号
非终结符号是表示串的集合的语法变量。非终结号表示的串集合用于定义由文法生成的语言。非终结符号给出了语言的层次结构,而这种层次结构是语法分析和翻译的关键
在一个文法中,某个非终结符号被指定为开始符号。这个个符号表示的串集合就是这个文法生成的语言。按照惯例,先列出开始符号的产生式
一个文法的产生式描述了将终结符号和非终结符号组合成串的方法。每个产生式由下列元素组成:a. 一个被称为产生式头或左部的非终结符号。这个产生式定义了这个头所代表的串集合的一部分 b. 方向向右的箭头 c.一个由零个或多个终结符号与非终结符号组成的产生式体或右部。产生式体中的成分描述了产生式头上的非终结符号所对应的串的某种构造方法
举个比较简单的示例,假设定义了下边这样的文法(下边的|是或的意思)
在上边定义的文法中,A、B、C 是非终结符号,c、a、r、n、y 是终结符号。上边的每一行都是一个产生式,A 也是开始符号。上边这一组产生式再加上非终结符、终结符、开始符号,就组成了一个上下文无关文法。上边个文法就可以匹配 can、arn、aan、cry、ray 等等
因为了解上边这些是为了后边看 Go 语法分析的实现,我这里就从 Go 的语法解析器中拿到了它的文法,如果你明白了上边提到的文法相关内容,就可以轻松看懂 Go 的语法解析用到的文法(Go 的语法分析源码在:src/cmd/compile/internal/syntax/parser.go)
因为每一个 Go 的源文件最终都会被解析成一个抽象语法树,语法树的最顶层的结构或开始符号,都是 SourceFile。SourceFile 这个产生式含义也很简单
首先是有一个包(package)的定义
然后是可选的导入声明(import) 。大括号是可选的意思,关于 Go 更多的文法,你可以去这里了解,非常详细,对 Go 中的每一种类型,都有详细的介绍
最后就是一个可选的顶层声明(TopLevelDecl)
上边并没有列出 Go 全部的产生式,比如函数和方法的产生式这里没有列,它会更复杂一些。这里以 PackageClause 这个产生式为例来说一下它的含义
知道了一门计算机语言的语法分析是通过定义好的文法来解析源文件中的语法的,那语法解析器是如何实现根据文法来进行语法解析的呢?这就需要用到一些算法,就是在上边文法部分提到的那三种,通用的、自顶向下的和自底向上的
抽象语法树
其实在语法分析阶段,不仅需要判断给定的字符串是不是满足规定的文法,还需要分析出这些字符串
符合哪些结构,也就是说,分析出这个字符串是怎么通过起始符开始,通过产生式匹配出来的,并根
据这个产生的过程生成语法树
假设有这样一个文法
比如"我写 bug"这句话,当我们知道了主谓宾分别是哪个词,才能理解这句话的含义。下边是它的语法树
再比如下边这个能匹配表达式的文法
假设有这样一个表达式 6 + ( 60 - 6 )。按照上边的文法,我们可以得到如下的推倒过程
用语法分析树来表示就是:
去掉里边的一些多余的节点,进一步的浓缩,就可以得到如下抽象语法树。对于这种树状结构,可以采用递归的方式加以分析,从而生成目标代码
看一个不一样的,假设有这么一个表达式:6+20*3,还是通过上边的文法对它进行解析,你会发现它可以由两种不同的方式推导出来
这个其实是语法中的歧义,就是指对于一个符合语法结构的字符串,它可以由两种不同的方式被推导出来。相反,如果有一个文法,任何一个字符串的推导方式都是唯一的,那这个文法就是没有歧义的。那很显然我们的程序语言使用的文法,必须是没有歧义的
要想消除歧义,我们可以通过改写文法来实现,将有歧义的产生式改写成无歧义的。但是,这种改写不仅非常困难,而且往往改写后的产生式和原产生式相差很大,可读性也比较差
而另一种方式就是通过优先级来实现,不需要改写产生式,当推导过程中出现歧义的时候,利用符号的优先级来选择需要的推导方式,编程语言中基本都是用的这种方式,我们后边要了解的 Go 的语法分析部分,也是用的这种方式,这里不做更加详细的介绍,感兴趣的可以看一下 Go 的语法解析这部分(位置:src/cmd/compile/internal/syntax/parser.go → func fileOrNil)
对于一个给定的文法 ,从中推导出一个任意的字符串是比较简单和直接的,但需要推导出指定的字符串或者给定一个字符串,要分析出它是怎么从起始符号开始推导得到的,就没那么简单了,这就是语法分析的任务。分析可以采用 自顶向下分析 或 自底向上分析 两种方法,因为这两种分析方法涉及的内容挺多的,我这里先简单的提一些对我们后边研究 Go 语法分析源码有帮助的部分。关于自顶向下分析 和 自底向上分析更详细的内容,可以看《编译原理》的第三章
语法分析方法
通用的语法分析方法
Cocke-Younger-Kasami算法和Earley算法这样的通用语法分析方法可以对任意文法进行语法分析。但是些通用方法效率很低,不能用于编译器产品
这里不详细介绍这两种通用的语法分析方法,感性趣的可自行点击了解
自顶向下的语法分析方法
自顶向下分析就是从起始符号开始,不断的挑选出合适的产生式,将一个中间字符串中的非终结符展开,最终展开到给定的字符串
以下边这个文法为例,来分析 code 这个字符串
开始时,起始符号只有一个产生式:S –> AB,所以只能选择这一个产生式,用这个产生式的右边部分代替 S,就得到一个中间字符串 AB
对于这个中间字符串,从它的起始符号 A 开始展开,A 展开可以得到三个产生式:A → oA; A→ cA; A→ ε。我们可以拿我们要分析的字符串 code 来和这个中间字符串 AB 对比一下,发现只能选择 A → cA 这个产生式,否则无法根据这个中间字符串推导出 code。所以这里选择将中间句子中的 A,替换成 cA,就得到了中间句子 cAB
然后继续尝试将 cAB 中的 A 展开。发现只能选择 A → oA 这个产生式
继续对 A 进行展开,发现 A 只能选择 A → ε这个产生式(否则是推导不出来 code 的)
然后就是展开非终结符 B,B 展开可以得到两个产生式:B → dB; B → e。按照上边相同的方法,可以得到:
以上就是自顶向下的语法分析过程,你现在再回过头去看 上下文无关文法 部分提到的 Go 语言中的文法,是不是就明白了 Go 的语法分析过程。如果你去看 Go 的语法分析源码(位置:src/cmd/compile/internal/syntax/parser.go → func fileOrNil),你会发现 Go 用的就是这种自顶向下的语法分析方法
自底向上的语法分析方法
自底向上分析的分析方法,是从给定的字符串开始,然后选择合适的产生式,将中间字符串中的子串折叠为非终结符,最终折叠到起始符号
假设有如下文法,我们要分析的字符串是: aaab
首先从左边第一个字符 a 开始,对比语法中的所有产生式,发现没有产生式的右边可以完全匹配。但经过观察和思考发现:可以尝试在 aaab 的最前面插入一个空句子 ε ,接下来可以应用 A -> ε ,之后再应用 A -> Aa, ... 。因此先插入空句子,得到中间句子 εaaab
此时,中间句子的最左边 ε 可以和产生式 A -> ε 匹配。用这个产生式,将 ε 折叠为 A ,得到 Aaaab
由中间字符串 Aaaab 可以发现,它的最前面的 Aa 可以和 A -> Aa 匹配上,且只能和这个产生式匹配上,因此应用此产生式,将 Aa 折叠为 A ,得到 Aaab
按照上边相同的步骤,将中间字符的子串折叠为非终结符,最终折叠到起始符号 S ,过程如下:
以上就是自底向上的语法分析大致过程,这里主要是为了方便理解,没有涉及到更多的东西。但是已经够后边看 Go 语法分析源码使用了
语法分析过程中的回溯和歧义
在前文中举的例子,可能比较特别,因为在推导的过程中,每一步都只有唯一的一个产生式满足条件。但是在实际的分析过程中,我们可能会遇到如下两种情况:
所有产生式都不可应用
有多个产生式可以应用
如果出现第二种情况,往往我们需要采用回溯来进行处理。先尝试选择第一个满足条件的产生式,如果能推导到目标字符串,则表示该产生式是可用的,如果在推导过程中遇到了第一种情况,则回溯到刚才那个地方,选择另一个满足条件的产生式
如果尝试完所有的产生式之后,都遇到了第一种情况,则表示该字符串不满足语法要求。如果有多条产生式都能推导出目标字符串,则说明语法有歧义
回溯分析一般都非常慢,因此一般通过精心构造语法来避免回溯
语法分析器生成工具
常见的语法分析器生成工具有Yacc、bison,它的原理其实跟上边介绍的词法分析器差不多。就是按照对应语法分析器工具的语法编写源文件(文件后缀是.y),(其实就是在源文件代码里边提供一些文法,词法分析器工具,也是我们提供了正则)然后通过使用对应工具的命令将.y 文件生成成.c 文件
生成的这个.c 文件里边,其实就根据我们提供的文法规则生成一个语法分析器的代码,我们只需要编译执行这个.c 文件即可
因为本篇文章已经很长了,这里就不举例了,关于这两个语法分析器生成工具,你可以点击我上边的链接,进入官网下载安装,自己尝试一下
过程是完全和上边介绍的词法分析器工具一样的
总结
本文主要是分享了词法分析中的不确定有穷自动机和确定有穷自动机,并且展示了词法分析中常用的词法分析器是如何使用的。然后是语法分析中涉及到的文法、抽象语法树的生成、以及语法解析的两种解析方式
感谢阅读,希望看完能有所收获
版权声明: 本文为 InfoQ 作者【书旅】的原创文章。
原文链接:【http://xie.infoq.cn/article/fc011eaceff33ce9a8856f740】。文章转载请联系作者。
评论