更新:我在文末的一个部分中添加了对您后续评论的回答。
我认为作者在撰写类型类章节时,希望与上一章的示例保持连贯性。他们可能还考虑到了在Haskell中使用类型类处理JSON的实际代码。 (我看到RWH的作者之一Bryan O'Sullivan也是出色的aeson JSON解析库的作者,该库极其有效地使用了类型类。)我认为他们也有点沮丧,因为他们最好的需要类型类的示例(BasicEq)已经被实现,这迫使读者假装语言设计人员遗漏了语言的关键功能,以便看到需要类型类的必要性。他们还意识到,JSON示例足够丰富和复杂,可以引入一些困难的新概念(类型同义词和重叠实例、开放世界假设、newtype包装器等),以用于教学目的。
因此,他们试图将JSON示例添加为一个逼真、相当复杂的示例,与早期材料相关,并可用于教育目的,以介绍一堆新材料。
很不幸,他们意识到例子的动机太弱了,至少没有引入一堆高级新概念和技术。因此,他们咕哝着说了些“缺乏灵活性”的话,仍然前进,最后让这个例子在结尾处消失了,从未真正回到有关如何使用
toJValue
或
fromJValue
的内容。
以下是一个演示,说明为什么
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 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 (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 ]
事实上,我们根本不需要函数
resultToJValue
和
searchToJValue
,因为它们的定义可以直接在实例中给出。因此,在
Search
和
Result
数据类型的定义之后,上面的所有代码都可以折叠成:
(.=) :: (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
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 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 (JSON a) => JSON [a] where
toJValue = JArray . map toJValue
fromJValue (JArray vals) = mapM fromJValue vals
fromJValue _ = Nothing
(.=) :: (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
data Search = Search
{ query :: String
, estimatedCount :: Double
, moreResults :: Bool
, results :: [Result]
} deriving (Show)
data Result = Result
{ title :: String
, snippet :: String
, url :: String
} deriving (Show)
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"
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
来说明错误的详细信息。好吧,但是书中对toJValue
和fromJValue
的定义与您的定义差异很大:我看不出toJValue = id
如何有用,因为基于id
的签名,输入和输出的类型不能不同; 而fromJValue
给定任何JValue
都会返回Right
,而您则将其拆解以返回其中包装的Haskell类型。
A: 是的,我在示例中使用了
Maybe
而不是
Either
来表示错误,只是因为我认为这样会使我的示例简单一些。该书在“更有帮助的错误”部分讨论了这一点,指出也可以使用
Maybe
,但是
Either
可以提供更有帮助的错误消息:该书的
Left
类似于我的
Nothing
,但还带有额外的解释说明。
也许我的简化计划失败了,因为我的版本应该看起来类似于该书的版本。我认为您只是在比较错误的实例。首先考虑 class
定义:
class JSON a where
toJValue :: a -> JValue
fromJValue :: JValue -> Either JSONError a
class JSON a where
toJValue :: a -> JValue
fromJValue :: JValue -> Maybe a
这里唯一的差别是书中版本的
fromJValue
可以返回
Left errmsg
或
Right answer
,而我的版本则可以返回
Nothing
或
Just answer
。对于特定实例,比如
Bool
实例,我们有:
instance JSON Bool where
toJValue = JBool
fromJValue (JBool b) = Right b
fromJValue _ = Left "not a JSON boolean"
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
问:此外,您在每个实例中都放置了fromJValue _ = Nothing
,而书中甚至没有提到Left
,直到“更有用的错误”之前。也许我需要继续阅读一些内容,因为我希望理解您回答的一半。
答:嗯,书中只介绍了“更有用的错误”部分之前的一个实例,即instance JSON JValue
。我的版本不需要Nothing
,就像书中不需要Left
一样。只要我们开始定义可能失败的实例,那么我们就需要Left
或Nothing
。
问:另一个问题涉及mapM
:为什么要使用它?我明白vals
是JArray
构造函数的有效列表输入(否则我们不能在解构它的行上,对吧?),因此将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
} 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})
所以我们需要进行更多编程,才能让它正常工作。
JValue
转换回Result
和Search
?_),因为它涉及太多我不知道的东西,但基于第一部分,经过仔细阅读,我可以说这个答案似乎非常清晰,因此我给了一个+1。我对第一部分有一些疑问,在下面的评论中列出。 - EnlicoJNull
,它是唯一一个不带任何参数的构造函数,因此没有什么可以解构的,也就没有类型可以成为JSON
的实例。这如何符合整体情况?另一个问题涉及到mapM
:为什么要使用它?我理解vals
是输入到JArray
构造函数的有效列表(否则我们不能在解构它的行上),因此将列表中的每个元素应用于fromJValue
应该返回一个Maybe
(全部都是Just
,对吧?)正如函数签名所要求的那样。 - EnlicoEither
,而你使用了Maybe
。我想这只是因为你在使用Nothing
来表示错误时,而书中建议使用解释性的String
来提供关于“错误”的详细信息。好吧,但是书中对toJValue
和fromJValue
的定义与你的定义非常不同:我无法看出toJValue = id
如何有用,因为根据id
的签名,输入和输出的类型不能不同;而fromJValue
给定任何JValue
都会返回Right
,而你则拆解以返回其中包装的 Haskell 类型。 - EnlicofromJValue _ = Nothing
放置其中,而书中甚至没有提到Left
,直到 _More Helpful Errors_。也许我需要继续阅读一下,因为理解您回答的一半内容有希望在我的理解上解开了某些东西。 - Enlico