现在作家Monad是否适合大型懒惰列表?

时间:2018-09-06 02:54:36

标签: haskell

我在多个地方都读过,列表的编写者单子将完整列表保存在内存中,因此,除了小样本(例如,无日志记录)之外,不应将其用于其他任何用途。

For instance, read here

但是,为了测试该要求,我编写了以下程序,并实际显示它成功地懒惰地输出了一个无限列表!

import Control.Monad.Writer

createInfiniteList :: Int -> Writer [Int] ()
createInfiniteList i = do
  tell [i]
  createInfiniteList (i+1)

main :: IO ()
main = do
  let x = execWriter $ createInfiniteList 1
  print x

我已经看到该程序输出了超过10亿个项目(它运行非常快),并且监视了我的计算机上的内存使用率从未超过0.1%。

作家monad是否已被改写以解决原始问题?我可以指望它将来继续以这种方式工作吗?

注意-我知道存在更好的日志记录monad(我在其他地方使用了这些...)我想要的用例不是日志记录(但它是相似的)

1 个答案:

答案 0 :(得分:4)

这里有两个因素在起作用。 <> / mappend调用的嵌套方式以及整个日志是否保存在内存中。

<>调用如何嵌套?

这取决于您使用Writer 编写代码的方式,而不取决于Writer的实现。要知道为什么,让我们作弊。

data Tree a = Nil | Leaf a | Node (Tree a) (Tree a)
  deriving (Show)

instance Semigroup (Tree a)
  where x <> y = Node x y

instance Monoid (Tree a)
  where mempty = Nil

由于<>不具有关联性,因此这不是适当的monoid。 x <> (y <> z)给出Node x (Node y z),而(x <> y) <> z)给出Node (Node x y) z。它使我们能够在事实之后判断作家的“日志”是减少了左嵌套还是右嵌套。

go :: Int -> Writer (Tree Int) ()
go i
  | i < 5
    = do tell (Leaf i)
         go (i+1)
  | otherwise
    = pure ()

main :: IO ()
main = do
  let (result, log) = runWriter $ go 1
  putStrLn (render log)

render Nil = "Nil"
render (Leaf x) = show x
render (Node x y) = "(" ++ render x ++ ") <> (" ++ render y ++ ")"

有了这个,您得到:(1) <> ((2) <> ((3) <> ((4) <> (Nil))))

很明显是在右边。因此,如何生成一个无限列表作为Writer的“日志”并使用它,因为它是在相对较小的空间中生成的。

但是交换tell和递归的顺序,使其看起来像这样:

go :: Int -> Writer (Tree Int) ()
go i
  | i < 5
    = do go (i+1)
         tell (Leaf i)
  | otherwise
    = pure ()

您将获得:((((Nil) <> (4)) <> (3)) <> (2)) <> (1)。现在它是左嵌套的,无限递归不起作用:

import Control.Monad.Writer

createInfiniteList :: Int -> Writer [Int] ()
createInfiniteList i = do
  createInfiniteList (i+1)
  tell [i]

main :: IO ()
main = do
  let x = execWriter $ createInfiniteList 1
  print x

这永远不会打印任何内容,并且会消耗越来越多的内存。

基本上,<>调用的结构类似于Writer表达式的结构。在调用中绑定到另一个函数的所有位置(包括do-block中的等效函数),该调用产生的所有<>调用都将是“括号内”。因此tell _ >> recurse导致右嵌套的<>,而recurse >> tell _导致左嵌套的<>,更复杂的调用图导致{的嵌套类似{1}}个。

强制结果构建整个日志

关于测试程序的另一点特别之处是它根本不使用Writer的“结果”,而仅使用“日志”。显然,如果递归是无限的,则根本不会有任何最终结果,但是如果我们像这样更改程序,则:

<>

然后它的行为类似; import Control.Monad.Writer createLargeList :: Int -> Writer [Int] () createLargeList i | i < 50000000 = do tell [i] createLargeList (i+1) | otherwise = pure () main :: IO () main = do let (result, log) = runWriter $ createLargeList 1 print $ length log print result 在产生列表时消耗掉该列表,并在短时间内完成(并且内存使用量相对较低)。之后,length随时可用并立即打印。

但是如果我们更改它以首先打印结果:

()

然后在我的系统上,这花费了更长的时间,并消耗了将近15 GB的RAM 1 。它确实必须在RAM中完全实现日志,以获取最终结果,即使import Control.Monad.Writer createLargeList :: Int -> Writer [Int] () createLargeList i | i < 50000000 = do tell [i] createLargeList (i+1) | otherwise = pure () main :: IO () main = do let (result, log) = runWriter $ createLargeList 1 print result print $ length log 被正确嵌套并且日志可以被延迟使用也是如此。

从技术上讲,我认为它不是在内存中建立 list ,而是一堆将<>应用于单例列表的笨蛋,它与最终列表一样长,并且可能使用链中每个链接更多的内存。结果列表仍然由<>消耗,因为它是通过强迫这些thunk生成的,但这并没有真正的帮助,因为必须生成整个thunk链才能获得最终的length结果如()所要求的列表中的更多内容,则具有链状链本身的问题。


1 就像length一样进行编译;如果我使用ghc foo.hs进行编译,则其行为类似于首先打印日志的长度。对于GHC来说,这是一个非常简单的例子,它可以内联所有内容并找出更好的方法来计算相同的结果。如果程序更复杂,我不会认为它的优化可以解决这个问题。