code的颜值
首发于code的颜值

从字符串到能用的数据结构到底有多远?——Haskell的Parsec实战

首先,为什么要用Parsec解析文本而不是正则表达式?在其它语言中,将内容分割成数组,用正则表达式来解析内容是普遍存在的。在Haskell中也可以沿着这一条路线走下去。但Parsec是一个更好的方式。看了Parsec之后我就用Parsec解析JSON文本(作为学习),从字符串到JSON类型没有想像中的那么远,以后再也不用害怕字符串了。

Parsec简介

依赖包: - parsec

必要的引入: import qualified Text.Parsec as P

定义:type P.Parsec s u = P.ParsecT s u Identity :: * -> *

Parsec是ParsecT的一个简写,ParsecT主要是以下类型类的实例

instance [safe] A.Alternative (P.ParsecT s u m)
  -- Defined in ‘Text.Parsec.Prim’
instance [safe] Applicative (P.ParsecT s u m)
  -- Defined in ‘Text.Parsec.Prim’
instance [safe] Functor (P.ParsecT s u m)
  -- Defined in ‘Text.Parsec.Prim’
instance [safe] Monad (P.ParsecT s u m)
  -- Defined in ‘Text.Parsec.Prim’
instance [safe] (Monoid a,
                 Data.Semigroup.Semigroup (P.ParsecT s u m a)) =>
                Monoid (P.ParsecT s u m a)

以下只列出关键点
Alternative : 可以使用<|>来表达『逻辑或』的关系。
Applicative: 可以使用pure,<*> ,<*,*> 函数,可以更改容器内的值。

Functor: 一般我用<$>替代fmap函数,另外还有一个有用的函数<$ 用于更改容器内的值
Monad: do 语句块
当然上面列的不全,ParsecT也是一个MonadTrans ,可以嵌入Monad(如最常用的IO)用lift升格。

Parsec s u a : s是源(也就是要解析的文本)类型,u是用户状态类型,a是结果

Parsec一些关键的函数(常用的函数)

P.parse 解析入口函数

P.parse                                                                      
  :: P.Stream s Identity t =>                                                 
     P.Parsec s () a -> P.SourceName -> s -> Either P.ParseError a 

parse Parsec 源文件名字(解析失败时用来定位文件) 待解析文本 -> Either

P.char、P.oneOf、P.digit、P.string、P.noneOf 、P.anyChar等都会返回Parsec用来解析 一个字符、多个字符之一、数字、字符串、非多个字符之一、任意字符等。

P.many Parsec 解析零个或多个Parsec直到解析失败
P.many1 Parsec 至少解析一个或者多个
P.skipMany、P.skipMany1 与上面many、many1一样只不过忽略返回结果
P.sepBy a b 用parsec b来分割parsec a(如解析1,12,34,4以固定字符隔开的token)
P.lookAhead Parsec 主要作用是不产生消耗还会给你结果
P.try 由于 a<|>b只对第一个字符做判断如果第一个字符成功了就返回a而不管整体失败与否,try是为了整体失败走b

P.eof 表示文件结尾的Parsec

了解了上面这些函数,类型类就可以完成解析JSON字符串的任务了。


JSON解析

解析之前,一般我会先定义适用于JSON的模型


data Number' = Int Int | Float Float deriving Show
data JSON = Null
  | Number Number'
  | String String
  | Bool Bool
  | Undefined
  | Object [(String ,JSON)]
  | List [JSON]
  deriving (Show)


写好『骨架』

parse :: String -> Either P.ParseError JSON
parse text = P.parse jsonParsec "JSON:" text

jsonParsec :: P.Parsec String () JSON
jsonParsec = P.spaces *> myParsec <* P.spaces <* P.eof 

parse使用jsonParsec解析text, P.spaces表示空白字符,前后可有任意空白字符最终返回myParsec(*>、<* 函数来自于Applicative)

解析Null,和undefined

myParsec = nullParsec
  <|> undefinedParsec

undefinedParsec = Undefined <$ P.string "undefined"
nullParsec = P.string "null" >> return Null

myParsec: <|>函数来自Alternative,如果NullParsec解析失败就用undefinedParsec解析
undefinedParsec : <$来自Functor ,如果解析成功容器内部的值改为Undefined

nullParsec : >> 来自Monad , 如果解析成功内部值改为Null。和undefinedParsec的功能相同。

现在可以运行parse函数,输入null,undefined得的结果和预期一致。

*Main Lib A> parse "null"
Right Null
*Main Lib A> parse "undefined"
Right Undefined
*Main Lib A>


解析String和Bool

js的字符串分为两种写法(es6以下),单引号,双引号,需要写两个Parsec。

写完Parsec之后在myParsec中加入进来

myParsec = nullParsec
  <|> stringParsec
  <|> stringParsec1
  <|> boolParsec
  <|> undefinedParsec

boolParsec = (Bool True <$ P.string "true") <|> (Bool False <$ P.string "false")
stringParsec = do
  P.oneOf "\""
  x <- P.many $ P.noneOf "\""
  P.oneOf "\""
  return $ String x

stringParsec1 = do
  P.oneOf "\'"
  x <- P.many $ P.noneOf "\'"
  P.oneOf "\'"
  return $ String x

boolParsec中所涵盖的内容前面已介绍过了,这里不在介绍
stringParsec解析双引号的字符串整体流程是:消费双引号->消费非双引号并把结果给x->消费双引号->返回。里面的P.oneOf "\"" 也可以换为 P.char '"'。


运行parse函数


*Main Lib A> parse "'hello'"
Right (String "hello")
*Main Lib A> parse "\"diqye\""
Right (String "diqye")
*Main Lib A> parse "abc"
Left "JSON:" (line 1, column 1):
unexpected "a"


解析Array和Object

原本以为这块会很困难,没想到很自然而然的写出来了。


listParsec = do
  P.char '['
  P.spaces
  a <- P.sepBy myParsec (P.try symbol1)
  P.spaces
  P.char ']'
  return $ List a

symbol1 = do
  P.spaces
  P.char ','
  P.spaces

keyParsec :: P.Parsec String () String
keyParsec = do
  c <- P.lookAhead P.anyChar
  let val | C.isDigit c = fail "非法的key"
          | otherwise = P.many1 $ P.noneOf ": "

objectInnerParsec = do
  (String key) <- stringParsec <|> stringParsec1 <|> (pure String <*> keyParsec) P.<?> "符合规定的key" 
  P.spaces
  P.char ':'
  P.spaces
  val <- myParsec
  return (key,val)

objectParsec = do
  P.char '{'
  P.spaces
  a <- P.sepBy objectInnerParsec (P.try symbol1)
  P.spaces
  P.char '}'
  return $ Object a

symbol1 只解析了一个逗号,只不过前后都忽略了空白字符, try symbol1是为了整体失败之后不在做消耗(主要是空白字符),参见简介处的介绍。

listParsec: 消费以『[』开头『]』结尾的字符,通过sepBy以逗号隔开,每一项使用myParsec来解析(递归解析)。

objectParsec: 以『{』开头『}』结尾,中间部分通过objectInnerParsec解析key和val,key可以是一个字符串也可以是普通的key。这里的keyParsec只是简单的解析为不能以数字开头。lookAhead不消费字符,这里使用它的主要目的是使报错的行号、列号更加精确。
解析数字

数字比较麻烦,分为整数,浮点数,负整数,负浮点数。


negdigit = pure (:)  <*> p.char '-' <*> posdigit
posdigit = p.many1 p.digit

negfloat = pure (:)  <*> p.char '-' <*> posfloat
posfloat = do
  digits <- p.many1 p.digit
  dot <- p.char '.'
  rdigits <- p.many1 p.digit
  return $ digits ++ (dot:rdigits)

digitparsec = number . int . (read :: string -> int) <$> (posdigit <|> negdigit)
floatparsec = number . float . (read :: string -> float) <$> (posfloat <|> negfloat)

有了Parsec这些也不再困难咯。

完整的myParsec

myParsec = nullParsec
  <|> stringParsec
  <|> stringParsec1
  <|> listParsec
  <|> objectParsec 
  <|> boolParsec
  <|> undefinedParsec
  <|> floatParsec <|> digitParsec 


repl中的测验


*Main Lib A> parse "['abc',{name:'diqye',age:10},-10,1.01]"
Right (List [String "abc",Object [("name",String "diqye"),("age",Number (Int 10))],Number (Int (-10)),Number (Float 1.01)])
*Main Lib A> parse "['abc',{name:'diqye',age:10},-10,1.01] i"
Left "JSON:" (line 1, column 40):
unexpected 'i'
expecting space or end of input


以上代码以上传至 ppzzppz/json-demo

这个JSON作为学习来说没毛病,作为使用来说,还有很多不足,一些特殊情况没有做处理。代码上可能有一些更好改进,欢迎指正和建议。

编辑于 2018-03-07

文章被以下专栏收录