Real World Haskell 第十章 代码案例学习: 解析二进制数据格式

目录

灰度图文件
解析PGM文件
处理样板代码
隐含状态

在这一章,我们将讨论一个常见的任务:解析二进制文件。用着个例子有两个目的。第一个是介绍些解析的内容,但我们主要的目标是讨论如何组织程序,重构,和样板代码的去除。我们将演示如何清理重复的代码,并为第14章将要讨论的 Monad 打好基础。

我们将要处理的文件格式来自netpbm软件包,它是一个古老并令人尊敬的程序库,包含了很多处理位图的程序和文件格式。这些文件格式具有应用广泛且解析非常简单的优点。对我们来说最方便的一点是netpbm文件没有被压缩。

灰度图文件

netpbm的灰度图文件格式名为 PGM (可移植灰度图)。其实它并不是一个格式,而是两个;“纯文本”格式(或称 P2)用 ASCII 编码,而更常用的“raw格式”(P5)是二进制的。

每种格式以文件头开始,依次以描述文件格式的“魔术”字开始。对于纯文本文件,魔术字是P2,raw格式的是P5.魔术字后跟一个空格,之后是三个数字:宽度,高度,和图片的最高灰度值。这些数字用 ASCII 十进制数字表示,用空格分开。

在最大灰度值后是图像数据。在 raw 文件里,是二进制值的字符串。在纯文本文件里,值表示成ASCII的十进制数字,用一个空格分隔。

raw文件里可以包含一系列图片,一个接一个,每一个都带自己的文件头。纯文本文件只能包含一个图片。

解析raw PGM文件

我们的第一个解析函数只关心raw PGM 文件。我们将把PGM解析器写成一个纯函数。它不负责获取要解析的数据,只负责实际的解析。这是Haskell程序中通常的做法。把读取数据和之后的处理分开,可以保持数据来源的灵活性。

我们用 ByteString 来存储灰度图数据,因为它是压缩的。因为PGM文件头是 ASCII 文本,而文件体是二进制的,我们把文本和二进制的ByteString模块都导入。

-- file: ch10/PNM.hs
import qualified Data.ByteString.Lazy.Char8 as L8
import qualified Data.ByteString.Lazy as L
import Data.Char (isSpace)

对我们来说,用惰性或严格的ByteString 都没关系,所以我们有些随意的选择了惰性的那种。

我们用直截了当的 data 类型来表示 PGM 图片。

-- file: ch10/PNM.hs
data Greymap = Greymap {
      greyWidth :: Int
    , greyHeight :: Int
    , greyMax :: Int
    , greyData :: L.ByteString
    } deriving (Eq)

一般来说,Haskell的Show 实例应当产生一个字符串表示,这个字符串应当可以被 read 调用读回。然而,对于一个位图文件,这会潜在的产生巨大的文本字符串,例如我们要显示一张照片。因此,我们不让编译器自动为我们继承 Show 的实例:我们将自己写,并有意的简化它。

-- file: ch10/PNM.hs
instance Show Greymap where
    show (Greymap w h m _) = "Greymap " ++ show w ++ "x" ++ show h ++
                             " " ++ show m

我们的Show 实例避免了输出位图的数据,所以没办法写一个 Read 实例,因为我们不能从show 的结果来重新构建一个合法的Greymap。

这是我们的解析函数的一个简单类型。

-- file: ch10/PNM.hs
parseP5 :: L.ByteString -> Maybe (Greymap, L.ByteString)

它取一个 ByteString,如果解析成功,返回解析后的 Greymap,连同解析后剩余的字符串。 ?? That residual string will 2 comments

解析函数一次要消耗一点输入。首先,需要确认我们确实是在解析一个 raw PGM 文件;之后需要解析剩余文件头里的数字;之后处理位图的数据。下面是一个简单的表达方式,这将作为我们后面改进的基础。

-- file: ch10/PNM.hs
matchHeader :: L.ByteString -> L.ByteString -> Maybe L.ByteString

-- "nat" here is short for "natural number"
getNat :: L.ByteString -> Maybe (Int, L.ByteString)

getBytes :: Int -> L.ByteString
         -> Maybe (L.ByteString, L.ByteString)

parseP5 s =
  case matchHeader (L8.pack "P5") s of
    Nothing -> Nothing
    Just s1 ->
      case getNat s1 of
        Nothing -> Nothing
        Just (width, s2) ->
          case getNat (L8.dropWhile isSpace s2) of
            Nothing -> Nothing
            Just (height, s3) ->
              case getNat (L8.dropWhile isSpace s3) of
                Nothing -> Nothing
                Just (maxGrey, s4)
                  | maxGrey > 255 -> Nothing
                  | otherwise ->
                      case getBytes 1 s4 of
                        Nothing -> Nothing
                        Just (_, s5) ->
                          case getBytes (width * height) s5 of
                            Nothing -> Nothing
                            Just (bitmap, s6) ->
                              Just (Greymap width height maxGrey bitmap, s6)

这段代码用一个很长的case 表达式,把所有的解析过程逐行写出。每个函数在处理完它需要的输入字符串后,返回剩余的 ByteString。把每次剩余的字符串传给下一步。依次解析出结果,每一步如果解析失败则返回Nothing,否则得到最终结果的一部分数据。这是解析过程中应用的函数的函数体。它们的类型注释掉了,因为之前在上面已经给出了。

-- file: ch10/PNM.hs
-- L.ByteString -> L.ByteString -> Maybe L.ByteString
matchHeader prefix str
    | prefix `L8.isPrefixOf` str
        = Just (L8.dropWhile isSpace (L.drop (L.length prefix) str))
    | otherwise
        = Nothing

-- L.ByteString -> Maybe (Int, L.ByteString)
getNat s = case L8.readInt s of
             Nothing -> Nothing
             Just (num,rest)
                 | num <= 0    -> Nothing
                 | otherwise -> Just (fromIntegral num, rest)

-- Int -> L.ByteString -> Maybe (L.ByteString, L.ByteString)
getBytes n str = let count           = fromIntegral n
                     both@(prefix,_) = L.splitAt count str
                 in if L.length prefix < count
                    then Nothing
                    else Just both

处理样本代码

虽然我们的 parseP5 函数可以工作,但是写法不是那么清爽。代码总是不断向屏幕的右侧延伸,显然如果对于更复杂的函数,将会很快跑到屋子外面去了。我们重复构建之后解析 Maybe值这样的模式,只有当特定值匹配到Just之后才继续。所有类似的代码就是“样板代码”,这些重复的代码遮盖了代码的真实意图。简而言之,这些代码需要抽象和重构。

往回翻一点,我们可以看到两个模式。第一个,我们应用的很多函数有类似的类型。每个函数取一个 ByteString 作为最后的参数,并返回一些东西的 Maybe 值。第二个,在parseP5 函数里的case“梯子”的每一步,都解析一个 Maybe 值,要么失败要么把拆包后的结果传给一个函数。

可以很简单的写一个函数来获得第二种模式。

-- file: ch10/PNM.hs
(>>?) :: Maybe a -> (a -> Maybe b) -> Maybe b
Nothing >>? _ = Nothing
Just v  >>? f = f v

(>>?) 函数非常简单:它取一个值作为其左侧参数,以及一个函数作为右侧参数。如果值不是Nothing,它将把值里里用 Just 包装的值拆包,并在其上应用这个函数。把这个函数定义成操作符,这样就可以把函数串接起来。最后,我们还没有给  (>>?) 提供一个优先级,所以它默认优先级是 infixl 9 (左结合性,最强操作符优先级)。也就是说, a >>? b >>? c 将会从左到右求值,即 ((a >>? b) >>? c)。

有了这个链接函数,就可以再尝试重写解析函数。

-- file: ch10/PNM.hs
parseP5_take2 :: L.ByteString -> Maybe (Greymap, L.ByteString)
parseP5_take2 s =
    matchHeader (L8.pack "P5") s       >>?
    \s -> skipSpace ((), s)           >>?
    (getNat . snd)                    >>?
    skipSpace                         >>?
    \(width, s) ->   getNat s         >>?
    skipSpace                         >>?
    \(height, s) ->  getNat s         >>?
    \(maxGrey, s) -> getBytes 1 s     >>?
    (getBytes (width * height) . snd) >>?
    \(bitmap, s) -> Just (Greymap width height maxGrey bitmap, s)

skipSpace :: (a, L.ByteString) -> Maybe (a, L.ByteString)
skipSpace (a, s) = Just (a, L8.dropWhile isSpace s)

理解者个函数的关键是考虑如何链接。每个(>>?)左侧是一个 Maybe 值; 右侧是一个返回Maybe值的函数。这样每一个左侧和右侧的表达式的类型是Maybe,正好适合传递给接下来的 (>>?) 表达式。

另一个增进可读性的改变是增加了 skipSpace 函数。通过这些改变,与开始的解析函数相比已经把代码减半了。把case表达式样本代码去除掉,代码也更容易理解了。

虽然在“匿名(lambda)函数”一节曾警告过不要过度使用匿名函数,我们在这里的函数链上使用了一些。因为这些函数很小,并且命名之后也不会提高可读性。

隐含状态

我们的代码还比较原始。代码显式的传递数据对,用一个元素来表示解析的中间结果部分,另一个上当前剩余的 ByteString。如果我们想扩展代码,例如要跟踪当前已经消耗的字节的个数,这样就可以在解析失败时报告出位置,我们有八处不同的地方需要修改,只是为了改成传递一个三元组。

用这种方法即是对于一小段代码都很难修改。问题出在从每一个数对中抽取值的模式匹配的使用上:我们已经把总是使用数对这个知识固化到我们的代码中了。模式匹配很方便实用,但如果不小心的使用它,将会被带到错误的方向上。

让我们做些修改以使新代码更灵活。首先,修改解析器使用的状态的类型。

-- file: ch10/Parse.hs
data ParseState = ParseState {
      string :: L.ByteString
    , offset :: Int64           -- imported from Data.Int
    } deriving (Show)

如果转为使用代数数据类型,就可以跟踪当前剩余字符串和相对于开始解析的原始字符串的偏移位置。最重要的改变是使用了记录语法:现在我们可以避免对传入的状态进行模式匹配,而是使用访问函数 string 和 offset。

我们给解析状态命名了。当我们给一些东西命名,它就变得更容易推理。例如,现在可以把解析看做这样一种函数:它消耗一个解析状态,并产生出一个新的解析状态和一些信息。我们可以直接用Haskell类型来表达它。

-- file: ch10/Parse.hs
simpleParse :: ParseState -> (a, ParseState)
simpleParse = undefined

为了给使用者提供更多信息,当解析出错时应该给出一个错误信息。这只需要把我们的解析器的类型做一点改变。

-- file: ch10/Parse.hs
betterParse :: ParseState -> Either String (a, ParseState)
betterParse = undefined

为了让代码适应未来情况,最好不要把我们的解析器的实现暴露给使用者。假如我们过早明确的用数对作为状态,一旦开始考虑扩展我们的解析器的能力,立刻就会遇到麻烦。为了避免重复这个难题,我们用newtype声明来把解析器的类型细节隐藏起来。

-- file: ch10/Parse.hs
newtype Parse a = Parse {
      runParse :: ParseState -> Either String (a, ParseState)
    }

记住newtype定义只是编译期对函数的包装,并不会产生运行时的开销。当需要使用函数时,我们应用 runParser 这个访问函数。

如果我们的模块不导出 Parse 值的构造器,就可以保证其他人不会偶然创建一个解析器,也不能通过模式匹配来查看它的内部状态。

identity 解析器

尝试创建一个简单的identity解析器。它的行为就是把传给它解析的任何东西转成结果。从这点上看,它有些像 id 函数。

-- file: ch10/Parse.hs
identity :: a -> Parse a
identity a = Parse (\s -> Right (a, s))

这个函数不动解析状态,并把它的参数当作解析的结果。我们用 Parse 类型来包装函数体来满足类型检查器。如何使用这个包装过的函数来解析呢?

首先要做的是把Parse 包装剥去,这样我们才能得到里面的函数。我们用runParse函数来做。我们还需要构造一个ParseState类型,之后将我们的解析函数应用在这个解析状态上。最后,我们还要把解析的结果从最终的ParseState中分离开。

-- file: ch10/Parse.hs
parse :: Parse a -> L.ByteString -> Either String a
parse parser initState
    = case runParse parser (ParseState initState 0) of
        Left err          -> Left err
        Right (result, _) -> Right result

因为 identity 解析器和我们的 parse 函数都不会查看解析状态,所以我们甚至不需要创建输入字符串就可以尝试我们的代码。

ghci> :load Parse
[1 of 2] Compiling PNM              ( PNM.hs, interpreted )
[2 of 2] Compiling Parse            ( Parse.hs, interpreted )
Ok, modules loaded: PNM, Parse.
ghci> :type parse (identity 1) undefined
parse (identity 1) undefined :: (Num t) => Either String t
ghci> parse (identity 1) undefined
Loading package array-0.1.0.0 ... linking ... done.
Loading package bytestring-0.9.0.1 ... linking ... done.
Right 1
ghci> parse (identity "foo") undefined
Right "foo"

一个根本不查看它的输入的解析器可能并没什么有趣的,但我们很快就会看到其实它很有用。同时,我们有把握说我们的类型是正确的,并且理解了代码的基本工作原理。

记录语法,更新,模式匹配

记录语法要比只用访问函数有用的多:我们可以复制或者部分改变一个存在的值。在用的时候,写法像下面这样。

-- file: ch10/Parse.hs
modifyOffset :: ParseState -> Int64 -> ParseState
modifyOffset initState newOffset =
    initState { offset = newOffset }

这创建了一个新的 ParseState 值,它和 initState 除了 offset 以外都相同,offset字段被设置为我们指定的任意 newOffset 值。

ghci> let before = ParseState (L8.pack "foo") 0
ghci> let after = modifyOffset before 3
ghci> before
ParseState {string = Chunk "foo" Empty, offset = 0}
ghci> after
ParseState {string = Chunk "foo" Empty, offset = 3}

可以在大括号里面设置任意多的字段,用逗号分隔。

一个更有趣的解析器

让我们开始关注如何写一个具有有意义行为的解析器。我们循序渐进的来:首先要做的是解析一个单独的字节。

-- file: ch10/Parse.hs
-- import the Word8 type from Data.Word
parseByte :: Parse Word8
parseByte =
    getState ==> \initState ->
    case L.uncons (string initState) of
      Nothing ->
          bail "no more input"
      Just (byte,remainder) ->
          putState newState ==> \_ ->
          identity byte
        where newState = initState { string = remainder,
                                     offset = newOffset }
              newOffset = offset initState + 1

在我们的定义中有一些新的函数。

L8.uncons 函数从一个 ByteString 中取出第一个元素。

ghci> L8.uncons (L8.pack "foo")
Just ('f',Chunk "oo" Empty)
ghci> L8.uncons L8.empty
Nothing

getState函数获得当前的解析状态,而putState函数替换它。 bail 函数中止解析并报告错误。 (==>) 函数把解析器链接起来。很快就会降到这些函数。

[提示]
[Tip]    悬挂 lambda 函数

parseByte 函数定义的书写风格之前没有讨论过。它包含的匿名函数,参数和  -> 符号在一行的结尾,函数体在下一行。

匿名函数的这种布局没有一个官方名称,因此让我们称他为“悬挂lambda”。它的主要用处是让函数体有更多空间。它也能让两个相连函数的关系看上去更清楚。比如经常会将第一个函数的结果当作参数传给第二个函数。

获取和修改解析状态

parseByte 函数并不把解析状态当作它的参数。而是通过调用 getState 来获得状态的一份拷贝,putState把当前状态替换成新的。

-- file: ch10/Parse.hs
getState :: Parse ParseState
getState = Parse (\s -> Right (s, s))

putState :: ParseState -> Parse ()
putState s = Parse (\_ -> Right ((), s))

阅读这些函数的时候,记住元组的左侧元素是 Parse 的结果,而右边的是当前的 ParseState。这会更容易看出这些函数是如何做的。

getState函数抽取出当前的解析状态,这样调用者就可以访问那个字符串。putState函数把当前的解析状态替换成新的。这个新状态会在(==>) 链的下一个函数中被看到。

这些函数让我们把状态处理明确的转移到真正需要它们的函数中。很多函数并不需要直到当前的状态,这样它们永远不会调用  getState 或 putState。这可以让我们写出比之前的解析器更紧凑的代码,之前必须手动的把状态元组传来传去。接下来的代码中会看到效果。

我们已经把解析的细节打包到 ParseState 类型中,并且使用访问函数而不是模式匹配。现在解析状态隐含的传递,我们还获得了额外的好处。如果我们像往解析状态中增加更多信息,只需要修改 ParseState 的定义,以及用到这些新的信息的函数。之前需要把所有的状态用模式匹配暴露,与之相比,现在的代码更模块化了:影响到的代码只有需要用到新的信息的那些。

报告解析错误

我们小心的设计Parse的类型来容纳可能的解析失败。 (==>) 组合子检查解析失败,当出现失败时停止解析。我们还没有介绍bail函数,我们用它来报告解析错误。

-- file: ch10/Parse.hs
bail :: String -> Parse a
bail err = Parse $ \s -> Left $
           "byte offset " ++ show (offset s) ++ ": " ++ err

调用 bail 后, (==>) 将匹配到 Left 构造子,它包装了一个错误信息,并且不会继续调用链上的下一个解析器。这会导致错误信息穿过解析链返回给之前的调用者。

将解析器链接起来

(==>)函数与之前的  (>>?) 函数有些类似:它是把函数链接起来的“胶水”。

-- file: ch10/Parse.hs
(==>) :: Parse a -> (a -> Parse b) -> Parse b

firstParser ==> secondParser  =  Parse chainedParser
  where chainedParser initState   =
          case runParse firstParser initState of
            Left errMessage ->
                Left errMessage
            Right (firstResult, newState) ->
                runParse (secondParser firstResult) newState

(==>)的函数体很有趣,并且非常的具有技巧性。Parse 类型表示在包装中的一个函数。因为 (==>) 函数让我们把两个 Parse 值链接起来产生第三个,因此它必须返回一个包装中的函数。

这个函数并没做太多工作:它只是创建了一个闭包来记住 firstParser 和 secondParser 的值。

提示
闭包是一个函数和它的执行环境,以及它可以看到的绑定变量的组合。闭包在Haskell中是普通的。例如,(+5) 就是一个闭包。Haskell的实现必须将5作为(+)操作符的第二个参数记录下来,这样返回的函数就可以给任何传进来的参数加5.

这个闭包直到调用 parse 的时候才会被解包并调用。在那时,将会用一个  ParseState来调用。它将会调用  firstParser 并检查它的结果。如果解析失败,闭包也会失败。否则,它将解析的结果和新的  ParseState 传给 secondParser。

这确实非常漂亮且精巧:我们有效的用隐藏参数把 ParseState传递给Parse 链上的下一个解析器。(在之后几章将会再介绍这种类型的代码,所以如果这里的解释看上去不太明白的话不必着急。)

介绍算子

我们现在已经彻底熟悉了 map 函数,它在一个列表的每一个元素上应用一个函数,返回不同类型的列表。

ghci> map (+1) [1,2,3]
[2,3,4]
ghci> map show [1,2,3]
["1","2","3"]
ghci> :type map show
map show :: (Show a) => [a] -> [String]

像map一类的行为在其他情况下也很有用。比如,考虑一个二叉树。

-- file: ch10/TreeMap.hs
data Tree a = Node (Tree a) (Tree a)
            | Leaf a
              deriving (Show)

如果我们想取一个字符串的树,将它转成一个包含这些字符串长度的树,我们可以写一个这样的函数来做。

-- file: ch10/TreeMap.hs
treeLengths (Leaf s) = Leaf (length s)
treeLengths (Node l r) = Node (treeLengths l) (treeLengths r)

现在我们已经能找出适合变成通用函数的模式,这里就可以看到一个例子。

-- file: ch10/TreeMap.hs
treeMap :: (a -> b) -> Tree a -> Tree b
treeMap f (Leaf a)   = Leaf (f a)
treeMap f (Node l r) = Node (treeMap f l) (treeMap f r)

就像我们期望的那样,treeLengths 和 treeMap length 获得相同的结果。

ghci> let tree = Node (Leaf "foo") (Node (Leaf "x") (Leaf "quux"))
ghci> treeLengths tree
Node (Leaf 3) (Node (Leaf 1) (Leaf 4))
ghci> treeMap length tree
Node (Leaf 3) (Node (Leaf 1) (Leaf 4))
ghci> treeMap (odd . length) tree
Node (Leaf True) (Node (Leaf True) (Leaf False))


Haskell提供了一个众所周知的类型类来更加通用化treeMap。这个类型类名为 Functor,它定义了一个函数 fmap。

-- file: ch10/TreeMap.hs
class Functor f where
    fmap :: (a -> b) -> f a -> f b

可以把 fmap 相像成一种提升函数,在“使用提升避免模板代码”一节介绍过。它取一个普通值 a->b 上的函数,并把它提升成处理容器类型的 f a -> f b。

如果我们用Tree类型替换类型变量f,那么fmap的类型就和treeMap一样了,实际上,我们可以用 treeMap 作为 Tree 上的fmap实现。

-- file: ch10/TreeMap.hs
instance Functor Tree where
    fmap = treeMap

也可以用map 作为列表的fmap实现。

-- file: ch10/TreeMap.hs
instance Functor [] where
    fmap = map

现在可以在不同的容器类型上使用 fmap。

ghci> fmap length ["foo","quux"]
[3,4]
ghci> fmap length (Node (Leaf "Livingstone") (Leaf "I presume"))
Node (Leaf 11) (Leaf 9)

Prelude 模块中定义了一些常用类型的Functor 实例,尤其是列表和Maybe。

-- file: ch10/TreeMap.hs
instance Functor Maybe where
    fmap _ Nothing  = Nothing
    fmap f (Just x) = Just (f x)

Maybe的实例清楚的显示了 fmap 的实现需要做什么。实现必须对每一个类型的构建子都具有有意义的行为。比如,一个值包装在Just里,fmap的实现在未包装的值上调用一个函数,并把结果重新用Just包装。

Functor的定义对 fmap 强加了一些明显的限制。例如,我们只能给正好具有一个类型参数的类型定义Functor实例。

因此我们不能给 Either a b 或者 (a, b) 类型写个 fmap 实现,因为这些类型具有两个类型参数。也不能给 Bool 或者 Int 写个fmap,因为它们没有类型参数。

另外,不能在类定义上加任何限制。这是什么意思呢?为了演示,先来看看普通的数据定义和它的Functor实例。

-- file: ch10/ValidFunctor.hs
data Foo a = Foo a
           
instance Functor Foo where
    fmap f (Foo a) = Foo (f a)

当定义一个新的类型时,可以在data关键字后面加一个类型限制。

-- file: ch10/ValidFunctor.hs
data Eq a => Bar a = Bar a

instance Functor Bar where
    fmap f (Bar a) = Bar (f a)

这就是说我们只能用 Eq 类型类的成员来放入 Foo中。但是,这个限制导致不能给Bar 写一个 Functor 实例。

ghci> :load ValidFunctor
[1 of 1] Compiling Main             ( ValidFunctor.hs, interpreted )

ValidFunctor.hs:13:12:
    Could not deduce (Eq a) from the context (Functor Bar)
      arising from a use of `Bar' at ValidFunctor.hs:13:12-16
    Possible fix:
      add (Eq a) to the context of the type signature for `fmap'
    In the pattern: Bar a
    In the definition of `fmap': fmap f (Bar a) = Bar (f a)
    In the definition for method `fmap'

ValidFunctor.hs:13:21:
    Could not deduce (Eq b) from the context (Functor Bar)
      arising from a use of `Bar' at ValidFunctor.hs:13:21-29
    Possible fix:
      add (Eq b) to the context of the type signature for `fmap'
    In the expression: Bar (f a)
    In the definition of `fmap': fmap f (Bar a) = Bar (f a)
    In the definition for method `fmap'
Failed, modules loaded: none.

类型定义中的限制是不好的

给类型定义增加一个限制基本上从来不是好主意。它会强迫所有使用这种类型的值的函数都必须加上类型限制。比如我们需要一种stack数据结构,它可以查询其元素是否遵守某种顺序的。这里是这个数据类型的简单定义。

-- file: ch10/TypeConstraint.hs
data (Ord a) => OrdStack a = Bottom
                           | Item a (OrdStack a)
                             deriving (Show)

如果我们想写一个函数来检查这个stack是否是升序的(也就是每个元素都比它下面的元素大),显然我们需要增加 Ord 限制来做两两比较。

-- file: ch10/TypeConstraint.hs
isIncreasing :: (Ord a) => OrdStack a -> Bool
isIncreasing (Item a rest@(Item b _))
    | a < b     = isIncreasing rest
    | otherwise = False
isIncreasing _  = True

然而,由于我们在类型定义上写了类型限制,这个限制最终会传播到并不需要它的地方:必须给 push 增加 Ord 限制,它本来并不关心stack中的元素的顺序。

-- file: ch10/TypeConstraint.hs
push :: (Ord a) => a -> OrdStack a -> OrdStack a
push a s = Item a s

尝试去掉上面的Ord限制,push的定义将通不过类型检查。

这就是为什么前面尝试写Bar 的Functor实例会失败:它要求给 fmap 的类型签名上增加 Eq 限制。

现在我们可以暂时确定在类型定义上增加类型限制是Haskell的错误特性,更明智的替代方法是什么呢?答案是简单的在类型定义中省略掉类型限制,并在需要它的函数中使用类型限制。

在这个例子里,可以在OrdStack 和 push里面去掉 Ord 限制。在 isIncreasing 里面要保留,否则就不能调用 (<)。现在只有在真正关心的地方有类型限制。这样进一步的好处是类型签名更能反应每一个函数的真是需求。

Haskell大部分容器类型遵循这个模式。Data.Map模块中的  Map 类型需要它的键是有序的,但是它的类型本身并没有这样的限制。它的类型限制是在像 insert 这样实际需要的函数中表示的,而在 size 这些不需要有序的函数中并没有。

fmap 的中缀使用

你经常会看到 fmap 作为操作符调用。

ghci> (1+) `fmap` [1,2,3] ++ [4,5,6]
[2,3,4,4,5,6]

或许有些奇怪,普通的map函数几乎从不这样用。

把 fmap 当作操作符使用的一个可能的原因是它可以省略掉它的第二个参数的括号。在读函数的定义时更少的括号可以减少错觉。
??
One possible reason for the stickiness of the fmap-as-operator meme is that this use lets us omit parentheses from its second argument. Fewer parentheses leads to reduced mental juggling while reading a function. 2 comments

ghci> fmap (1+) ([1,2,3] ++ [4,5,6])
[2,3,4,5,6,7]

如果确实想把 fmap当作操作符,Control.Applicative 模块中包含一个操作符 (<$>) ,它是fmap的别名。它的名字中的 $ 说明了下面两者间的相似性:用 ($) 操作符将函数应用到它的参数上,和把函数提升成一个算子。我们会看到这在我们写的解析代码中工作良好。

灵活的实例

你可能希望给 Either Int b 类型写一个Functor实例,它只有一个类型参数。

-- file: ch10/EitherInt.hs
instance Functor (Either Int) where
    fmap _ (Left n) = Left n
    fmap f (Right r) = Right (f r)

然而,Haskell 98 的类型系统不保证检查这样的实例的约束时会中止。一个不中止的约束检查可能会使编译器进入无限循环,所以这种形式的实例是禁止的。

ghci> :load EitherInt
[1 of 1] Compiling Main             ( EitherInt.hs, interpreted )

EitherInt.hs:2:0:
    Illegal instance declaration for `Functor (Either Int)'
        (All instance types must be of the form (T a1 ... an)
         where a1 ... an are distinct type *variables*
         Use -XFlexibleInstances if you want to disable this.)
    In the instance declaration for `Functor (Either Int)'
Failed, modules loaded: none.

GHC具有具有比基础的 Haskell 98 标准更强大的类型系统。为了最大的可移植性,在默认情况下它使用Haskell 98 兼容模式。我们可以使用特殊的编译指令来命令它允许更弹性的实例。

-- file: ch10/EitherIntFlexible.hs
{-# LANGUAGE FlexibleInstances #-}

instance Functor (Either Int) where
    fmap _ (Left n)  = Left n
    fmap f (Right r) = Right (f r)

编译指令嵌入在特别的 LANGUAGE 指令字中。

有了Functor实例,让我们在 Either Int 上试一下 fmap。

ghci> :load EitherIntFlexible
[1 of 1] Compiling Main             ( EitherIntFlexible.hs, interpreted )
Ok, modules loaded: Main.
ghci> fmap (== "cheeseburger") (Left 1 :: Either Int String)
Left 1
ghci> fmap (== "cheeseburger") (Right "fries" :: Either Int String)
Right False

算子的更多考虑

我们对算子应该如何工作做了些隐含的假定。把这些假定弄清晰并把它们当作规则来遵守是有帮助的,因为这可以让我们把算子作为一致的执行正确的对象。我们只需要记住两条简单的规则。

第一条规则是算子必须保持同一性。也就是说在应用 fmap id 到一个值时应当返回这个值本身。

ghci> fmap id (Node (Leaf "a") (Leaf "b"))
Node (Leaf "a") (Leaf "b")

第二个规则是算子必须可以组合。也就是说把两次fmap应用组合起来,与组合两个函数并用一次fmap 的结果相同。

ghci> (fmap even . fmap length) (Just "twelve")
Just True
ghci> fmap (even . length) (Just "twelve")
Just True

对这两个规则的另外看法是算子必须保持形状。算子不能影响集合的结构;只有集合里面的值可以改变。

ghci> fmap odd (Just 1)
Just True
ghci> fmap odd Nothing
Nothing

如果你在写一个Functor的实例,记住这些规则是有用的,并且要确实测试它们,因为编译期是不会检查上面列出的这些规则的。另外,如果只是简单的使用算子,这些规则非常“自然”,不必去记它。它们只是“按我的意思做”的直觉概念的规范化。这里是想要的行为的伪代码表示。

-- file: ch10/FunctorLaws.hs
fmap id       ==  id
fmap (f . g)  ==  fmap f . fmap g

给Parse写一个算子实例

对于我们现在讨论过的类型,我们希望fmap具有的行为已经很明显了。Parse不是那么清楚,因为它比较复杂。一个合理的猜测是 fmap 时的函数应该应用到当前的解析结果上,而不碰解析状态。

-- file: ch10/Parse.hs
instance Functor Parse where
    fmap f parser = parser ==> \result ->
                    identity (f result)

这个定义比较好读,我们来做些快速的检查看看它是否符合我们关于算子的规则。

首先检查是否保持同一性。我们先来用一个应该失败的 parse 来测试:从一个空字符串中解析一个字节(<$>就是 fmap)。

ghci> parse parseByte L.empty
Left "byte offset 0: no more input"
ghci> parse (id <$> parseByte) L.empty
Left "byte offset 0: no more input"

好的。现在试一个应该成功的parse。

ghci> let input = L8.pack "foo"
ghci> L.head input
102
ghci> parse parseByte input
Right 102
ghci> parse (id <$> parseByte) input
Right 102

通过检查上面的结果,我们也能够看到我们的算子实例也遵守第二条规则:保持形状。失败保持为失败,而成功被保持为成功。

最后,我们要确定可以保持组合。

ghci> parse ((chr . fromIntegral) <$> parseByte) input
Right 'f'
ghci> parse (chr <$> fromIntegral <$> parseByte) input
Right 'f'

以这些简明的检查为基础,我们的Functor实例可以说是运行良好的。

用算子进行解析

对算子的这些讨论有一个目的:它们让我们写出干净富有表达能力的代码。回想早前介绍的 parseByte 函数。在用新的解析器结构来重做 PGM 解析器的时候,我们经常希望用 ASCII 字符来代替 Word8 值。

我们可以写一个 parseChar 函数,它与 parseByte 结构相似,现在可以通过 Parse 算子天然的优点来避免重复的代码。我们的算子在一个解析的结果上应用函数,这样我们需要做的就是写一个函数来把 Word8 转换成 Char。

-- file: ch10/Parse.hs
w2c :: Word8 -> Char
w2c = chr . fromIntegral

-- import Control.Applicative
parseChar :: Parse Char
parseChar = w2c <$> parseByte

也可以用算子来写一个紧凑的“peek查看”函数。在输入字符串的末尾则返回 Nothing。否则将返回下一个字符而不会消耗掉它(就是说它可以查看但是不打扰当前的解析状态)。

-- file: ch10/Parse.hs
peekByte :: Parse (Maybe Word8)
peekByte = (fmap fst . L.uncons . string) <$> getState

与parseChar中相同的提升技巧可以让我们写出紧凑的 peekChar 定义。

-- file: ch10/Parse.hs
peekChar :: Parse (Maybe Char)
peekChar = fmap w2c <$> peekByte

注意 peekByte 和 peekChar 每一个都调用了两次 fmap,其中一个伪装成 (<$>)。这是必须的因为 Parse (Maybe a) 的类型是一个算子的算子。这样我们必须将一个函数提升两次才能“进到”算子内部。

最后,我们写另一个常用的组合子,它是Parse的类似takeWhile的东西:它消耗输入直到判定条件返回 True。

-- file: ch10/Parse.hs
parseWhile :: (Word8 -> Bool) -> Parse [Word8]
parseWhile p = (fmap p <$> peekByte) ==> \mp ->
               if mp == Just True
               then parseByte ==> \b ->
                    (b:) <$> parseWhile p
               else identity []

我们再一次把算子用在几个(需要的话会加倍)地方来减少代码的冗余。这里是不用算子而直接写的相同的函数。

-- file: ch10/Parse.hs
parseWhileVerbose p =
    peekByte ==> \mc ->
    case mc of
      Nothing -> identity []
      Just c | p c ->
                 parseByte ==> \b ->
                 parseWhileVerbose p ==> \bs ->
                 identity (b:bs)
             | otherwise ->
                 identity []

如果你不太熟悉算子的话,这个更冗长的定义可能更容易读。然而,在Haskell里充分的使用了算子,这些更紧凑的表示很快就会显得更加自然(读和写都是如此)。

重写PGM解析器

用新的解析代码, raw PGM解析函数会变成什么样子呢?

-- file: ch10/Parse.hs
parseRawPGM =
    parseWhileWith w2c notWhite ==> \header -> skipSpaces ==>&
    assert (header == "P5") "invalid raw header" ==>&
    parseNat ==> \width -> skipSpaces ==>&
    parseNat ==> \height -> skipSpaces ==>&
    parseNat ==> \maxGrey ->
    parseByte ==>&
    parseBytes (width * height) ==> \bitmap ->
    identity (Greymap width height maxGrey bitmap)
  where notWhite = (`notElem` " \r\n\t")

这个定义用了下面一些辅助函数,它的模式我们现在应该熟悉了。

-- file: ch10/Parse.hs
parseWhileWith :: (Word8 -> a) -> (a -> Bool) -> Parse [a]
parseWhileWith f p = fmap f <$> parseWhile (p . f)

parseNat :: Parse Int
parseNat = parseWhileWith w2c isDigit ==> \digits ->
           if null digits
           then bail "no more input"
           else let n = read digits
                in if n < 0
                   then bail "integer overflow"
                   else identity n

(==>&) :: Parse a -> Parse b -> Parse b
p ==>& f = p ==> \_ -> f

skipSpaces :: Parse ()
skipSpaces = parseWhileWith w2c isSpace ==>& identity ()

assert :: Bool -> String -> Parse ()
assert True  _   = identity ()
assert False err = bail err

(==>&)组合子将解析器链接起来,就像 (==>) 一样,但是它右侧忽略掉左侧的结果。 assert 函数检查一个属性,如果属性是 False 则给出一个有用的错误信息后中止解析。

注意我们写的这些函数很少引用到当前的解析状态。最引人注目的是老的 parseP5 函数要显式的把二元组从数据流链上传递,而在 parseRawPGM里所有的状态管理都隐藏起来了。

当然,我们不能完全的避免查看和修改解析状态。这里是它应用的实例,parseRawPGM需要的最后一个辅助函数。

-- file: ch10/Parse.hs
parseBytes :: Int -> Parse L.ByteString
parseBytes n =
    getState ==> \st ->
    let n' = fromIntegral n
        (h, t) = L.splitAt n' (string st)
        st' = st { offset = offset st + L.length h, string = t }
    in putState st' ==>&
       assert (L.length h == n') "end of input" ==>&
       identity h

未来的方向
这一章的主题是抽象。我们发现在函数链上显式的传递状态很不令人满意,所以我们把这些细节抽象出去。我们注意到在写解析的代码时有些重复的代码,把它们抽象成通用函数。同时,我们介绍了算子的概念,它提供了在参数化类型上映射的通用方法。

在第16章《使用Parsec》将再次介绍解析,parsec是应用广泛且灵活的解析库。这第14章《Monad》里,将回到抽象的主题,那时将会看到我们这章写的代码可以通过使用 monad 来进一步简化。


要高效的解析 ByteString 表示的二进制数据,Hackage 软件包数据库中有很多可用的包。在本书写作时,最流行的一个是 binary ,他很易于使用,并且提供很高的性能。

练习

1. 给 "plain" PGM文件写一个解析器。

2. 在介绍 "raw" PGM 文件时,我们忽略了一个小细节。如果在文件头里“最大灰度”值小于256,每个像素就用一个字节表示。然而它的范围可以到 65535,在这时一个像素将用两个字节表示,字节顺序用 big-endian(最重要的字节放前面)。

重写 raw PGM解析器来支持单-双字节像素格式。

3. 扩展解析器来识别PGM文件是raw还是plain格式的,并用适当的类型解析文件。

转载于:https://www.cnblogs.com/IBBC/archive/2011/07/25/2116333.html

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值