【问题标题】:Is the Writer Monad Now Suitable for Large Lazy Lists?Writer Monad 现在适合大型惰性列表吗?
【发布时间】:2018-09-06 02:54:35
【问题描述】:

我在多个地方读到过,列表的 writer monad 将该完整列表保存在内存中,因此不应将其用于除小样本(例如,无日志记录)之外的任何内容。

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%。

是否重写了 writer monad 以解决原始问题?我可以指望它在未来继续以这种方式工作吗?

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

【问题讨论】:

  • @n.m.来自 Hackage,while >>= combines the outputs of the subcomputations using mappend..... 所以最终创建的价值是一个巨大的无限列表。你可以直接在print的输出中看到它,它总是以未完成列表的形式[1,2,3....,没有右括号。
  • 对不起,我还没起床。请无视。
  • 显然,mappend 的应用程序不能像链接文章中所说的那样左嵌套。
  • IIUC,WriterT 被认为不好的真正原因(与 StateT 相比)是您最终在 every 绑定上得到 mappend,而不仅仅是一个产生作家输出。相比之下,从不触及状态的StateT 计算只是将值沿未触及状态线程化。 mappendmempty 上的不断使用最终在实际程序中变得不必要地昂贵,因为 tell 相对于变压器堆栈中的其他单子动作而言很少使用,但在像这样的玩具程序中并不重要,因为每个动作都是tell

标签: haskell


【解决方案1】:

这里有两个因素在起作用。 <>/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

这不是一个适当的幺半群,因为&lt;&gt; 不是关联的。 x &lt;&gt; (y &lt;&gt; z) 给出Node x (Node y z)(x &lt;&gt; y) &lt;&gt; z) 给出Node (Node x y) z。它允许我们事后判断 Writer 的“日志”是减少了左嵌套还是右嵌套。

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) &lt;&gt; ((2) &lt;&gt; ((3) &lt;&gt; ((4) &lt;&gt; (Nil))))

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

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

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

你会得到这个:((((Nil) &lt;&gt; (4)) &lt;&gt; (3)) &lt;&gt; (2)) &lt;&gt; (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

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

基本上&lt;&gt; 调用的结构类似于Writer 表达式的结构。在调用绑定到另一个函数的任何地方(包括 do 块中的等效函数),由该调用产生的所有 &lt;&gt; 调用都将位于“括号内”。所以tell _ &gt;&gt; recurse 导致右嵌套&lt;&gt;s,而recurse &gt;&gt; tell _ 导致左嵌套&lt;&gt;s,更复杂的调用图导致&lt;&gt;s 的类似结构嵌套。

强制结果构建整个日志

您的测试程序的另一个特别之处是它根本不使用 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 在列表生成时使用它,并在短时间内完成(并且内存使用量相对较低)。之后,() 随时可用并立即打印出来。

但是如果我们把它改成先打印结果:

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

然后在我的系统上,这需要更长的时间,并且消耗近 15 GB 的 RAM1。它确实必须在 RAM 中完全实现日志才能获得最终结果,即使 &lt;&gt;s 是右嵌套的并且可以延迟使用日志。

从技术上讲,我认为它不是在内存中构建 list,而是将&lt;&gt; 应用于单例列表的一系列 thunk,它与最终列表一样长并且可能使用更多内存链中的每个环节。结果列表仍然由length 使用,因为它是通过强制这些 thunk 生成的,但这并没有真正帮助,因为必须生成整个 thunk 链才能获得最终的 () 结果,而不是拥有 thunk链本身生成为length 需要更多列表。


1 编译就像ghc foo.hs;如果我用-O2 编译,那么它的行为类似于首先打印日志的长度。这是 GHC 内联所有内容并找出计算相同结果的更好方法的一个相当简单的案例;如果程序更复杂,我不会认为它的优化可以解决这个问题。

【讨论】:

  • 至于解决方案,使用dlist 作为底层幺半群解决了第一个问题(嵌套)。我不认为它解决了第二个(将日志保存在内存中)--我不知道第二个有什么好的解决方案……很好奇
  • @Ben 很有趣,但我认为这是更普遍的懒惰陷阱,而不是 Writer 特有的陷阱。这两个问题都是“你不能等待整个无限列表”的变体。第一次失败会等待创建整个列表,然后再开始输出任何“日志记录”消息。第二个阻塞内存中的即用型消息,在打印无限列表的长度之前不打印任何内容。我可以编写一个非 Writer-non append 的程序,它也有同样的问题。
  • @jamshidh 基本上是的。但是“懒惰的一般陷阱”是 Writer 对于大型列表的所有问题。您的测试程序运行良好,因为它恰好避免了这些问题,而不是因为 Writer 已被重写为对大型惰性列表更友好。特别是,对于 Writer 的实际使用,从不使用结果似乎很不现实;如果你只需要日志,你可以直接使用 Monoid。
猜你喜欢
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2018-05-07
  • 2017-01-28
相关资源
最近更新 更多