更新:我在最后的一个部分中添加了对您后续 cmets 的答案。
我认为作者在编写类型类一章时,希望保持与上一章示例的连续性。他们可能还想到了他们编写的一些真实代码,其中类型类用于在 Haskell 中处理 JSON。 (我看到 RWH 的作者之一 Bryan O'Sullivan 也是出色的 aeson JSON 解析库的作者,该库非常有效地使用了类型类。)我认为他们也对他们最好的例子感到有点沮丧需要类型类 (BasicEq) 是已经实现的东西,这迫使读者假装语言设计者在语言中留下了一个关键特性,以便了解对类型类的需求。他们还意识到 JSON 示例足够丰富和复杂,足以让他们引入一些困难的新概念(类型同义词和重叠实例、开放世界假设、新类型包装器等)。
因此,他们尝试将 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 {-# 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 数据类型,以“工作中的类型类”部分中给出的 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是 Nothing(即 JSON 中的意外类型),否则调用函数:
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'
对评论问题的回答
您在 cmets 中提出了一系列后续问题。我试图在这里回答它们,但顺序略有不同:
问:本书使用Either,而您使用Maybe。我会说这只是因为您使用Nothing 来表示出了点问题,而本书建议使用解释性String 来提供有关错误的详细信息。好的,但是这本书对toJValue 和fromJValue 的定义与你的有很大不同:我看不出toJValue = id 有什么用处,因为输入和输出的类型不能不同,基于id 的签名;和fromJValue,给定任何JValue 返回Right 和JValue,而您解构以返回包装在其中的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 errmsg 或Right answer与 在我的版本中Nothing 或Just 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,但是如果类型不返回Left errmsg(或Nothing,对于我的版本),一般fromJValue函数会失败匹配。但是,JValue 始终是“翻译”为JValue 的正确类型,因此我们始终可以返回Right 答案。我的这个实例版本如下所示:
instance JSON JValue where
toJValue = id
fromJValue = Just -- use Just instead of Right; we never return Nothing
问:另外,你在每个实例中都输入了fromJValue _ = Nothing,而书中甚至没有提到Left,在“更多有用的错误”之前没有提到。也许我必须继续阅读,因为理解你的答案的一半有望解开我的理解。
A:嗯,这本书在“更有用的错误”部分之前只介绍了一个实例,那就是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 函数是一个通用的单子映射,但对于我正在使用的特定单子(Maybe 单子),它有签名:
mapM :: (a -> Maybe b) -> [a] -> Maybe [b]
最好理解为使用函数a -> Maybe b,它可以通过返回Just“成功”或通过返回Nothing 来“失败”,并将该函数应用于列表[a]。如果所有应用成功,则返回Just结果列表(将Just拉到列表外);如果 any 失败,则返回全局 Nothing 失败值。
实际上和 RWH 中的函数mapEithers 是一样的想法。该函数将一个函数应用于列表[a],如果它们all成功(通过返回Rights),它返回Right结果列表(将Right拉到外面名单);如果 any 失败(通过返回 Left),则返回 Left 失败值(如果生成多个错误,则使用遇到的“第一个”错误消息)。事实上,mapEithers 不需要定义。它可以被mapM 替换,因为mapM 与Maybe monad 和Either errmsg monad 一起工作,并且与mapEithers 的Either errmsg monad 具有相同的行为。
问:一个问题是JNull,它是唯一一个不带任何参数的构造函数,所以没有什么可解构的,因此没有类型可以成为JSON 的实例。这与整体情况如何?
答:翻译JNull 最直接的方式是将其翻译成不包含任何信息的 Haskell 类型。居然有这种类型。它被命名为“单元”并在源代码中写为()。您可能已经看到它在各种情况下使用。适当的实例是:
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)
现在,我美丽的instance JSON Result 坏了,因为我没有办法处理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),其中两个Maybes 有不同的用途:外部Maybe 表示转换是否成功,内部@987654483 @ 表示可选值是可用还是缺失。所以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 实际上并没有处理真正的 missing 可选字段:
> 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})
所以我们需要做更多的编码才能让它正常工作。