某高中生眼中入门级别的程序。
本文要点
你将收获
- 了解Parser Combinator的概念、特点以及使用方法。
- 学会用Haskell亲手构建简单却实用的Parser Combinator。
- 加深对Monad的理解。
所需基础
- 简单了解Haskell语法即可。
Parser Combinator 概念
什么是Parser:
简单地说:给定一个字符流,比如一个字符串、一段代码、一篇文章等,输入到Parser,会把原本平坦的结构改造成有层次有内涵的结构。
什么是Parser Combinator:
由于函数式语言里函数是头等公民,可以把一个函数作为另一个函数的参数,一个函数可以产生出另外一个函数。基于这样的特点,我们可以把一个Parser作为参数传给另外一个Parser,一个Parser也可以产生另外一个新的Parser。有了这样的结合性,我们就可以实现Parser Combinator。
它是一种基于递归下降原理的非常简洁的Parser实现方法。
为什么是Parser Combinator:
Parser Combinator在模块化、可读性、易维护等方面有着无可比拟的优势。
举个简单的例子:就像小孩子读文章,一开始他只认识一个一个字,拿着手指一个字一个字地点着读。接着他学会了把字结合起来变成词,认识词后又学会如何识别句子(很可能是因为学会了标点符号),慢慢长大后他便一目十行,脑中自然形成了文章的概念。Parser Combinator就很像这样一步一步组合的过程,非常符合人类直觉,所以编写起来相当容易。
编写语言:
Haskell,不想写C++找罪受。
注:演示代码将尽量详细,所以可能不够point-free。
数据类型
第一步,先定义Parser的数据类型。
还记得我上面说过,Parser是将字符流转换成一种结构,比如Tree
。所以可以适当猜想一下数据类型的样子:
但显然不可能这样一步到位,一个Parser
直接生成一棵树,那还谈什么结合。按道理,应该是一个Parser接受字符串,消耗一定字符,产生特定的结构,完成后把剩余的字符交给下一个Parser
继续解析:
这个看起来还行,不过只能产生Tree
这种数据结构。我们不应该给它这样的限制,而是允许使用者自由地决定解析什么数据结构,所以把Tree
抽象成任意具体类型。
最后再加上代表解析失败的信号,就形成了一个可用的Parser
类型。
解析失败将返回Nothing
。
热身
在最开始的地方,就让我们写个最简单的,能够解析一个特定字符的的Parser
:
接受一个字符,返回解析该字符的Parser
:
为了测试效果,还得实现一个运行函数:
终端:
- 解析 a 字符。给定 “abc” 字符串,字符串消耗一个 ‘a’ ,返回一个 ‘a’ ,剩余字符串为 “bc” 。
- 解析 a 字符。给定 “b” 字符串,解析失败,返回 Nothing 。
太好了,莫大的鼓励。
开始构建
satisfy
上面简单实现了char
。不过,是否可以略微抽象一下呢。不一定要相等,只要满足给定的条件就算解析成功。
相信读者已经想到了,把Char
改成函数Char -> Bool
不就可以了吗,就取名作satisfy
吧:
对比上面的char
,这里只是把if
右边的判断语句改了一下,就实现了更丰富的功能。
既然satisfy
是由char
抽象而来的,那么char
应该也可以由satisfy
实现:
不仅如此,现在因为有了satisfy
,从Data.Char
包里找到一些Char -> Bool
的函数,我们就又可以实现一大堆Parser
了:
|
|
赚大了,简直是买一送十,实现个satisfy
白拿这么多Parser
。
测试:
还有这两个相当有用的Parser
:
测试:
string
尽管上面已经实现了很多Parser
,但我们发现它们都是解析Char
类型的,不能就此停下脚步。
现在尝试一下实现解析多个Char
的Parser
,也就是String
:
测试:
这里使用了递归,看起来似乎有点复杂,嵌套了两层case
,但在注释的帮助下,相信读者能够轻松理解。
Combinator
也许会有读者问到,我们仅仅是解析一串字符串,都要嵌套来嵌套去,这就是所谓的可读性和易构建吗?
这个问题问得非常好。假设我们要连续解析多个类型,如果继续按照上面的做法,很可能会产生一大串case
链,这是非常难写和难懂的。
这个问题的解决,还是只有那个办法 —— 抽象。
要在旧的Parser
产生的结果的基础上构造新的另外一种类型的Parser
,可以这样做:
第二个参数a -> Parser b
实际上只是一个幌子,它最大的作用是把第一个Parser a
的结果a
「骗」出来。平时使用时通常写成一个lambda
表达式,然后在这个lambda
里专注于Parser b
的实现。
若一头雾水,请留意接下来的演示。
用它来重新写string
:
lift
函数不消耗字符串,只为把Parser
结构补上。
如此奇妙的思路,如此精巧的实现,当然这不是我这种普通人能够「显而易见」的。
在此致敬相关数学家。
Monad
躲不过的,躲不过的。写Haskell无论如何都是是躲不过Monad的。
Haskell上bind
语法的简化版:
看起来更加清晰自然,这才是Combinator
应有的样子。
想要得到这种do
语法,只需要将Parser
类型实现Monad
。
其实我们已经实现了Monad
需要的(>>=)
和return
函数,但由于还未实现Monad
的前置范畴Functor
和Applicative
,所以暂时还不能instance Monad
。Functor
和Applicative
也是很有用的范畴,下面就来实现一下。
Functor
|
|
最少实现:fmap :: (a -> b) -> f a -> f b
下面来尝试一下:
fmap
需要提供一个函数a -> b
和一个Parser a
,然后就可以产生另外一个Parser b
。
Applicative
|
|
最少实现:pure :: a -> f a
、(<*>) :: f (a -> b) -> f a -> f b
pure
已经实现过了,就是上面的lift
:1pure x = Parser $ \inp -> Just (x, inp)lift
:1234pab <*> pa = Parser $ \inp ->case runParse pab inp ofNothing -> NothingJust (fab, rest) -> runParse (fmap fab pa) rest合起来:
123456instance Applicative Parser wherepure x = Parser $ \inp -> Just (x, inp)pab <*> pa = Parser $ \inp ->case runParse pab inp ofNothing -> NothingJust (fab, rest) -> runParse (fmap fab pa) rest
Monad
|
|
最少实现:(>>=) :: m a -> (a -> m b) -> m b
直接复制上面的bind
|
|
用
Monad
改写string
:123456string :: String -> Parser Stringstring "" = return ""string (x:xs) = dos <- char xss <- string xsreturn (s:ss)测试:
1234λ> runParse (string "abc") "abcd"Just ("abc","d")λ> runParse (string "abcde") "abcd"Nothing用
Functor
和Applicative
改写string
:123string :: String -> Parser Stringstring "" = return ""string (x:xs) = (:) <$> char x <*> string xs测试:
1234λ> runParse (string "abc") "abcd"Just ("abc","d")λ> runParse (string "abcde") "abcd"Nothing
选择与回溯
尽管有上面的Monad
,我们已经可以构建绝大多数的Parser
了。
但当我们常常希望解析同样的值多次,或是尝试解析a
失败后继续解析b
。
幸运的是,只要instance Alternative
,就能一次性获得以上好处。
首先需要import
:
|
|
只要实现empty
和<|>
:
注:这里发生了件很微妙的事情 —— 这里的<|>
实现是必回溯的,也就是说,如果左边的pa
失败了,则pb
会从头开始解析。
现在我们有了some
、many
、<|>
,可以用来做一些更酷的事情。
|
|
注:>>
是>>=
的丢弃结果版本,a >> b
相当于a >>= \_ -> b
。带1
的函数代表至少解析一个值,对应some
。
测试:
其他函数
between
通常用来解析括号之间的值,利用Applicative
。
|
|
|
|
sepBy
通常用来解析列表形式的值,每个值之间用特定分隔符隔开,解析成功则返回值的列表。
|
|
|
|
endBy
和sepBy
的区别是,endBy
末尾还有分隔符。
|
|
|
|
chainl
左结合,需要提供结合两个值的函数。
|
|
chainr
右结合,需要提供结合两个值的函数。
|
|
实例:构造四则运算语法树
语法树数据结构
|
|
顺便instance Show
,按S-表达式语法:
|
|
解析数字
|
|
|
|
Term Factor Expression
读者可以参考中缀表达式转化前后缀引发的思考。
由于四则运算的结合性都是左结合,需要一个Parser (a -> a -> a)
类型的结合函数:
Exp = Factor (( ‘+’ | ‘-‘ ) Factor)*
|
|
Factor = Term (( ‘*‘ | ‘/‘ ) Term)*
|
|
Term = <数字> | ‘(‘ Exp ‘)’
|
|
测试
|
|
注意到,这里的原始式不能带空格。
练习:请读者自行实现一个允许带空格的四则运算Parser
。
下一步
- 本文实现的
Parser Combinator
的缺陷:不包含错误信息提示、<|>
强制回溯等,将在下篇文章解决。 - 将带来更多的实例。
参考
Monadic Parser Combinators
https://en.wikipedia.org/wiki/Parser_combinator