从零开始的编译器前端之Monadic Parsing
这是第二篇文章。这篇文章试图介绍一下Monadic Parsing的基本概念。
主要的内容来自于Programming in Haskell,虽然也加上了一些自己的个人理解。
提到编译器,就不可避免的要提到Parser了。那么,什么是Parser呢?
抽象而言,Parser是一种输入字符串输出树状的数据结构的程序。一段含有特定的结构的文本(某个语言里面的程序的代码)被解析后生成对应的抽象语法树(AST)。这个AST可以表达出许多在原始文本中没有表现出来的文法结构,以方便编译器进行下一步的操作。
例如,2+3*4
在解析后生成的AST,明确的表示出*
和+
作为具有两个操作元的操作符,同时*
具有更高的优先级。
Parser在计算机科学里面是一种很常见的东西,不仅仅是用于实现编程语言的编译器,也可以用来解析一些相对简单的文法。把文本处理过程抽离成生成AST的Parser和对AST进行处理的业务逻辑,虽然看起来比直接处理字符串或者正则要麻烦许多,但是通常有更好的可读性、维护性而且也能较为简单地处理(在字符串处理中)需要很复杂的算法的问题。在OO中,Parser也是一种设计模式的重要构成部分。
Parser作为函数
自然而然的,在Haskell中,Parser可以看作是一种函数:接收字符串作为参数,生成一个树状结构。假设具有生成的AST的结构AST
,那么则有:
1 |
|
通常来说,并不一定希望Parser消费整个输入,比如说一个数字紧跟着一串词汇的情况,会希望先用对应的Parser处理了这些数字,再交由下一个Parser去处理剩下的部分。可以使用Tuple来同时返回「剩下的文本」和当前的AST:
1 |
|
除此之外,一个Parser也有可能失败,通常用一个数组来表示是否失败的状态:如果解析成功,则返回一个只有这个AST的数组,否则返回一个空数组[1]:
1 |
|
最后,不同的Parser会返回不同的类型的AST,或者,对应的说,不同的值。所以通常还会把具体的返回类型抽象出来,用一个泛型参数表示:
1 |
|
总结起来就是,A数据类型的Parser类型是一个输入字符串后,输出一个列表,其中每一列都是一组包含一个返回类型A和一个字符串作为输出的函数。
看起来Parser和State
Monad(State -> (a, State)
)也很像,不同之处在于这里操作的不是一个状态而是一个字符串[2],除此之外,Parser是可以失败的(返回空列表)。
基本定义
首先用newtype
来声明代表Parser函数的数据结构:
1 |
|
parse
函数在给定一个Parser和输入字符串的情况下输出解析出的AST和剩下的字符串[3]。
item
是第一个基本组合子(其实也许并不算是?),如果当前的字符串是个空字符串,那么它会失败,否则会输出第一个字符作为返回值:
1 |
|
所有其他的组合子都是以此构建起来的。item
的行为如下:
1 |
|
Parser的顺序组合
顺序组合起来的Parser使用do-notation表示。为了能够使用do-notation,首先要把Parser变成Functor、Applicative和Monad的instance。
首先是Functor。
1 |
|
fmap
的作用是(在解析成功的时候)把作为参数的函数应用到Parser的返回值上,否则把失败值向外扩散,从一个给定的Parser构造一个这样的新的Parser。
例如:
1 |
|
然后是Applicative。
1 |
|
pure
把一个单独的值转换为「永远成功并且把这个值作为返回值」的Parser,这个Parser不消耗任何输入。例如:
1 |
|
<*>
把一个关于函数的Parser应用到另一个关于值的Parser上,返回新的Parser,这个新的Parser的作用是把这个函数应用到值上,并且只有所有部分都成功的时候才成功。一个例子是消耗三个字符,返回第一个和最后一个字符,忽略中间字符的Parser,可以用Applicative
style来定义:
1 |
|
使用如下[4]:
1 |
|
最后是Monad:
1 |
|
换句话说,p >>= f
这个Parser只在parse p input
这个函数失败的时候失败,否则会把函数f
应用到返回值v
上,得到另外一个Parser
f v
,然后应用这个Parser到out
字符串上,获得最后输出的结果。
这时候就可以用do-notation来重写连续进行的Parser了,例如前面的three
函数:
1 |
|
一般来说do-notation和Applicative style是等价的。
分支
do-notation用顺序的方式组合不同的Parser,如果一个Parser成功,那么剩余的输出会作为下一个Parser的输入,以此类推。另一个常见的情况是把Parser并联起来,如果一个Parser失败了,那么用同样的输入去匹配另一个Parser。
这种choice的思路并不是Parser独有的,也可以扩大到许多其他的Applicative类型,在Haskell里面,这种行为特征被叫做Alternative。
1 |
|
这里empty
表示一个失败了的Alternative,<|>
则是choice运算符。Alternative也需要满足以下的法则:
empty <|> p = p
p <|> empty = p
p <|> (q <|> r) = (p <|> q) <|> r
让Parser也成为Alternative的:
1 |
|
这里empty
是一个无论输入如何永远失败的Parser,<|>
会在第一个Parser成功的时候返回它的解析结果,否则将会使用第二个Parser。例如:
1 |
|
其他的组合子
有了item
和empty
,再和顺序组合还有分支选择结合起来,可以定义许多有用的组合子了。首先定义一个判断单个字符是否满足条件的组合子sat
:
1 |
|
使用sat
可以进一步定义更多的组合子:
1 |
|
也可以定义一个消耗指定的字符串并且返回这个字符串的Parser:
1 |
|
空字符串会始终被解析成功。字符串非空的时候,只有整个字符串都被消费成功的时候才会解析成功:
1 |
|
接下来的两个组合子some
和many
,应用Parser尽可能多次。不同的是many
对应0或多次,some
则需要至少一次成功的解析。例如:
1 |
|
不过其实并不需要专门定义这两个组合子,因为已经在标准库的Alternative typeclass里面被定义过了,注意这两个函数的定义使用了互递归:
1 |
|
使用many
和some
可以进一步定义更多的组合子,例如「变量名」(小写字母开头紧接若干字母/数字组合)、自然数、空格……
1 |
|
1 |
|
这里space
用到的()
通常被用来表示并不关心返回的值是什么(其实就是Unit)。
进一步的,还可以定义出代表正负整数的组合子:
1 |
|
处理空格
实际使用中的Parser往往都需要处理空格。例如1+2
和1 + 2
。为了处理这种情况,需要再定义一个基本组合子来丢弃所有的空格,又叫「tokenization」[5]:
1 |
|
用token
就可以定义出具有语义的组合子:
1 |
|
一个例子是一个解析非空自然数的列表的Parser,忽略所有的空格:
1 |
|
运行如下:
1 |
|
小结
简单的介绍了一下Monadic Parsing的基本原理。
其实这里提到的组合子也还是有一些局限性的,比如说对空格的处理很弱,也并不支持对于/* */
或者//
这样的注释写法。
比较好的写法是把Tokenization抽离出来,用专门的Lexer来处理这些有关字符串、空格和注释的事情,然后再把输出的[Token]
流传给Parser进行解析处理。相比之下,许多之前很难实现甚至在ANTLR里都无法实现的功能,也会显得很简单了。之后会专门提到Lexer的部分。
需要注意的是,即使Monadic Parsing允许一定程度上的回溯,但是设计不好的语法(比如说有循环)还是会导致无法解析,比如说卡死在死循环里。所以实际使用的时候,还是最好先把语法变成LL(1)的。