Laziness
This lecture is adjusted from Joachim Breitner.
Strict Evaluation
Haskell is a lazy language, meaning that it employs lazy evaluation.
Before explaining lazy evaluation, let’s first explain strict evaluation which most readers will likely be more familiar with.
Strict evaluation means that arguments to functions are evaluated prior to being passed to the functions. For example, consider the following function.
fst x y = x
Suppose we then called fst as follows.
fst 1 (len [1..9999999999999]) == 1
In a strict language, we must first evaluate the second argument (len [1..9999999999999]) to 9999999999999 before executing the call to fst, even though the function does not use the second argument.
While potentially inefficient, one of the benefits of strict evaluation is predictability. In particular, when using this strategy, the programmer can easily determine what will execute and when it will execute. This can be important when trying to analyze the complexity of an algorithm.
Another use for strict evaluation is in the presence of side effects. For example, consider the following Java example.
int x = 4; int foo() { x++; return 2; } int bar() { return x; } int baz(int a, int b) { return b; } void main() { assert baz(foo(), bar()) == 5; }
For this example, assuming Java evaluates arguments of functions from left to right, we expect foo() to execute first, which increments x to 5 (the side effect) and returns 2. Then bar() is executed, returning x, which is now 5. Finally, baz(foo(), bar()) returns 5.
Notice that the strict evaluation of foo() changed the result of the call baz(foo(), bar()), even though baz never actually used the result of its first argument. In this case, strictness ensures that side-effects of unused function arguments are still processed.
Lazy Evaluation
Lazy evaluation delays evaluation of function arguments until it is absolutely necessary to do so. Even further, it only evaluates those arguments as much as is necessary.
For example, say we had the same example from earlier in Haskell.
fst :: a -> b -> a fst x y = x len :: [a] -> Int len [] = 0 len (x:xs) = 1 + len(xs)
And we ran the same code.
Prelude> fst 1 (len [1..9999999999999]) 1
This time, Haskell does not evaluate the second argument (length [1..9999999999999]). This is good, as evaluating that argument would take a very long time.
Instead of evaluating this second argument, Haskell passes fst the unevaluated expression, which is called a “thunk”. You can think of a “thunk” as a recipe for how to evaluate the argument if need be.
So, the obvious question is, when do we know we need to evaluate an expression in Haskell?
The fst example above benefited from laziness because the second argument was not used. However, as mentioned at the beginning, this is not the extent of laziness. Consider the following example.
listify :: [a] -> [[a]] listify x = [x]
Let’s compare listify and len. Notice that the two functions treat their argument of type [a] very differently. listify wraps x in an additional list, regardless of its contents while len performs pattern matching on it’s argument. As a result, it turns out that len will require (some) evaluation of its argument while listify will not. To see this in action, let’s try an example.
Prelude> len $ listify (len [1..999999999999999]) 1
In order to execute this, len executes with listify (len [1..999999999999999]) as an argument. len pattern matches on this input, which indicates that we need to execute listify. listify takes it’s argument (len [1..999999999999999]) and wraps it in a list, so now we have [(len [1..999999999999999])] where the inner call to len remains unevaluated because the result of that call is not yet needed. In this case, this triggers the second pattern match in len which matches x with len [1..999999999999999] and xs with []. We then call 1 + len(xs) which never uses x, so evaluation of len [1..999999999999999] is never required. The recursive call to len(xs) requires a pattern match on xs, which is already evaluated to [] and simply returns 0.
Let’s try a slightly more interesting example with take which fetches the first N elements of a list.
take :: Int -> [a] -> [a] take _ [] = [] take n (x:xs) = if n <= 0 then [] else (x : take (n-1) xs)
Before using this, let’s first redefine our list builder notation [x..y] as a function so it is a bit more clear.
buildList :: Int -> Int -> [Int] buildList x y = if x > y then [] else x:(buildList (x+1) y)
Note that buildList 1 999999999999999 is equivalent to [1..999999999999999].
Let’s see how take operates lazily by tracing out the execution of a call to it.
take 3 (buildList 1 999999999999999) take 3 (1:(buildList 2 999999999999999)) 1:(take 2 (buildList 2 999999999999999)) 1:(take 2 (2: (buildList 3 999999999999999))) 1:2:(take 1 (buildList 3 999999999999999)) 1:2:(take 1 (3: (buildList 4 999999999999999))) 1:2:3:(take 0 (buildList 4 999999999999999)) 1:2:3:[]
Notice that the call to buildList is only evaluated up to the value needed by take and no more. Laziness is cool.
Disclaimer: GHC uses a slightly more complicated procedure for lazy evaluation known as “graph reduction” which avoids duplicate evaluation of equivalent expressions and therefore might differ slightly from the example above. However, the example above is a semantically equivalent notion of lazy evaluation.
Benefits of Lazy Evaluation
Lazy evaluation has a number of benefits.
1. Infinite data structures.
Let’s try a little experiment in GHC. Let’s try the following.
Prelude> [1..] [1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20, 21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37, 38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,...
Woah, it just keeps going! This is an infinite list, but how is it done? Let’s define our own, called infList.
infList :: Int -> [Int] infList x = x : (infList (x+1))
Indeed, infList 1 is equivalent to [1..].
Prelude> infList 1 [1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20, 21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37, 38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,...
So, what’s happening? infList is executed the number of times required by the function which called it. In this case, we are using ghci, which calls show on the result, which requires evaluation of every element in the list. Therefore, we have an infinite loop, iteratively evaluating infList generating the next element of the list.
Why is this useful? Well, suppose we want the first N primes. Let’s use infinite lists to achieve this. First, let’s define some helper functions.
factors :: Int -> [Int] factors n = [i | i <- [1..n], n `mod` i == 0] isPrime :: Int -> Bool isPrime n = factors n == [1,n]
factors computes the factors of a number and returns them in a list and isPrime determines if a number is prime by determining if its factors are simple 1 and itself.
Let’s use these to define all the primes.
primes = [ i | i <- [1..], isPrime i]
Finally, let’s define a function the first N primes.
firstNPrimes :: Int -> [Int] firstNPrimes n = take n primes
Let’s check it out!
Prelude> firstNPrimes 100 [2,3,5,7,11,13,17,19,23,29,31,37,41,43,47,53,59,61,67,71,73, 79,83,89,97,101,103,107,109,113,127,131,137,139,149,151,157, 163,167,173,179,181,191,193,197,199,211,223,227,229,233,239, 241,251,257,263,269,271,277,281,283,293,307,311,313,317,331, 337,347,349,353,359,367,373,379,383,389,397,401,409,419,421, 431,433,439,443,449,457,461,463,467,479,487,491,499,503,509, 521,523,541]
2. Efficiency
Lazy evaluation can offer efficiency benefits as it avoid unnecessary evaluation. We saw this earlier. Another powerful example is the if function. As if is pre-defined in Haskell, I will create my own which I call if’.
if' :: Bool -> a -> a -> a if' True x _ = x if' False _ y = y
Notice that our defintion of if2 will only ever require evaluation of one of the branches of the if statement. This is built into the function via laziness and as a result we get it for free!
3. Purity(?)
Lazy evaluation works well with pure languages. I suppose it’s arguable that purity is a benefit, as you could also view it as “laziness does not work well with side effects.” However, I like purity, so I’m going to view it as a plus that lazy evaluation pushes us away from impure languages. Let’s return to the Java example from the first section. Give it another look to refresh yourself.
If Java were lazy, neither foo() nor bar() would be evaluated initially. Instead, we would enter the body of baz. We then reach the statement b which we must evaluate. We realize we need the actual values of the second argument. We evaluate the call bar() which returns 4, and thus baz(foo(), bar()) returns 4. Note, this is different from the value of 5 that is returned from standard Java as foo() is never evaluated, and therefore its side-effect is never processed.
While there is nothing explicitly wrong with lazy Java, one can imagine that reasoning about the behaviour of a program with side-effects and laziness would be very difficult. Debugging would be a nightmare!
I suppose this leads well into the downsides of laziness...
Consequences of Laziness
1. Efficiency
What?! I thought the whole point of laziness was efficiency?! Well, you’re mostly right, but laziness does come with tradeoffs. Everytime a function argument is not evaluated, it must get wrapped in a “thunk” which takes memory. This repeated process can add up, adding huge memory overhead for a program and causing it to slow down unexpectedly. Let’s consider an example.
badSum :: Num a => [a] -> a badSum [] = 0 badSum (x:xs) = x + badSum xs
Why is badSum bad? Well, it’s not tail-recursive, which means you need to keep around all of the previous stack frames. This will not work well for long lists. Let’s make a better one.
goodSum :: Num a => [a] -> a goodSum = go 0 where go acc [] = acc go acc (x:xs) = go (x + acc) xs
goodSum is good, right? Almost. Laziness is going to rear its (sometimes) ugly head.
Let’s consider the evaluation of a call to goodSum.
goodSum [1,2,3,4] go 0 [1,2,3,4] go (1 + 0) [2,3,4] go (2 + (1 + 0)) [3,4] go (3 + (2 + (1 + 0))) [4] go (4 + (3 + (2 + (1 + 0)))) [] (4 + (3 + (2 + (1 + 0)))) (4 + (3 + (2 + 1))) (4 + (3 + 3)) (4 + 6) 10
Huh, well that didn’t seem to help. goodSum still waited till the end to evaluate the addition, meaning we have to keep creating bigger and bigger “thunks” to hold the unevaluated portions. This will also not work well for big lists.
Luckily, Haskell gives us a way to force strict evaluation. Let’s see it at work.
strictSum :: Num a => [a] -> a strictSum = go 0 where go acc [] = acc go acc (x:xs) = acc `seq` go (x + acc) xs
seq has type a -> b -> b. It forces the evalution of its first argument, and then returns its second argument. In this case, it forces the evaluation of the accumulator at each recursive call. We now get the following.
strictSum [1,2,3,4] go 0 [1,2,3,4] go (1 + 0) [2,3,4] go (2 + 1) [3,4] go (3 + 3) [4] go (4 + 6) [] (4 + 6) 10
2. Understanding evaluation is difficult!
As described above in the context of “side-effects,” understanding the exact evaluation of lazily-evaluated programs can be quite difficult. This can make determining properties like algorithmic complexity quite challenging.