PL真有意思(二):程序设计语言语法

前言

虽然标题是程序语言的语法,但是讲的是对词法和语法的解析,其实关于这个前面那个写编译器系列的描述会更清楚,有关语言语法的部分应该是穿插在整个设计当中的,也看语言设计者的心情了

和英语汉语这些自然语言不一样,计算机语言必须是精确的,它们的语法和语义都必须保证没有歧义,这当然也让语法分析更加简单

所以对于编译器一项很重要的任务就是时别程序设计语言的结构规则,要完成这个目标就需要两个要求:

  • 完成对语法规则的描述
  • 确定给定程序是否按照这些规则构造起来,也就是符合语法规则

第一个要求主要由正则表达式和上下文无关文法来描述完成,而第二个要求就是由编译器来完成,也就是语法分析了

描述语法:正则表达式和上下文无关语法

对于词法,都可以用三种规则描述出来:

  1. 拼接
  2. 选择
  3. Kleene(也就是重复任意多次)

比如一个整数常量就可以是多个数字重复任意多次,也叫做正则语言。如果对于一个字符串,我们再加入递归定义即可以描述整个语法,就可以称作上下文无关语法

单词正则表达式

对于程序语言,单词的类型不外乎关键字、标识符、符合和各种类型的常量

对于整数常量就可以用这样的正则表达式来表示

integer -> digit digit* digit -> 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9

上下文无关文法

一般正则表达式只适用于描述单词,因为正则表达式无法描述嵌套结构,一般正则表达式的实现都是用有限状态自动机,之前用Python实现了一个简单的正则表达式引擎也是这样,但是对于匹配任意深度的嵌套结构就需要有一个任意大的状态机,显然不符合。而定义嵌套结构对于描述语法非常有用,所以就有了上下文无关文法

expr := id | number | - expr | ( expr ) | expr or expr

op := + | - | * | /

对于上下文无关文法,每条规则叫做一个产生式,产生式左部的符合称为非终结符,而右部则是多个终结符或者非终结符,最后所有规则都会推到至终结符上,而终结符就是正则表达式定义的单词

推导和语法树

一个正确的上下文无关文法,就可以指导我们如何生成一个合乎语法的终结符串

最简单的就是从开始符号开始,用这个产生式的右部取代开始符合,再从得到的串选择一个非终结符继续进行推导,直到没有剩下的非终结符,这个过程就像递归构造一个树的过程

1
2
3
4
5
6
7
expr := expr op expr
:= expr op id
:= expr + id
:= expr op expr + id
:= expr op id + id
:= expr * id + id
:= id * id + id

但是对于给定的上下文语法有可能会推导出不止一颗语法分析树,我们就说这个上下文语法是存在歧义性的。所以对于上面的上下文无关语法还有更好的文法

扫描

扫描也就是词法分析,词法分析完全可以不需要什么正则表达式、自动机什么的,徒手撸出来,现在业界为了更好的生成错误信息,应该很多也是手工的词法分析器

手工的词法分析器,无非就是一直读入字符,到能判断出它的token在送入语法分析器

有限状态自动机

使用有限状态机的词法分析一般都是这样的几个步骤

  • 给出词法的正则表达式

  • 将正则表达式转换为非确定有限自动机(NFA)

其实对于任意的正则表达式都可以用拼接、选择和Kleene闭包来表示

而同样的,有限自动机也可以通过这三种方式来表示,图就不画了,这个在之前写Python正则表达式引擎的文章里都画过了(溜了

  • 将NFA转换为确定性有限状态自动机(DFA)

将NFA转换到DFA可以采用的是子集构造法,主要思想就是,在读入给定输入之后所到达的DFA状态,表示的是原来NFA读入同样输入之后可能澳大的所有状态

  • 最小化DFA

对于最小化DFA的主要思想是,我们把DFA所有状态分为两个等价类,终止态状态和非终止状态。然后我们就反复搜索等价类X和输入符合c,使得当给定C作为输入时,X的状态能转换到位于k>1个不同等价类中的状态。之后我们就把X划分为k个类,使得类中所有转台对于C都会转移到同一个老类的成员。直到无法再按这种方式找到划分的类时,我们就完成了

这四个步骤在之前的写的正则表达式引擎中都完成了,在那三篇文章里会更详细一点

语法分析

一般语法分析器的输入是token流,而输出是一颗语法分析树。其中分析方法一般可以分为自上而下和自下而上两类,这些类中最重要的两个分别称为LL和LR

LL表示从左向右,最左推导,LR表示从左向右,最右推导。这两类文法都是从左到右的顺序读取输入,然后语法分析器试图找出输入的推导结果

自上而下的方式

一般自上而下的语法分析器比较符合之前的推导方法,从根节点开始像叶节点反复的递归推导,直到当前的叶节点都是终结符

  • 递归下降

递归下降很符合上面说的从根节点出发进行推导,一般用于一些相对简单一些的语言

1
2
3
4
5
read A
read B
sum := A + B
write sum
write sum / 2

比如对于这个程序的递归下降,语法分析器一开始调用program函数,在读入第一个单词read后,program将调用stmt_list,再接着调用stmt才真正开始匹配read A。以这种方式继续下去,语法分析器执行路径将追溯出语法分析树的从左向右、自上而下的遍历

  • 表格驱动的LL自上而下

表格驱动的LL是基于一个语法分析表格和一个栈

分析流程是

  1. 初始化一个栈
  2. 将开始符号压入栈
  3. 弹出栈顶,然后根据栈顶的符号和当前的输入符号查表
  4. 如果弹出的是非终结符,将会继续查表来确定下一个压入栈中的产生式
  5. 如果是终结符将进行匹配

预测集合

从上面可以看出来最重要的就是那个语法分析表格了,语法分析表格其实就是根据当前输入字符对下一个产生式的预测,这里就要用到一个概念:预测集合,也就是First和Follow集合。这个在之前写编译器系列讲的比较详细,在这里就不写了

当然LL语法也会有很多处理不了的文法,所以也才会有其它的语法分析方法

自下而上的方式

在实践中,自下而上的语法分析都是表格驱动的,这种分析器在一个栈中保存所有部分完成的子树的根。当它从扫描器中得到一个新的单词时,就会将这个单词移入栈。当它发现位于栈顶的若干符号组成一个右部时,它就会将这些符号归约到对应的左部。

一个自底向上的语法分析过程对应为一个输入串构造语法分析书的过程,它从叶子节点开始,通过shift和reduce操作逐渐向上到达根节点

自底向上的语法分析需要一个堆栈来存放解析的符号,例如对于如下语法:

1
2
3
4
5
0.	statement -> expr
1. expr -> expr + factor
2. | factor
3. factor -> ( expr )
4. | NUM

来解析1+2

stack input
null 1 + 2
NUM + 2 开始读入一个字符,并把对应的token放入解析堆栈,称为shift操作
factor + 2 根据语法推导式,factor -> NUM,将NUM出栈,factor入栈,这个操作称为reduce
expr + 2 这里继续做reduce操作,但是由于语法推导式有两个产生式,所以需要向前看一个符合才能判断是进行shift还是reduce,也就是语法解析的LA
expr + 2 shift操作
expr + NUM null shift操作
expr + factor null 根据fator的产生式进行reduce
expr null reduce操作
statement null reduce操作
此时规约到开始符号,并且输入串也为空,代表语法解析成功

有限状态自动机的构建

1
2
3
4
5
6
7
0.	s -> e
1. e -> e + t
2. e -> t
3. t -> t * f
4. t -> f
5. f -> ( e )
6. f -> NUM
  • 对起始推导式做闭包操作

先在起始产生式->右边加上一个.

1
s -> .e

对.右边的符号做闭包操作,也就是说如果 . 右边的符号是一个非终结符,那么肯定有某个表达式,->左边是该非终结符,把这些表达式添加进来

1
2
3
s -> . e
e -> . e + t
e -> . t

对新添加进来的推导式反复重复这个操作,直到所有推导式->右边是非终结符的那个所在推导式都引入

  • 对引入的产生式进行分区

把 . 右边拥有相同非终结符的表达式划入一个分区,比如

1
2
e -> t .
t -> t . * f

就作为同一个分区。最后把每个分区中的表达式中的 . 右移动一位,形成新的状态节点

  • 对所有分区节点构建跳转关系

根据每个节点 . 左边的符号来判断输入什么字符来跳入该节点

比如, . 左边的符号是 t, 所以当状态机处于状态0时,输入时 t 时, 跳转到状态1。

  • 对所有新生成的节点重复构建

最后对每个新生成的节点进行重复的构建,直到完成所有所有的状态节点的构建和跳转

小结

这一篇主要是提了对词法和语法的分析过程,因为想要结合语言设计和实践,更详细的应该去看前面的写一个编译器系列