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.