我在多个地方都读过,列表的编写者单子将完整列表保存在内存中,因此,除了小样本(例如,无日志记录)之外,不应将其用于其他任何用途。
但是,为了测试该要求,我编写了以下程序,并实际显示它成功地懒惰地输出了一个无限列表!
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(我在其他地方使用了这些...)我想要的用例不是日志记录(但它是相似的)
答案 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来说,这是一个非常简单的例子,它可以内联所有内容并找出更好的方法来计算相同的结果。如果程序更复杂,我不会认为它的优化可以解决这个问题。