在实例中使用类型类(Use of typeclasses):JSON示例来源于《Real World Haskell》

3
我发现Real World Haskell - 第5章非常令人困惑,似乎在第6章中也对我产生了影响。
第6章中,在 Typeclasses at work: making JSON easier to use 之前的一切都很清楚;然后,书中展示了一个JSON文件的部分内容,以及定义了一个变量result来保存(大部分)该JSON示例的Haskell源代码。
随后,参考前面的代码,对比了可以包含不同类型元素的JSON对象和不能包含的Haskell列表。这解释了在前述代码中使用JValue的构造函数(JNumberJBool等)的原因。
到这里为止还好,然后它开始变得对我来说很困惑。
这限制了我们的灵活性:如果我们想将数字3920更改为字符串"3,920",我们必须将用于包装它的构造函数从JNumber更改为JString

是啊,那又怎样?如果我的意图是进行这种更改,我将不得不更改例如此行代码:

("esitmatedCount", JNumber 3920)

到这里

("esitmatedCount", JString "3,920")

这对应于在实际的JSON文件中将3920更改为"3,920"。那又怎样呢?如果我有机会在Haskell列表中放置不同类型,我仍然需要用双引号包装数字并添加逗号。
我不明白灵活性的损失在哪里。
然后提出了一个诱人的解决方案(“诱人”?那么它不好...哪里有缺点?在线书籍中的一些评论提出了同样的问题)。
type JSONError = String

class JSON a where
    toJValue :: a -> JValue
    fromJValue :: JValue -> Either JSONError a

instance JSON JValue where
    toJValue = id
    fromJValue = Right

Now, instead of applying a constructor like JNumber to a value to wrap it, we apply the toJValue function. If we change a value's type, the compiler will choose a suitable implementation of toJValue to use with it.

这让我想到意图是使用toJValue函数代替构造函数JNumber,但我不知道toJValue 3920如何工作。 我该如何利用上面的代码?
1个回答

3
更新:我在文末的一个部分中添加了对您后续评论的回答。
我认为作者在撰写类型类章节时,希望与上一章的示例保持连贯性。他们可能还考虑到了在Haskell中使用类型类处理JSON的实际代码。 (我看到RWH的作者之一Bryan O'Sullivan也是出色的aeson JSON解析库的作者,该库极其有效地使用了类型类。)我认为他们也有点沮丧,因为他们最好的需要类型类的示例(BasicEq)已经被实现,这迫使读者假装语言设计人员遗漏了语言的关键功能,以便看到需要类型类的必要性。他们还意识到,JSON示例足够丰富和复杂,可以引入一些困难的新概念(类型同义词和重叠实例、开放世界假设、newtype包装器等),以用于教学目的。
因此,他们试图将JSON示例添加为一个逼真、相当复杂的示例,与早期材料相关,并可用于教育目的,以介绍一堆新材料。
很不幸,他们意识到例子的动机太弱了,至少没有引入一堆高级新概念和技术。因此,他们咕哝着说了些“缺乏灵活性”的话,仍然前进,最后让这个例子在结尾处消失了,从未真正回到有关如何使用toJValuefromJValue的内容。
以下是一个演示,说明为什么JSON类很有用,它的动机来自于aeson包。请注意,它使用了几个更高级的特性,这些特性还没有在RWH的前五章中介绍过,所以你可能还不能完全理解它。
为了让我们保持一致,假设我们有以下代码,这是第6章类型类和实例的稍微简化版本。下面的代码需要一些额外的语言扩展。
{-# LANGUAGE TypeSynonymInstances, FlexibleInstances, RankNTypes, RecordWildCards #-}

module JSONClass where

data JValue = JString String
            | JNumber Double
            | JBool Bool
            | JNull
            | JObject [(String, JValue)]
            | JArray [JValue]
  deriving (Show)

class JSON a where
  toJValue :: a -> JValue
  fromJValue :: JValue -> Maybe a
instance JSON Bool where
  toJValue = JBool
  fromJValue (JBool b) = Just b
  fromJValue _ = Nothing
instance {-# OVERLAPPING #-} JSON String where
  toJValue = JString
  fromJValue (JString s) = Just s
  fromJValue _ = Nothing
instance JSON Double where
  toJValue = JNumber
  fromJValue (JNumber x) = Just x
  fromJValue _ = Nothing
instance {-# OVERLAPPABLE #-} (JSON a) => JSON [a] where
  toJValue = JArray . map toJValue
  fromJValue (JArray vals) = mapM fromJValue vals
  fromJValue _ = Nothing

假设我们有一些Haskell数据类型,表示搜索结果,这些类型是根据“Typeclasses at work”部分中给出的“result”示例模式化的:
data Search = Search
  { query :: String
  , estimatedCount :: Double
  , moreResults :: Bool
  , results :: [Result]
  } deriving (Show)
data Result = Result
  { title :: String
  , snippet :: String
  , url :: String
  } deriving (Show)

将它们转换为JSON格式会很好。使用RecordWildCards扩展可以将参数的字段“扩展”为单独的变量,我们可以编写出非常清晰的代码:

resultToJValue :: Result -> JValue
resultToJValue Result{..}
  = JObject [("title", JString title), ("snippet", JString snippet), ("url", JString url)]
searchToJValue :: Search -> JValue
searchToJValue Search{..}
  = JObject [("query", JString query),
             ("estimatedCount", JNumber estimatedCount),
             ("moreResults", JBool moreResults),
             ("results", JArray $ map resultToJValue results)]

构造函数有点混乱。我们可以通过用toJValue替换一些构造函数来“简化”这个过程,如下所示:

resultToJValue :: Result -> JValue
resultToJValue Result{..}
  = JObject [("title", toJValue title), ("snippet", toJValue snippet),
                 ("url", toJValue url)]
searchToJValue :: Search -> JValue
searchToJValue Search{..}
  = JObject [("query", toJValue query),
             ("estimatedCount", toJValue estimatedCount),
             ("moreResults", toJValue moreResults),
             ("results", JArray $ map resultToJValue results)]

你可以轻易地争论这并没有减少混乱。然而,类型类允许我们定义一个帮助函数:
(.=) :: (JSON a) => String -> a -> (String, JValue)
infix 0 .=
k .= v = (k, toJValue v)

它引入了一种漂亮、简洁的语法:

resultToJValue :: Result -> JValue
resultToJValue Result{..}
  = JObject [ "title" .= title
            , "snippet" .= snippet
            , "url" .= url ]
searchToJValue :: Search -> JValue
searchToJValue Search{..}
  = JObject [ "query" .= query
            , "estimatedCount" .= estimatedCount
            , "moreResults" .= moreResults
            , ("results", JArray $ map resultToJValue results)]

最后一行看起来不美观的唯一原因是我们没有为 Result 提供它的 JSON 实例:

instance JSON Result where
  toJValue = resultToJValue

这将允许我们编写:

searchToJValue :: Search -> JValue
searchToJValue Search{..}
  = JObject [ "query" .= query
            , "estimatedCount" .= estimatedCount
            , "moreResults" .= moreResults
            , "results" .= results ]

事实上,我们根本不需要函数resultToJValuesearchToJValue,因为它们的定义可以直接在实例中给出。因此,在SearchResult数据类型的定义之后,上面的所有代码都可以折叠成:
(.=) :: (JSON a) => String -> a -> (String, JValue)
infix 0 .=
k .= v = (k, toJValue v)

instance JSON Result where
  toJValue Result{..}
    = JObject [ "title" .= title
              , "snippet" .= snippet
              , "url" .= url ]
instance JSON Search where
  toJValue Search{..}
    = JObject [ "query" .= query
              , "estimatedCount" .= estimatedCount
              , "moreResults" .= moreResults
              , "results" .= results ]

提供支持的功能:

search = Search "awkward squad haskell" 3920 True
           [ Result "Simon Peyton Jones: papers"
                    "Tackling the awkward squad..."
                    "http://..."
           ]

main = print (toJValue search)

将JSON中的JValue转换回Result和Search怎么样?您可以尝试写出不使用类型类的解决方案并查看其效果。使用类型类的解决方案需要使用一个令人费解的辅助函数(需要使用RankNTypes语言扩展)。
withObj :: (JSON a) => JValue ->
           ((forall v. JSON v => String -> Maybe v) -> Maybe a) -> Maybe a
withObj (JObject lst) template = template v
  where v k = fromJValue =<< lookup k lst

然后,可以使用应用语法(<$><*>)轻松编写实例,这使我们能够将一堆Maybe值组合为函数调用的参数,如果任何一个参数是Nothing(即JSON中的意外类型),则返回Nothing并调用函数:

instance JSON Result where
  fromJValue o = withObj o $ \v -> Result <$> v "title" <*> v "snippet" <*> v "url"
instance JSON Search where
  fromJValue o = withObj o $ \v -> Search <$> v "query" <*> v "estimatedCount"
    <*> v "moreResults" <*> v "results"

没有类型类,使用辅助函数(.=)和withObj对不同字段类型进行统一处理是不可能的,编写这些编组函数的最终语法将会更加复杂。
在RWH第6章中无法直接引入此示例,因为它涉及应用程序(<*>语法),更高级别的类型(withObj),以及可能有其他我已经忘记的东西。我不确定它是否可以简化到足以使使用类型类的优点变得清晰,并且最终语法看起来足够好看。
无论如何,这是完整代码。您可能需要浏览aeson包的文档,以了解基于此方法的真正库是什么样子。
{-# LANGUAGE TypeSynonymInstances, FlexibleInstances, RankNTypes, RecordWildCards #-}

module JSONClass where

-- JSON type
data JValue = JString String
            | JNumber Double
            | JBool Bool
            | JNull
            | JObject [(String, JValue)]
            | JArray [JValue]
  deriving (Show)

-- Type classes and instances
class JSON a where
  toJValue :: a -> JValue
  fromJValue :: JValue -> Maybe a
instance JSON Bool where
  toJValue = JBool
  fromJValue (JBool b) = Just b
  fromJValue _ = Nothing
instance {-# OVERLAPPING #-} JSON String where
  toJValue = JString
  fromJValue (JString s) = Just s
  fromJValue _ = Nothing
instance JSON Double where
  toJValue = JNumber
  fromJValue (JNumber x) = Just x
  fromJValue _ = Nothing
instance {-# OVERLAPPABLE #-} (JSON a) => JSON [a] where
  toJValue = JArray . map toJValue
  fromJValue (JArray vals) = mapM fromJValue vals
  fromJValue _ = Nothing

-- helpers
(.=) :: (JSON a) => String -> a -> (String, JValue)
infix 0 .=
k .= v = (k, toJValue v)
withObj :: (JSON a) => JValue ->
           ((forall v. JSON v => String -> Maybe v) -> Maybe a) -> Maybe a
withObj (JObject lst) template = template v
  where v k = fromJValue =<< lookup k lst

-- our new data types
data Search = Search
  { query :: String
  , estimatedCount :: Double
  , moreResults :: Bool
  , results :: [Result]
  } deriving (Show)
data Result = Result
  { title :: String
  , snippet :: String
  , url :: String
  } deriving (Show)

-- JSON instances to marshall them in and out of JValues
instance JSON Result where
  toJValue Result{..}
    = JObject [ "title" .= title
              , "snippet" .= snippet
              , "url" .= url ]
  fromJValue o = withObj o $ \v -> Result <$> v "title" <*> v "snippet" <*> v "url"
instance JSON Search where
  toJValue Search{..}
    = JObject [ "query" .= query
              , "estimatedCount" .= estimatedCount
              , "moreResults" .= moreResults
              , "results" .= results ]
  fromJValue o = withObj o $ \v -> Search <$> v "query" <*> v "estimatedCount"
    <*> v "moreResults" <*> v "results"

-- a test
search :: Search
search = Search "awkward squad haskell" 3920 True
           [ Result "Simon Peyton Jones: papers"
                    "Tackling the awkward squad..."
                    "http://..."
           ]
main :: IO ()
main = do
  let jsonSearch = toJValue search
  print jsonSearch
  let search' = fromJValue jsonSearch :: Maybe Search
  print search'

评论问题的答案

您在评论中提出了一堆后续问题。我尝试在此处回答它们,稍微按不同顺序:

问:书中使用Either,而您使用Maybe。我会说这只是因为您使用Nothing表示出现错误,而书中建议使用解释性的String来说明错误的详细信息。好吧,但是书中对toJValuefromJValue的定义与您的定义差异很大:我看不出toJValue = id如何有用,因为基于id的签名,输入和输出的类型不能不同; 而fromJValue给定任何JValue都会返回Right,而您则将其拆解以返回其中包装的Haskell类型。

A: 是的,我在示例中使用了 Maybe 而不是 Either 来表示错误,只是因为我认为这样会使我的示例简单一些。该书在“更有帮助的错误”部分讨论了这一点,指出也可以使用 Maybe,但是 Either 可以提供更有帮助的错误消息:该书的 Left 类似于我的 Nothing,但还带有额外的解释说明。

也许我的简化计划失败了,因为我的版本应该看起来类似于该书的版本。我认为您只是在比较错误的实例。首先考虑 class 定义:

-- from book
class JSON a where
    toJValue :: a -> JValue
    fromJValue :: JValue -> Either JSONError a
-- mine
class JSON a where
  toJValue :: a -> JValue
  fromJValue :: JValue -> Maybe a

这里唯一的差别是书中版本的 fromJValue 可以返回 Left errmsgRight answer,而我的版本则可以返回 NothingJust answer。对于特定实例,比如 Bool 实例,我们有:
-- from book
instance JSON Bool where
    toJValue = JBool
    fromJValue (JBool b) = Right b
    fromJValue _ = Left "not a JSON boolean"
-- mine
instance JSON Bool where
  toJValue = JBool
  fromJValue (JBool b) = Just b
  fromJValue _ = Nothing

再次比较这些内容,除了Right变成了Just以及Left "message"变成了Nothing之外,它们是匹配的。我认为让你感到困惑的是书中为JValue类型定义了这个额外实例:

instance JSON JValue where
    toJValue = id
    fromJValue = Right

这个实例很奇怪,与所有其他实例都不同。所有其他实例涉及将其他Haskell类型转换为相应的JValue表示形式,而这个实例则“翻译”JValue本身。因此,toJValue只是id,因为实际上不需要任何转换。对于fromJValue,我们也会想使用id,但通用的fromJValue函数允许通过返回Left errmsg(或我的版本中的Nothing)来失败,如果类型不匹配。但是,JValue始终是“翻译”为JValue的正确类型,因此我们始终可以返回Right答案。我的版本如下:

instance JSON JValue where
    toJValue = id
    fromJValue = Just  -- use Just instead of Right; we never return Nothing

问:此外,您在每个实例中都放置了fromJValue _ = Nothing,而书中甚至没有提到Left,直到“更有用的错误”之前。也许我需要继续阅读一些内容,因为我希望理解您回答的一半。

答:嗯,书中只介绍了“更有用的错误”部分之前的一个实例,即instance JSON JValue。我的版本不需要Nothing,就像书中不需要Left一样。只要我们开始定义可能失败的实例,那么我们就需要LeftNothing

问:另一个问题涉及mapM:为什么要使用它?我明白valsJArray构造函数的有效列表输入(否则我们不能在解构它的行上,对吧?),因此将fromJValue应用于列表的每个元素应该返回一个Maybe(全部为Just,对吧?)正如函数签名所需。

A: 是的,你说得对。只是你漏掉了一步。为了具体说明,假设我们正在尝试从JSON值中读取Haskell双精度列表([Double]),例如:

JArray [JNumber 1.0, JNumber 2.0, JNumber 3.0]

所以我们有vals = [JNumber 1.0, JNumber 2.0, JNumber 3.0]。正如您所说,我们想将fromJValue应用于此列表的每个元素。如果我们使用map这样做,例如:
map fromJValue vals

我们会得到:

[Just 1.0, Just 2.0, Just 3.0] :: [Maybe Double]

但是该返回值实际上与类型签名不匹配。那是一个[Maybe Double]值,但我们想要一个更像Maybe [Double]的值,就像这样:
Just [1.0, 2.0, 3.0] :: Maybe [Double]

mapM 的目的是将 "Just" 从列表中提取出来。它还有第二个目的。如果我们尝试读取像这样的列表:

[JNumber 1.0, JNumber 2.0, JString "three point zero"]

然后应用 map fromJValue vals(在已将 fromJValue 专门化为 Double 实例的上下文中),将会得到:

[Just 1.0, Just 2.0, Nothing] :: [Maybe Double]

在这里,尽管一些元素已成功转换为双精度浮点数,但仍有一些无法转换,因此我们实际上希望通过将整个内容转换为最终结果来指示总体失败:

Nothing :: Maybe [Double]

mapM 函数是一个通用的 monadic map,但对于我正在使用的特定 monad(Maybe monad),它具有以下签名:

mapM :: (a -> Maybe b) -> [a] -> Maybe [b]

这个概念最好理解为使用一个函数 a -> Maybe b,该函数可以通过返回Just来“成功”,或者通过返回Nothing来“失败”,并将该函数应用于列表[a]。如果所有应用都成功,则返回结果列表的Just值(将Just从列表中提取出来);如果任何一个失败,则返回全局Nothing失败值。

实际上,这和 RWH 中的函数 mapEithers 思路一样。该函数将函数应用于列表 [a],如果所有函数都成功(通过返回 Right),则返回结果列表的 Right 值(将 Right 拉出列表);如果任何函数失败(通过返回 Left),则返回一个 Left 失败值(使用遇到的“第一个”错误消息,如果生成了多个错误)。事实上,mapEithers 不需要定义。它可以被 mapM 替换,因为 mapM 适用于 Maybe 和 Either errmsg monad,并且对于 Either errmsg monad 具有与 mapEithers 相同的行为。

问: 关于JNull,它是唯一一个不带任何参数的构造函数,因此没有什么可以拆解的东西,因此也没有类型可以成为JSON的实例。 这如何符合总体情况?

答: 最直接的翻译JNull的方式是将其翻译成一个不包含任何信息的Haskell类型。 实际上确实有这样一种类型。 它被命名为“unit”,在源代码中写作()。 你可能已经在各种上下文中看到过它的使用。 相应的实例应该是:

instance JSON () where
  toJValue () = JNull
  fromJValue JNull = Just ()
  fromJValue _ = Nothing

我没有包含这个实例,因为它相当无用。 它只对将某个字段始终具有显式值null的JSON进行转换时有帮助,但这不是JSON中使用null的方式。
冒着使事情更加复杂的风险,这里有一个JNull的潜在用途。 假设我想为我的Result类型添加一个可选字段,用于“收藏夹图标”或其他内容的URL。
data Result = Result
  { title :: String
  , snippet :: String
  , url :: String
  , favicon :: Maybe String   -- new, optional field
  } deriving (Show)

现在,我的美丽的实例JSON结果已经损坏了,因为我没有处理Maybe String字段的方法。但是,我可以引入一个新的实例来处理Maybe值,使用JNull作为Nothing的等价物:
instance JSON a => JSON (Maybe a) where
  toJValue Nothing = JNull
  toJValue (Just x) = toJValue x
  fromJValue JNull = Just Nothing
  fromJValue x = Just <$> fromJValue x

这里发生了许多复杂的事情,我不会尝试解释所有内容。然而,理解fromJValue的返回值是相当奇怪的类型Maybe (Maybe a)可能有所帮助,其中两个Maybe具有不同的目的:外部Maybe表示转换是否成功,而内部Maybe表示可选值是否可用或缺失。因此,Just Nothing是一个代表成功转换为丢失值的值!
该实例的重点在于,我们可以更新Result实例以包括新字段:
instance JSON Result where
  toJValue Result{..}
    = JObject [ "title" .= title
              , "snippet" .= snippet
              , "url" .= url
              , "favicon" .= favicon ]
  fromJValue o = withObj o $ \v -> Result <$> v "title" <*> v "snippet"
                                   <*> v "url" <*> v "favicon"

现在,该实例(在某种程度上)可以处理可选值:

> toJValue (Result "mytitle" "mysnippet" "myurl" Nothing)
JObject [("title",JString "mytitle"),("snippet",JString "mysnippet"),("url",JString "myurl"),("favicon",JNull)]
> toJValue (Result "mytitle" "mysnippet" "myurl" (Just "myfavicon"))
JObject [("title",JString "mytitle"),("snippet",JString "mysnippet"),("url",JString "myurl"),("favicon",JString "myfavicon")]

尽管在生成真实的JSON时,您可能会省略任何看起来像{ ..., favicon: null, ... }的字段,因此您需要引入一些过滤器来从最终的JSON值中删除空字段。另外,fromJValue实际上并没有处理一个真正的缺失可选字段:
> fromJValue (JObject [("title",JString "mytitle"),("snippet",JString "mysnippet"),("url",JString "myurl")]) :: Maybe Result
Nothing

相反,它需要明确的JNull才能正常工作:

> fromJValue (JObject [("title",JString "mytitle"),("snippet",JString "mysnippet"),("url",JString "myurl"),("favicon",JNull)]) :: Maybe Result
Just (Result {title = "mytitle", snippet = "mysnippet", url = "myurl", favicon = Nothing})

所以我们需要进行更多编程,才能让它正常工作。


尽管我现在不得不放弃你回答的第二部分(_将JSON JValue转换回ResultSearch?_),因为它涉及太多我不知道的东西,但基于第一部分,经过仔细阅读,我可以说这个答案似乎非常清晰,因此我给了一个+1。我对第一部分有一些疑问,在下面的评论中列出。 - Enlico
其中一个问题涉及到JNull,它是唯一一个不带任何参数的构造函数,因此没有什么可以解构的,也就没有类型可以成为JSON的实例。这如何符合整体情况?另一个问题涉及到mapM:为什么要使用它?我理解vals是输入到JArray构造函数的有效列表(否则我们不能在解构它的行上),因此将列表中的每个元素应用于fromJValue应该返回一个Maybe(全部都是Just,对吧?)正如函数签名所要求的那样。 - Enlico
哦,还有一个疑问。书中使用了 Either,而你使用了 Maybe。我想这只是因为你在使用 Nothing 来表示错误时,而书中建议使用解释性的 String 来提供关于“错误”的详细信息。好吧,但是书中对 toJValuefromJValue 的定义与你的定义非常不同:我无法看出 toJValue = id 如何有用,因为根据 id 的签名,输入和输出的类型不能不同;而 fromJValue 给定任何 JValue 都会返回 Right,而你则拆解以返回其中包装的 Haskell 类型。 - Enlico
此外,在每个实例中,您都将 fromJValue _ = Nothing 放置其中,而书中甚至没有提到 Left,直到 _More Helpful Errors_。也许我需要继续阅读一下,因为理解您回答的一半内容有希望在我的理解上解开了某些东西。 - Enlico
我在答案的末尾加了一节来尝试回答这些问题。 - K. A. Buhr
除了接受答案,我还能说什么呢?我将来经常需要回到这个问题。非常感谢你付出的努力来编写这篇文章。我希望它对许多其他人有用。 - Enlico

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接