如何使用 Haskell 中的随机性来生成 JSON "model" 的实例?

How to use randomness in Haskell to produce instances of a JSON "model"?

我有这项工作,我必须从文件中读取 JSON 并根据其模型生成它的实例。我正在使用 aeson 来序列化对象,但我在处理生成新对象的随机性方面遇到了很大的问题。

根据我从文件中得到的内容生成一个新的 JSON 非常简单:

{-# LANGUAGE OverloadedLists #-}
{-# LANGUAGE OverloadedStrings #-}

import qualified Data.ByteString.Lazy.Char8    as ByteString
import qualified Data.Aeson                    as Aeson
import qualified Data.Aeson.Types              as Types
import qualified Data.Text

read :: String -> IO ()
read filePath = do
    json <- readFile filePath
    let Just parsedJSON =
            Data.Aeson.decode $ ByteString.pack json :: Maybe Aeson.Object
    let newJSON = fmap valueMapper parsedJSON
    print $ Aeson.encode newJSON


valueMapper :: Types.Value -> Types.Value
valueMapper value =
    case value of
        Types.String _      -> Types.String "randomValue"
        Types.Number _      -> Types.Number 0
        Types.Object object -> Types.Object $ fmap valueMapper object
        Types.Array  array  -> Types.Array $ fmap valueMapper array

我的第一次尝试是在 IO 之外产生随机值。我使用了这个功能:

randomStr :: String
randomStr = take 10 $ randomRs ('a','z') $ unsafePerformIO newStdGen

穿上 valueMapper:

valueMapper :: Types.Value -> Types.Value
valueMapper value =
    case value of
        Types.String _      -> Types.String $ Data.Text.pack randomStr
        Types.Number _      -> Types.Number 0
        Types.Object object -> Types.Object $ fmap valueMapper object
        Types.Array  array  -> Types.Array $ fmap valueMapper array

这个 "works",但是所有生成的字符串对于每个 String 字段都是相同的。

经过一些研究,我发现如果我想为每个 String 事件产生不同的值,我必须使用 IO:

randomStr :: IO String
randomStr = replicateM 10 (randomRIO ('a', 'z'))

现在,我知道 randomStr 的每次调用都有不同的字符串...但我也有类型不匹配的问题。 Value 的 Aeson String 构造函数采用 Data.Text,但我拥有的是 IO String。据我所知,我的字符串永远无法从 IO.

返回

我不知道是否有办法(希望如此)使用最新的 randomStr 来组成我的新 JSON 对象。我也不知道我的方法是否好。我愿意接受有关如何以我的方式或任何其他方式将其付诸实践的建议(关于如何编写更好的代码的一些技巧也很棒)。

在编写 Haskell 代码时发现自己需要将纯代码块转换为 IO 操作(或其他操作中的单子代码)的情况相对常见。它伴随着实践(并且,根据评论,在阅读了很多教程之后),但我可以向您展示我在处理您的代码示例时的思考过程。

如您所见,尝试使用 unsafePerformIO "hide" IO 是一个糟糕的主意。正确的选择是重写整个事情以在 IO monad 中操作,即使——如您所见——将 randomStr :: String 重写为 randomStr :: IO String 会启动一系列类型不匹配错误,这些错误需要一直解决到顶部。

所以,让我们来解决它们。如果 valueMapper 要使用 randomStr :: IO String,它也需要在 IO monad 中运行:

valueMapper :: Types.Value -> IO Types.Value

(注意:如果您在使用实时类型检查 IDE 时进行此更改,您会发现 read 中对 valueMapper 的调用现在被标记为类型错误,与 case 语句中的四个分支一样。)

总之,valueMapper的外部结构没有问题,即参数大小写匹配:

valueMapper value =
    case value of
        Types.String _      -> ???
        Types.Number _      -> ???
        Types.Object object -> ???
        Types.Array  array  -> ???

区别在于现在每个 ??? 都需要 return 一个 IO Types.Value 而不是 Types.Value。让我们从一个简单的开始。假设我们还不打算生成随机数,所以我们只想转换分支:

Types.Number _ -> Types.Number 0   -- pure version

到 IO。在这里,我们有一个纯值 Types.Number 0 :: Types.Value,我们想要一个 monadic 版本。这就是 return 的用途:

Types.Number _ -> return (Types.Number 0)   -- IO version

下一个最简单的是字符串分支。现在,它看起来像:

Types.String _ -> Types.String $ Data.Text.pack randomStr

其中 randomStr 是一个 IO String。不过,它仍然是一堆类型错误。那是因为 randomStr 是一个 IO String,我们想把它转换成一个 IO Types.Value,但是构造:

Types.String $ Data.Text.pack _

正在尝试将 String 直接转换为 Types.Value。这是使用 monadic 值时的常见问题。我们有一个要转换为 IO bIO a,但我们只有一个函数(此处为 Types.String . Data.Text.pack)来执行直接转换 a -> b。如果我们有一些带有签名的适配器函数会很有帮助:

foo :: (a -> b) -> IO a -> IO b

幸运的是,因为 IO 和所有 monad 一样,也是一个仿函数,我们确实有这样一个适配器:

fmap :: (a -> b) -> IO a -> IO b

所以,我们可以把分支写成:

Types.String _ -> fmap (Types.String . Data.Text.pack) randomStr

最后两个,对于对象和数组,更难,尽管它们的解决方案是相同的。采取对象分支,它的纯版本看起来像:

Types.Object object -> Types.Object $ fmap valueMapper object

在纯版本中,fmap用于将valueMapper :: Types.Value -> Types.Value应用于列表object :: [Types.Value]的每个元素,以得到一个新的[Types.Value]类型的列表,然后使用 Types.Object 构造函数转换为 Types.Value

让我们先解决 fmap。我们仍然会从我们的纯参数 value 的大小写匹配中得到 object :: [Types.Value],所以这没有改变。但是我们想将函数 valueMapper :: Types.Value -> IO Types.Value 应用于 object 的每个元素。结果将是 Types.Value 的列表,但在 IO monad 上下文中,因此完整的结果类型将为 IO [Types.Value]。即我们要一个适配器函数:

bar :: (a -> IO a) -> [a] -> IO [a]
       ^^^^^^^^^^^    ^^^    ^^^^^^- output list in IO context
                 |      `- input list
                 `- element-by-element conversion

这样的函数已经以更一般的形式存在,如 traverse。完整的签名是:

traverse :: (Traversable t, Applicative f) => (a -> f b) -> t a -> f (t b)

但专用于可遍历列表和 IO 应用程序,它是:

traverse :: (a -> IO b) -> [a] -> IO [b]

在我们的对象分支中使用它,结果将如下所示:

Types.Object object -> Types.Object $ traverse valueMapper object

这仍然会出现类型错误,因为 traverse valueMapper object return 是一个 IO [Types.Value],而我们正尝试在实际需要转换的地方使用直接转换 Types.Object :: [Types.Value] -> Types.Value IO [Types.Value] -> IO Types.Value。这与我们在上面的字符串分支中遇到的问题相同,解决方案是使用 fmap,因此进行以下类型检查:

Types.Object object -> fmap Types.Object (traverse valueMapper object)

您可能想在这里花点时间对数组分支执行等效转换。

通过这些更改,valueMapper 将进行类型检查。现在唯一的问题是它在 read 中的用法不会进行类型检查。问题是行:

let newJSON = fmap valueMapper parsedJSON

这里 parsedJSONAeson.Object 又名 Types.Object 类型,实际上是 HashMap Text Types.Value 的别名。这里的 fmap 用于依次将纯 valueMapper :: Types.Value -> Types.Value 应用于每个 hashmap 元素。

现在,我们要依次将 valueMapper :: Types.Value -> IO Types.Value 应用于每个元素,并在 IO 上下文中得到整个结果作为 IO Aeson.Object。幸运的是,Aeson.Object AKA HashMapTraversable,所以这里的解决方案与之前的对象和数组分支相同——将 fmap 替换为 traverse :

let newJSON = traverse valueMapper parsedJSON

这仍然无法正常工作,因为下一行:

print $ Aeson.encode newJSON

期望 newJSON 是一个纯 Aeson.Object,但是 traverse 调用的 return 值在 IO 上下文中,所以它是 IO Aeson.Object .我们可以尝试重写此 print 行以期望 newJSON :: IO Aeson.Object。例如,以下将起作用:

print =<< fmap Aeson.encode newJSON

不过,其实还有更简单的方法。在 do 块中,左箭头 <- 符号可用于此目的。其中:

let newJSON = traverse valueMapper parsedJSON

分配 newJSON 类型为 IO Aeson.Object 的 IO 操作,备选方案:

newJSON <- traverse valueMapper parsedJSON

"unwraps" 分配 newJSON 底层 Aeson.Object 的 IO 操作,以便在后续语句中使用。所以:

newJSON <- traverse valueMapper parsedJSON
print $ Aeson.encode newJSON

将进行类型检查。

还有一个文体注释。通常使用中缀同义词 <$> 代替 fmap 将纯函数应用于 IO 操作。因此,最终程序将如下所示:

import qualified Data.ByteString.Lazy.Char8    as ByteString
import qualified Data.Aeson                    as Aeson
import qualified Data.Aeson.Types              as Types
import qualified Data.Text

import Control.Monad
import System.Random

read :: String -> IO ()
read filePath = do
    json <- readFile filePath
    let Just parsedJSON =
            Aeson.decode $ ByteString.pack json :: Maybe Aeson.Object
    newJSON <- traverse valueMapper parsedJSON
    print $ Aeson.encode newJSON

valueMapper :: Types.Value -> IO Types.Value
valueMapper value =
    case value of
        Types.String _      -> Types.String . Data.Text.pack <$> randomStr
        Types.Number _      -> return $ Types.Number 0
        Types.Object object -> Types.Object <$> traverse valueMapper object
        Types.Array  array  -> Types.Array <$> traverse valueMapper array

randomStr :: IO String
randomStr = replicateM 10 (randomRIO ('a', 'z'))