I'm learning Haskell and currently trying to wrap my head around monads. While playing with some random number generation I got tripped on lazy evaluation once again. In an effort to simplify something close to the:
roll :: State StdGen Int
roll = do
gen <- get
let (n, newGen) = randomR (0,1) gen
put newGen
return n
main = do
gen <- getStdGen
let x = sum $ evalState (replicateM iterations roll) gen
print x
into something like this:
roll' :: IO Int
roll' = getStdRandom $ randomR (0,1)
main = do
x' <- fmap sum $ replicateM iterations roll'
print x'
on a larger number of iterations
, let's say 1000 * 1000 * 10
, second example results in a stack overflow.
Why is the first version happily runs in constant space and the second one blows up?
Speaking more broadly, can you recommend some reading to improve one's mental model of Haskell's lazy evaluation? (Introductory to intermediate level, preferably.) Because when it comes to evaluation in Haskell, my intuition completely fails me.
This is because
Control.Monad.State
re-exportsControl.Monad.State.Lazy
. If you imported,Control.Monad.State.Strict
, both would overflow that way.The reason it overflows with strict
State
orIO
is thatreplicateM
needs to run the actioniterations
times recursively, before it can build the list. To put it loosely,replicateM
must "combine" the "effects" of all the actions it replicates into one giant "effect". The terms "combine" and "effect" are very vague, and can mean an infinite number of different things, but they're about the best we've got for talking about such abstract things.replicateM
with a large value will end up overflowing the stack in nearly every choice of monad. It's the fact that it doesn't with lazyState
that's bizarre.To see why it doesn't overflow with lazy
State
, you need to look into the details of(>>=)
for lazyState
, andreplicateM
. The following definitions are greatly simplified, but they reflect the details necessary to illustrate how this works.So first, look at
replicateM
. Take note that whenn
is greater than 0, it is a call to(>>=)
. So the behavior ofreplicateM
depends closely on what(>>=)
does.When you look at
(>>=)
, you see it produces a state transition function that binds the results of the state transition functionx
in a let binding, then returns the result of the transition function that's the result off
applied to arguments from that binding.Ok, that statement was clear as mud, but it's really important. Let's just look inside the lambda for the moment. Looking at the result of the function
(>>=)
creates, you seelet {something to do with x} in {something to do with f and the results of the let binding}
. This is important with lazy evaluation. It means that just maybe it can ignorex
, or maybe part of it, when it evaluates(>>=)
, if the particular functionf
allows it to. In the case of lazyState
, it means that it might be able to delay calculating future state values, iff
can produce a constructor before looking at the state.This turns out to be what allows it to work. The particular way
replicateM
assembles calls to(>>=)
, it results in a function that produces(:)
constructors before examining the state passed to them. This allows incremental processing of the list, if the final state is never examined. If you ever look at the final state, that destroys the ability to function incrementally, because the final state requires doing all the work to calculate it. But your use ofevalState
resulted in the final state being thrown away unexamined, so evaluation was free to proceed incrementally.The culprit is hidden deep inside
replicateM
. Let's look at the source.In particular, take a look at a single unrolling of the
foldr
insequence
In other words, unless we can lazily return
(x : ... thunk ... )
early then we'll unroll the entire replication prior to returning the first value. The answer as to whether or not we can return that value has to do with the definition of(>>=)
in our monad.It's fair to say that since
IO
performs side-effects it's going to perform binds sequentially—we're definitely going to unroll the whole thing.State
has two forms, theControl.Monad.Trans.State.Lazy
version and theControl.Monad.Trans.State.Strict
version whereControl.Monad.Trans.State
defaults to theLazy
version. There,(>>=)
is defined asSo we can see the explicit irrefutable bind happening which lets us proceed to return the result lazily.
It's worth taking a look at a recent review of this problem by Joachim Breitner. There's also a lot of work on this in the
pipes
andconduit
ecosystems that might be worth examining.Generally, it's worth being suspect of
replicateM
however due to that notion of sequencing we saw above: "build the head then build the tail then return the cons".