Haskell中的单体分析器组合器

233 阅读10分钟

正如维基百科所描述的,解析器组合器是一个高阶函数,它接受几个解析器作为输入,并返回一个新的解析器作为其输出

当你想建立模块化的解析器并为进一步的扩展留有余地时,它们可以非常强大。 但是当使用一个3rd方组合器库时,要想获得正确的错误报告是很棘手的,而且它们在命令式语言中往往比较慢。 尽管如此,它是函数式编程和PLT中一个有趣的基石,所以通过自己建立一个来学习它们应该不会有坏处。

我们将首先编写一个库,描述几个微小的解析器和对这些解析器进行操作的函数。 然后,我们将建立一些解析器来证明我们工作的有用性。

为了给你一个小小的预告,这里有一个接受C风格标识符的解析器,是在我们方便的组合器的帮助下编写的。

-- matches strings that satisfy [a-zA-Z][a-zA-Z0-9]+
ident :: Parser String
-- One letter or '_', followed by zero of more '_', letters or digits
ident = alpha_ `thenList` many (alpha_ <|> digit)
  where
    alpha_ = letter <|> char '_'

建议你对以下内容有一些基本了解。

  • 解析器
  • 函数式编程中的单体
  • Haskell

这篇文章是我最近读过的两篇论文的衍生品(12)。 如果你喜欢更深入的阅读,你可以通过论文来代替。

解析器类型

在我们开始定义作用于解析器的组合器之前,我们必须先为解析器选择一个表示法。

解析器接收一个字符串并产生一个输出,这个输出可以是任何东西。 一个列表解析器会产生一个列表作为它的输出,一个整数解析器会产生Ints,一个JSON解析器可能返回一个代表JSON的自定义ADT

因此,使 Parser 成为一个多态类型是有意义的。 返回一个结果列表而不是一个单一的结果也是有意义的,因为语法可能是模糊的,而且可能有几种方法来解析同一个输入字符串。

那么,一个空的列表就意味着解析器未能解析所提供的输入。(1)

newtype Parser a = Parser { parse :: String -> [(a, String)] }

你可能会问,为什么我们要返回元组(a, String) ,而不仅仅是a 。好吧,一个解析器可能无法解析整个输入字符串。通常,一个解析器只打算解析输入的一些前缀,而让另一个解析器来做其余的解析。因此,我们返回一个包含解析结果a 和后续解析器可以使用的未吸收的字符串的对。

我们可以使用type 关键字让Parser 成为String -> [(a, String)] 的别名,但是有一个独特的数据类型使我们有能力把它实例化为一个类型类,这是我们以后要做的事情。

婴儿分析器

我们可以从描述一些基本的解析器开始,这些解析器做的工作很少。一个result 解析器总是在不消耗输入字符串的情况下成功地进行解析。

result :: a -> Parser a
result val = Parser $ \inp -> [(val, inp)]

解析器zero ,总是会以返回一个空列表的方式失败。

zero :: Parser a
zero = Parser $ const []

item 无条件地接受任何输入字符串的第一个字符。

item :: Parser Char
item = Parser $ parseItem
  where
    parseItem [] = []
    parseItem (x:xs) = [(x, xs)]

让我们在GHCi中尝试一些这样的解析器。

*Main> parse (result 42) "abc"
[(42, "abc")]
*Main> parse item "abc"
[('a', "bc")]

按需构建解析器

我们上面定义的基本解析器用处不大。 理想情况下,我们希望解析器能够接受满足某些约束条件的输入字符串。 例如,我们希望解析器能够在一个字符串的第一个字符满足一个谓词的情况下消耗该字符串。 我们可以通过编写一个函数来概括这个想法,该函数接收一个(Char -> Bool) 谓词并返回一个解析器,只有在输入字符串的第一个字符提供给谓词时返回True

这方面最简单的解决方案是。

sat :: (Char -> Bool) -> Parser Char
sat p (x:xs) = Parser $ if p x
  then [(x, xs)]
  else []
sat _ [] = result []

然而,由于我们已经有了一个无条件地从字符串中提取第一个字符的item 解析器,我们可以利用这个机会来创建一个基本的解析器组合器。

在编写组合器之前,我们必须首先将Parser 实例化为一个Monad(2)

instance Monad Parser where
  (>>=) : Parser a -> (a -> Parser b) -> Parser b
  p >>= f = Parser $ \inp ->
    concat [parse (f v) inp' | (v, inp') <- parse p inp]
  -- a -> Parser a
  return = result

bind 操作接收一个Parser a (p) 和一个函数a -> Parser b (f),并返回一个Parser b 。我们的想法是应用p ,如果失败了,那么我们就有一个空列表,其结果是concat [[]] =[] 。如果p 成功地将inp 解析为一个或多个可能的解析结果,我们对每个结果应用f ,得到相应的Parser bs ,然后将这些应用于其余的输入。

有了这个新的扩展,我们的sat 解析器可以重新写成。

sat p =
-- Apply `item`, if it fails on an empty string, we simply short circuit and get `[]`.
  item >>= \x ->
    if p x
      then result x
      else zero

现在我们可以用sat 组合器来描述几个有用的解析器。例如,一个char 解析器只消耗以特定字符开始的字符串。

char :: Char -> Parser Char
char x = sat (== x)
*Main> parse char "abc"
[('a', "bc")]

一个用于ASCII数字的解析器。

-- import Data.Char (isDigit, isLower, isUpper)
digit :: Parser Char
digit = sat isDigit

以及类似的小而有用的解析器。

lower :: Parser Char
lower = sat isLower

upper :: Parser Char
upper = sat isUpper
$ ghci -i main.hs
*Main> parse lower "aQuickBrownFox"
[('a',"QuickBrownFox")]

现在我们有了upper,lowerdigit ,这为组合提供了新的可能性。

  • 一个alphabet 解析器,接受一个可由upperlower 消费的字符。
  • 一个alphanumeric 剖析器,接受一个字符,可以是alphabetdigit

显然,一个能够捕捉到这种反复出现的模式的or 组合器将派上用场。

让我们从描述一个plus 组合器开始,该组合器将两个分析器返回的结果连接起来。

-- Applies two parsers to the same input, then returns a list
-- containing results returned by both of them.
plus :: Parser a -> Parser a -> Parser a
p `plus` q = Parser $ \inp -> parse p inp ++ parse q inp

Haskell有一个MonadPlus类型类,在前奏中是这样定义的。

class (Monad m) => MonadPlus m where
  mzero :: m a
  mplus :: m a -> m a -> m a

mzero 代表失败,而 代表两个单体的组合。由于 已经是一个单体,我们可以实例化 类型类来执行这个想法。mplus Parser MonadPlus

import Control.Applicative
instance MonadPlus Parser where
  mzero = zero
  mplus = plus

然后,or 组合器就可以了。

or :: Parser a -> Parser a -> Parser a
p `or` q = Parser $ \inp -> case parse (p `plus` q) inp of
    [] -> []
    (x:xs) -> [x]

事实上,Alternative 类型类已经用选择 (<|>) 操作符定义了这个功能。

instance Alternative Parser where
  empty = zero
  (<|>) = or

最后,我们可以返回到letteralphanum 解析器。

letter :: Parser Char
letter = lower <|> upper

alphanum :: Parser Char
alphanum = letter <|> digit

我们现在可以在GHCi中对它们进行测试。

*Main> parse letter "p0p3y3"
[('p',"0p3y3")]
*Main> parse letter "30p3y3"
[]
*Main> parse alphanum "foobar"
[('f',"oobar")]

作为一个随机的旁观者,我们可以使用排序(>>)操作符来编写更简洁的代码。例如,考虑一下函数string ,其中string "foo" 返回一个只接受以 "foo "开头的字符串的解析器。

string :: String -> Parser String
string "" = result ""
string (x:xs) =
  char x >> string xs >> result (x:xs)

使用>>= 符号,我们就必须写。

string (x:xs) =
  char x
    >>= const string xs -- same as \_ -> string xs
    >>= const result (x:xs) -- same as \_ -> result (x:xs)
*Main> parse (string "prefix") "prefixxxxx"
[("prefix", "xxxx")]

使用do记号

Haskell提供了一个方便的do符号,用于可读地对单体计算进行排序。 当组成单体动作变得有点难看时,这很有用。 考虑这个例子,它组成了几个分析器的输出。

parser = parser1 >>= \x1 -> -- 1. apply parser1
  make_parser2 x1 >>= \x2 -> -- 2. use parser1's output to make parser2
    make_parser3 x2 >>= \x3 -> -- 3. Use parser2's output to make parser3
      return (f x1 x2 x3) -- 4. Combine all parse results to form the final result

使用do 符号,上面的代码片断变成了。

parser = do
  x1 <- parser1
  x2 <- make_parser2 x1
  x3 <- make_parser3 x2
  return (f x1 x2 x3)

今后,只要能提高可读性,我们将倾向于使用do 符号而不是>>=

用于重复的组合器

你可能很熟悉regex匹配器+*a* 匹配零个或多个字母'a'的出现,而a+ 希望有一个或多个字母'a'的出现。

我们可以将* 匹配器表示为一个组合器,像这样。

many :: Parser a -> Parser [a]
many p = do
  x  <- p -- apply p once
  xs <- many p -- recursively apply `p` as many times as possible
  return (x:xs)

看起来不错,但是当在GHCi中运行时,它不能产生预期的结果。

*Main> parse (many $ char 'x') "xx"
[]

如果你试图用手来解决这个解析器的应用问题,你会注意到我们的基础案例中有一个缺陷:在最后的递归调用中,当输入的字符串是""x <- p 失败,我们就会短路,返回[]

为了处理这种情况,我们可以使用我们的or 组合器。

many :: Parser a -> Parser [a]
many p = do {
      x  <- p; -- apply p once
      xs <- Main.many p; -- recursively apply `p` as many times as possible
      return (x:xs)
      } <|> return []

  -- In case `p` fails either in the initial call, or in one of the
  -- recursive calls to itself, we return an empty list as the parse result.

而我们是金色的。

*Main> parse (many $ char 'x') "xxx123"
[("xxx","123")]

如果你对<|> 的使用仍然感到困惑,可以试着在纸上解决这个问题。

类似于regex+ 匹配器,我们可以写一个many1 组合器,接受一个或多个输入序列的出现。 借用many ,这可以简单写成。

many1 :: Parser a -> Parser [a]
many1 p = do
  x <- p
  xs <- many p
  return (x:xs)

解析一个标识符的列表

如果你现在还没有意识到,我们已经建立了一些能够解析常规语言的组合器。 回到本篇文章的开头,这里有一个能够解析有效的C语言标识符的组合器。

ident :: Parser String
ident = do
  x <- alpha_
  xs <- many (alpha_ <|> digit)
  return (x : xs)
  where
    alpha_ = letter <|> char '_'
*Main> parse ident "hello_123_ = 5"
[("hello_123_"," = 5")]

为了使其更加简洁,我们可以定义一个then' 组合器,它使用一个调用者提供的函数将两个解析器产生的结果结合起来。

then' :: (a -> b -> c) -> Parser a -> Parser b -> Parser c
then' combine p q =
  p >>= \x ->
    q >>= \xs ->
      result $ combine x xs

然后,一个thenList 组合器可以使用(:) 来组合解析类型为a[a] 的结果。

thenList :: Parser a -> Parser[a] -> Parser[a]
thenList = then' (:)

现在我们的标识符解析器变得更短了。

ident :: Parser String
ident = alpha_ `thenList` many (alpha_ <|> digit)
  where
    alpha_ = letter <|> char '_'

现在,让我们的组合器更进一步。 假设我们想解析一个逗号分隔的标识符列表,这里有一种方法可以做到。

idList :: Parser [String]
idList = do
  firstId <- ident
  restIds <- many $ (char ',' >> ident)
  return (firstId : restIds)

符号分隔的项目列表是语言语法中经常出现的模式。 因此,我们可以用一个sepBy 组合器来抽象出这个概念。

-- Accept a list of sequences forming an `a`, separated by sequences forming a `b`.
sepBy :: Parser a -> Parser b -> Parser [a]
p `sepBy` sep = do
  x <- p
  xs <- many (sep >> p)
  return (x : xs)

idList = ident `sepBy` char ','

现在,如果标识符列表像在数组中一样被括号括起来呢? 我们可以定义另一个组合器,bracket ,来解析被括在特定序列中的字符串。

bracket :: Parser a -> Parser b -> Parser c -> Parser b
bracket open p close = do
  _ <- open
  x <- p
  _ <- close
  return x

序列运算符可以用来以一种稍微优雅的方式编写bracket

bracket open p close = open >> p << close

利用这一点,我们的项目列表的解析器可以写成:。

idList = bracket (char '[') ids (char ']')
  where
    ids = ident `sepBy` char ','

让我们在GHCi中测试一下这个实现。

*Main> parse idList "[foo,bar,baz]"
[(["foo","bar","baz"],"")]

完美!

解析自然数

由于我们的解析器是多态的,我们可以返回一个包含输入字符串评估值的解析结果。 下面是一个消耗和评估自然数值的解析器。

nat :: Parser Int
nat =
  many1 digit >>= eval
  where
    eval xs = result $ foldl1 op [digitToInt x | x <- xs]
    m `op` n = 10 * m + n

一个自然数是一个或多个十进制数字,然后我们将其折叠,产生一个基数为10的数值。另外,我们可以使用内置的read ,实现nat

nat = read <$> many1 digit

处理空白处

你可能已经注意到了,到目前为止我们所写的分析器在处理空白处方面并不出色。

*Main> parse idList "[a, b, c]"
[]

理想情况下,我们应该忽略标记符前后的任何空白。 一般来说,标记符的工作是处理空白,并返回一个标记符的列表,然后解析器可以使用。 然而,在使用组合器时,可以完全跳过标记符。

我们可以定义一个token 组合器来处理所有的尾部空白。

spaces :: Parse ()
spaces = void $ many $ sat isSpace

token :: Parser a -> Parser a
token p = p <* spaces

还有一个parse' 组合器,可以去除所有的前导空格。

parse' :: Parser a -> Parser a
parse' p = spaces >> p

parse' 组合器被应用于最终的分析器一次,以确保没有前导空白。token 组合器消耗所有的后导空白,从而确保没有前导空白留给后续的分析器。

我们现在可以编写不考虑空白的解析器了。

identifier :: Parser String
identifier = token ident

在这一点上,我们有了可以插在几个地方的原子分析器。 其中一个地方可以是算术表达式评估器。

一个表达式解析器

最后,为了证明我们到目前为止所定义的组合器的有用性,我们建立了一个基本的算术表达式解析器。我们将支持二进制+- 操作符,括号内的表达式和整数字面。到最后,我们将有一个eval 函数,可以像这样评估表达式。

*Main> eval "1 + 2 - 3 - 4 + 10"
6

-- consume a character and discard all trailing whitespace
charToken :: Char -> Parser Char
charToken = token <$> char

-- an ADT representing a parse tree for expressions
data Expr = Add Expr Expr
          | Sub Expr Expr
          | Par Expr
          | Lit Int
          deriving (Show)

eval' :: Expr -> Int
eval' (Add a b) = eval' a + eval' b
eval' (Sub a b) = eval' a - eval' b
eval' (Par a)   = eval' a
eval' (Lit a)   = a

eval :: String -> Int
eval =  fst . Bifunctor.first eval' . head <$> parse expr

chainl1 :: Parser a -> Parser (a -> a -> a) -> Parser a
p `chainl1` op = do
  first <- p
  rest <- many $ do
    f    <- op
    term <- p
    return (f, term)
  return $ foldl (\x (f, y) -> f x y) first rest

-- Our expression parser expects a string of the following grammar:
-- expr ::= term (op term)*
-- op ::= '+' | '-'
-- term ::= int | '(' expr ')'
-- int ::= [0-9]*

-- The `expr` parser first consumes an atomic term - <X>, then it
-- consumes a series of "<op> <operand>"s and packs them into tuples like ((+), 2)
-- We then fold the list of tuples using <X> as the initial value to produce the result.
expr :: Parser Expr
expr = do
  x <- term
  rest <- many parseRest
  return $
    foldl (\x (op, y) -> x `op` y) x rest
  where
    parseRest = do
      f <- op
      y <- term
      return (f, y)

-- term := int | parens
term :: Parser Expr
term = int <|> parens

-- parens := '(' expr ')'
parens :: Parser Expr
parens = bracket (char '(') expr (char ')')

-- int := [0-9]*
int :: Parser Expr
int = Lit <$> token nat

-- op := '+' | '-'
op :: Parser (Expr -> Expr -> Expr)
op = makeOp '+' Add <|> makeOp '-' Sub
    where makeOp x f = charToken x >> return f

启动GHCi,我们就有了它。

*Main> parse expr "1 + 2 - (3 - 1)"
[(1, "")]

*Main> eval "1 + 2  3 - 3"
0

我们的解析器很不错,但还可以进一步重构。 一个表达式是一个括号表达式和整数字的列表,用+-

事实证明,解析一个由标记限定的项目组成的列表是一个由chainl1 组合器捕获的常见模式。

chainl1 :: Parser a -> Parser (a -> a -> a) -> Parser a
p `chainl1` op = do
  first <- p
  rest <- many $ do
    f    <- op
    term <- p
    return (f, term)
  return $ foldl (\x (f, y) -> f x y) first rest

expr :: Parser Expr
expr = term `chainl1` op

就这样,我们有了一个由几个微小的模块化解析器组成的单体表达式解析器。 作为一个练习,你可以扩展这个解析器,添加更多的运算符,如乘法、除法和对数。

进一步阅读

正如你可能已经猜到的那样,许多语言已经有了几个解析器组合库。 特别是Parsec,是Haskell程序员中最常用的一个。 这里有更多资源供你咀嚼。

  1. 单体分析器组合器
  2. Functional Pearls - Haskell中的单数据分析
  3. 微软研究院--现实世界中的直接风格单体分析器组合器

前两篇文章,如果你到目前为止一直在关注这个帖子,应该会觉得非常熟悉。 第三篇是一篇论文,试图为使用单体的解析提供一个更好的替代技术。

在这一点上,解析器组合已经成为你函数式编程武库中的另一个工具。 去吧,写一些杀手级的解析器吧

后面的事情

  1. 在大多数实现中,解析结果是一个可以存储错误信息的漏斗,以防解析器失败。

    newtype Parser a = Parser { parse :: String -> ParseResult a }
    type ParesResult a = Either ParseError a
    type ParseError = String
    
  2. 为了将Parser实例化为Haskell中的Monad,我们还必须使它成为FunctorApplicative 的实例。

instance Functor Parser where
  fmap f p = Parser (fmap (Bifunctor.first f) . parse p)

instance Applicative Parser where
  pure = result
  p1 <*> p2 = Parser $ \inp -> do
      (f, inp') <- parse p1 inp
      (a, inp'') <- parse p2 inp'
      return (f a, inp'')