DEV Community

Kurt
Kurt

Posted on • Originally published at getcode.substack.com on

A Nibble of Lazy Evaluation

Nibble: a small piece of food bitten off. In computing: half a byte of information. Every nibble explains one computing science or software engineering idea in five minutes.

Every programming language needs to choose in which order to evaluate expressions. Almost all use strict evaluation: before evaluating an expression, all sub-expressions are evaluated first. For example, arguments to a function are evaluated before the function. Many people use eager evaluation as a synonym for strict evaluation.

A minority, most prominently Haskell, uses non-strict evaluation. Non-strict evaluation is defined by what it is not: any evaluation strategy that doesn't evaluate all sub-expressions first, or at all, is non-strict. Lazy evaluation is a particular strategy of non-strict evaluation.

Let's try to clear up the confusion with a nibble of lazy evaluation.

koala on bough
I envy koalas for their ability to be supremely comfortable in the most awkward of places. Photo by David Clode on Unsplash

Strict evaluation

Like the majority of languages, python uses strict evaluation for function applications:

def log(value):
    if log_level == INFO:
        print(value)

log(42 + 33)
Enter fullscreen mode Exit fullscreen mode

First, the sum is calculated, then it's logged. While intuitive to understand, the example shows a potential downside of strict evaluation: if the argument ends up being unused, if log_level is WARN, then it's calculated needlessly.

A simple way to spot strict evaluation is to imagine what would happen if one sub-expression gets into an infinite loop. If the top-level expression never gets evaluated, then evaluation is strict.

Strict evaluation doesn't restrict in which order sub-expressions are evaluated, just that they're all evaluated before the top-level expression. It can be left to right, like in Python; right to left, like in OCaml; or left undefined, like in C.

Non-strict evaluation

Non-strict evaluation is not as unusual as it may appear at first - if...else... for example is non-strict.

Let's apply our trick to spot strict evaluation. The following Python program terminates even though the else branch loops forever:

if True:
    print("I am True")
else:
    while True:
        print("I never terminate")
Enter fullscreen mode Exit fullscreen mode

The else branch is not evaluated at all, so if...else... is not strict in python.

Short-circuited boolean operations are also non-strict. The following or expression terminates.

True or infinite_loop()
Enter fullscreen mode Exit fullscreen mode

Non-strict DIY

That's nice for built-ins, but if you want something done right...

We can simulate non-strict evaluation using thunks: functions without arguments. Thunks delay evaluation until needed.

def log(value_thunk):
    if log_level == INFO:
        print(value_thunk())

log(lambda: 42 + 33)
Enter fullscreen mode Exit fullscreen mode

Thunks avoid unnecessary work - if the value to log is expensive to compute, the log function only evaluates it when logging is enabled.

Lazy evaluation

An annoying problem with thunks is that they are re-evaluated each time they are called.

def log(value_thunk):
    if log_level == INFO:
        print(value_thunk())
        send_to_log_aggregator(value_thunk())
Enter fullscreen mode Exit fullscreen mode

To solve that, we can keep the thunk's result the first time it's evaluated, and re-use it. This evaluation strategy is called lazy evaluation. It combines two optimizations:

  • It never performs unnecessary work, by delaying evaluation until needed.

  • It never repeats work, by caching the first result.

There is a catch: if the thunk has side effects, such as writing to disk, it becomes hard to understand when or if the side effect occurs. For this reason, lazy evaluation assumes thunks have no side effects.

The functional programming language Haskell gets away with using lazy evaluation by default because it is pure: Haskell functions do not have side effects.

Strict languages like OCaml, F#, and C# support lazy evaluation using a lazy keyword or Lazy object.

Here's an illustrative implementation of a Lazy object in python:

class Lazy:
    def __init__ (self, thunk):
        self._thunk = thunk
        self._is_cached = False
        self._value = None

    @property
    def value(self):
        if not self._is_cached:
            self._value = self._thunk()
            self._is_cached = True
        return self._value
Enter fullscreen mode Exit fullscreen mode

Why lazy evaluation matters

Strict evaluation is more common because it's easier to understand, and is more straightforward to debug. That said, non-strict evaluation is indispensable in some situations.

Doug McIlroy's Power Serious illustrates this beautifully: a complete, 10-line implementation of infinite power series in Haskell.

Here's one line from it:

series f = f : repeat 0
Enter fullscreen mode Exit fullscreen mode

In Haskell, one of the fundamental data structures is a lazy list. The function series creates such a list. The first element of the list is the number f, and the rest are zeroes. The list constructor : prepends a list with one element. The standard function repeat s creates an infinite list of s.

Concretely, series 13 represents the infinite list [13, 0, 0, 0,...]. But since it's lazy, the elements are only calculated when needed. One way to ask for elements is to use the function take n, which takes n elements from the front of a list. In other words, take 4 (series 13) yields [13, 0, 0, 0].

At this point, you may think that lazy lists are just iterators. Like lazy lists, iterators are computed on demand and can be used to represent infinite data structures. Here's series as a python generator:

def series(f: int) -> Iterable[int]:
    yield f
    while True:
        yield 0
Enter fullscreen mode Exit fullscreen mode

The difference is that lazy lists cache elements of the list as they are evaluated. A program like:

myList = series 10 --list thunk is created
some = take 5 myList --eval first 5 elements
somemore = take 20 myList --eval 15 more elements
Enter fullscreen mode Exit fullscreen mode

evaluates only 20 values in total. Iterators sometimes can't be reset, and if they can the whole computation is redone from scratch. Not so with lazy lists!

Back to Power Serious. Doug McIlroy uses lazy lists to represent the coefficients of an infinite power series. In other words, the list [1, 2, 3, 4, 0, ...] represents the power series 1 + 2𝑥 + 3𝑥² + 4𝑥³ + ...

Here's addition of two power series:

 (f:ft) + (g:gt) = f+g : ft+gt
Enter fullscreen mode Exit fullscreen mode

Haskell's syntax is terse. In this one line both the : and + symbols are overloaded. The : symbol on the left of the equals sign means list deconstruction. On the right, it means list construction. Here's an image that color-codes the different meanings of each symbol to clarify:

Image description

Hold on - a recursive definition without a base case? That's only possible thanks to lazy evaluation. When the first element of a sum of two series is needed, the expression f+g in turn needs f and g, which triggers evaluation of the first elements of the left and right list via the lazy pattern matches (f:ft) and (g:gt). Further evaluation of ft+gt is delayed until more elements are needed. This process repeats when a second element is asked for, and so on.

Lazy infinite lists simplify everything: no need to check when the list ends because it doesn't, and no recursive base case is needed because we only recurse when needed.

Recap

This nibble discussed a bunch of terms.

a venn diagram showing strict, non strict and lazy evaluation

I divided evaluation strategies into two categories: strict and non-strict.

In strict evaluation, all of an expression's sub-expressions are evaluated first. Non-strict evaluation encompasses all strategies that do anything else, including not evaluating some arguments at all.

Strict evaluation is sometimes called eager evaluation, which sounds like it's the counterpart of lazy evaluation. However, lazy evaluation is just one of many possible non-strict strategies. Lazy evaluation stands out because it additionally caches results, but that's only possible if the expressions don't have side effects.

Finally, strict and non-strict evaluation is commonly used together in a single programming language, and even in the same expression. Hence the intersection in the image. An example is if...else...: evaluation of the condition is strict, but evaluation of the two clauses is non-strict.

Thanks for reading! I intend to write one nibble every month. For more, subscribe to my newletter and follow me on Twitter

Top comments (0)