Parse an Expression
这篇博客讨论如何使用Haskell的Parsec库来实现对表达式的parse,本文主要参考《Intro to Parsing with Parsec in Haskell》。
使用Parsec
首先介绍一些简单的需要使用的parser combinators。
1 | satisfy :: Stream s m Char => (Char -> Bool) -> ParsecT s u m Char |
1 | *Main> parseTest (satisfy isLower ) "abc" |
这是一个非常基本的parser,很多parser都可以通过satisfy构造出来,只需要调整test函数即可。
1 | char c = satisfy (==c) |
下面介绍一下many,many p相当于正则表达式中的p*,匹配零次或多次p,将结果返回一个列表;many1是匹配一次或多次。many的一种可能的实现如下:
1 | many p = |
有了many就可以实现一些字面值的parse,比如integer、identifier:
1 | integer :: Parsec String u Int |
1 | *Main> parseTest integer "12341" |
下面说明如何匹配括号:
1 | parens :: Parsec String u a -> Parsec String u a |
很容易发现,这样子是不允许中间有空白的。 1
2
3
4
5
6*Main> parseTest (parens integer) "(123)"
123
*Main> parseTest (parens integer) "( 123 )"
parse error at (line 1, column 2):
unexpected " "
expecting digit
一种简单的改进,在char '('后面添加spaces。但是这样手动添加空白太过于繁琐,于是就有了lexeme parsing——每个符号parser应该同时消耗并忽略尾部的空白。
lexeme parser自动消耗末尾的空白,一定不要忘记开头的空白,只需要将最外层的parser p修改为(spaces *> p)。
1 | type Parser = Parsec String () |
其中parensP是一个只匹配括号的parser,其产生式如下: 1
P -> '(' P ')' | epsilon | PP
parensP才长这个样子。
1 | *Main> parseTest parensP "(())(" |
Parse an Expression
首先给出Expression的产生式和AST:
1 | E -> number | var | E + E | E - E | E * E | (E) |
1 | data Expr = |
这是一个简单的四则运算的产生式,很明显不能直接写作parser combinator,因为这玩意儿一看就问题多多:
- 左递归
- 不是LL(1)
再次强调,Parsec最擅长的是LL(1)的parsing,如果我非要直接写行不行?使用“回溯”是可以处理非LL(1)的情况。
左递归必须干掉,不干掉的话会发生什么?
1 | num = Num <$> integer |
这里,我们使用try先判断是不是加法,如果不是加法,再判断后面的情况,这是因为add和num、var不是LL(1)的。然而这个实现是错误的,因为存在左递归,程序会一直在expr和add之间反复横跳。
为了“消除左递归”,我们引入一个新的变量term,表示加法的“项”,term应该是expr的终结符(First集合)组成,要包含expr的所有First集合的可能性。
1 | num = Num <$> integer |
这个实现其实已经可以满足需求了,但是还有令人不满意的地方:
- 首先这个结构是“右结合”的,
1+2+3被解释成1+(2+3)而不是(1+2)+3 - 其次这个实现使用了
try,会产生回溯,这令人不满。
1 | *Main> parseTest expr "1+2" |
能否搞成左结合呢?类比foldl和foldr的区别,我们可以这么实现:
1 | num = Num <$> integer |
我们可以看到,已经可以正确的得到左结合的结构。 1
2*Main> parseTest expr "1+2+3"
Add (Add (Num 1) (Num 2)) (Num 3)
同时注意到,我们没有使用try,也就是说这是LL(1)的。
为什么这是正确的呢?其实思路很简单,之前我们的v2版本试图从一开始就对expr进行分类——你是add还是term。然而这并不合理,一个表达式的核心在于op,所以我们应该在op这个地方进行分类,这样就是LL(1)了,不需要回溯。
为了正确的产生“左结构”,我们使用了类似foldl的方法,搞一个acc,带着acc向后扫描即可。(其实这就是chainl1的实现)
前文提到,这种方法是在op处进行分支判断,所以只需要对op添加更多parser,就可以支持多种运算符。
1 | operator = choice [ |
然而这个v4版本揭示了一个很有意义的bug,如下:
1 | *Main> parseTest expr "1 + 2 * 3" |
没错,这是一个“歧义文法”。其实我们的实现并没有错,“错的不是我,是这个世界”,只是因为该文法本身就是有歧义的。
怎么消除歧义?数学上我们知道要定义“优先级”,那优先级在这里应该指什么呢?
一种直觉的思路,优先级就是一遍一遍扫,外层的优先级低,内层的优先级高(优先级高的先结合)。这里其实并不是指多次遍历,更准确的说法是:加法的“项”可以是一次乘法的结果,所以可以分层次地改写文法:
1 | Expr -> Term | Expr '+' Term | Expr '-' Term |
这就是改写后的文法,这个文法就是无歧义的,我们只需要将这个文法按照上文的方法实现即可。
1 | expr = term `chainl1` addop |
这基本就是终极状态了,可以看到并不复杂,反而非常简洁,非常接近产生式,基本相当于直接翻译产生式,chainl就具备这种将“左递归”的产生式正确翻译的能力,其实现就是v4版本里面写的那样,也不复杂。
parsing expression作为常见的parser,Text.Parsec.Expr提供了一个非常简单的接口,基本相当于把我们的v5进行很复杂的封装,能够支持:
- 前缀单目运算
- 后缀单目运算
- 中缀双目运算
1 | pteTable = [[Infix (Mul <$ symbol '*') AssocLeft, Infix (Div <$ symbol '/') AssocLeft] |
迄今为止我们的程序正确性,都基于op的parsing是LL(1)的,对于>和>=这样的运算符,显然就不能够区分,还是要使用try,在什么地方添加try又是一个问题。
所以对于复杂的表达式,先pass一遍生成token是很好的办法,然后对于[token]再去写parser。