【问题标题】:Use of typeclasses in the JSON example form Real World Haskell在 JSON 示例表单 Real World Haskell 中使用类型类
【发布时间】:2020-05-02 18:55:42
【问题描述】:

我发现Real World Haskell - Chapter 5 主要是令人困惑,而且它似乎也在Chapter 6 中刺痛了我。

Chapter 6 中,直到Typeclasses 在起作用:使JSON 更易于使用之前,我似乎都明白了;然后本书显示了 JSON 文件的一部分,以及定义了一个变量 result 的 Haskell 源代码,其中包含(大部分)该 JSON 示例。

然后,参考前面的代码块,它区分了 JSON 对象,它可以包含不同类型的元素,而 Haskell 列表不能。这证明了在上述代码中使用JValue 的构造函数(JNumberJBool、...)是合理的。

到目前为止一切顺利。然后它开始让我感到困惑。

这限制了我们的灵活性:如果我们想将数字 3920 更改为字符串 "3,920",我们必须将用于包装它的构造函数从 JNumber 更改为 JString

是的,那又怎样?如果我打算进行此更改,我将不得不更改,例如,这一行

("esitmatedCount", JNumber 3920)

到这里

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

对应于在实际 JSON 文件中将 3920 更改为 "3,920"。所以呢?如果我有机会将不同的类型放入 Haskell 列表中,我仍然需要将数字括在双引号中并添加逗号。

我不明白失去灵活性的原因。

然后提出了一个诱人的解决方案(tempting?比这不行...缺点在哪里?网上书里有的cmet提出了同样的问题。)

type JSONError = String

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

instance JSON JValue where
    toJValue = id
    fromJValue = Right

现在,我们不应用 JNumber 之类的构造函数来包装它,而是应用 toJValue 函数。如果我们改变一个值的类型,编译器会选择一个合适的toJValue 实现来使用它。

这让我觉得其意图是使用toJValue 函数而不是 构造函数JNumber,但我不知道toJValue 3920 是如何工作的。

我应该如何使用上面的代码?

【问题讨论】:

    标签: json haskell


    【解决方案1】:

    更新:我在最后的一个部分中添加了对您后续 cmets 的答案。

    我认为作者在编写类型类一章时,希望保持与上一章示例的连续性。他们可能还想到了他们编写的一些真实代码,其中类型类用于在 Haskell 中处理 JSON。 (我看到 RWH 的作者之一 Bryan O'Sullivan 也是出色的 aeson JSON 解析库的作者,该库非常有效地使用了类型类。)我认为他们也对他们最好的例子感到有点沮丧需要类型类 (BasicEq) 是已经实现的东西,这迫使读者假装语言设计者在语言中留下了一个关键特性,以便了解对类型类的需求。他们还意识到 JSON 示例足够丰富和复杂,足以让他们引入一些困难的新概念(类型同义词和重叠实例、开放世界假设、新类型包装器等)。

    因此,他们尝试将 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 数据类型,以“工作中的类型类”部分中给出的 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 转换回 ResultSearch 怎么样?您可能想尝试在不使用类型类的情况下编写它并查看它的外观。类型类的解决方案使用了一个令人费解的辅助函数(需要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
    

    之后,可以使用应用语法(&lt;$&gt;&lt;*&gt;)轻松编写实例,这允许我们将一堆 Maybe 值组合为函数调用的参数,如果有任何参数,则返回 NothingNothing(即 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 章中按原样介绍,因为它涉及应用程序(&lt;*&gt; 语法)、更高级别的类型(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 来提供有关错误的详细信息。好的,但是这本书对toJValuefromJValue 的定义与你的有很大不同:我看不出toJValue = id 有什么用处,因为输入和输出的类型不能不同,基于id 的签名;和fromJValue,给定任何JValue 返回RightJValue,而您解构以返回包装在其中的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 变为 JustLeft "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一样。一旦我们开始定义可能失败的实例,我们就需要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 函数是一个通用的单子映射,但对于我正在使用的特定单子(Maybe 单子),它有签名:

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

    最好理解为使用函数a -&gt; Maybe b,它可以通过返回Just“成功”或通过返回Nothing 来“失败”,并将该函数应用于列表[a]。如果所有应用成功,则返回Just结果列表(将Just拉到列表外);如果 any 失败,则返回全局 Nothing 失败值。

    实际上和 RWH 中的函数mapEithers 是一样的想法。该函数将一个函数应用于列表[a],如果它们all成功(通过返回Rights),它返回Right结果列表(将Right拉到外面名单);如果 any 失败(通过返回 Left),则返回 Left 失败值(如果生成多个错误,则使用遇到的“第一个”错误消息)。事实上,mapEithers 不需要定义。它可以被mapM 替换,因为mapMMaybe monad 和Either errmsg monad 一起工作,并且与mapEithersEither 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})
    

    所以我们需要做更多的编码才能让它正常工作。

    【讨论】:

    • 尽管我现在不得不放弃答案的第二部分(将 JSON JValue 转换回 ResultSearch 怎么样?),因为它带来了太多我不知道的东西,基于第一部分我可以说,仔细阅读后,答案似乎很清楚,因此我的+1。我对第一部分有一些疑问,列在下面的评论中。
    • 一个关注JNull,它是唯一一个不带任何参数的构造函数,所以没有什么可解构的,因此,没有任何类型可以成为JSONinstance。这如何适应整体情况?其他问题mapM:为什么要使用它?我知道valsJArray 构造函数输入中的有效列表(否则我们不能在解构它的行上,对吗?),因此将fromJValue 应用于列表的每个元素应该返回一个Maybe(都是Just,对吗?)正如函数签名所要求的那样。
    • 哦,还有一个疑问。本书使用Either,而您使用Maybe。我会说这只是因为您在错误时使用Nothing 来表达某些东西,而本书建议使用解释性String 来提供有关错误的详细信息。好的,但是本书对toJValuefromJValue 的定义与您的有很大不同:我看不出toJValue = id 如何有用,因为输入和输出的类型不能不同,基于id 的签名;和fromJValue,给定任何JValue 返回RightJValue,而您解构以返回包装在其中的Haskell 类型。
    • 此外,您在每个实例中都输入了fromJValue _ = Nothing,而本书甚至没有提到Left,在更多有用的错误之前没有提及。也许我必须继续阅读,因为理解了你一半的答案,希望能在我的理解中解开一些东西。
    • 我在答案末尾添加了一个部分来尝试回答这些问题。
    猜你喜欢
    • 1970-01-01
    • 1970-01-01
    • 2013-04-07
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2016-08-12
    • 1970-01-01
    相关资源
    最近更新 更多