CMPUT-325 · Lesson 6 of 12 · 34 min

Evaluation strategies, laziness and continuations

Lesson 5 showed that the order in which redexes are reduced can decide whether a computation finishes at all. Real languages must fix an order, and they must decide how arguments are passed to functions: as values, as references, or as unevaluated expressions. These choices affect performance, what programs can express and how easy programs are to reason about. This lesson compares the main evaluation strategies, implements lazy evaluation with thunks, and introduces continuations, the idea that 'the rest of the computation' can itself be a value. Continuations explain exceptions, generators, async functions and tail calls in one framework.

CMPUT-274 used generators and async functions as tools and mentioned laziness briefly. Here we implement the mechanisms ourselves, so that you can see exactly what a thunk, a continuation or a trampoline does.

Strict and non-strict evaluation

A function is strict in an argument if it always needs that argument's value. A language is strict, or eager, if it evaluates every argument before calling a function; this is call-by-value, the applicative order of lesson 5. C, Java, Python, Rust, OCaml and JavaScript are strict. A non-strict language passes arguments unevaluated and evaluates them only when, and if, they are needed. Call-by-name re-evaluates the argument expression every time the parameter is used, as ALGOL 60 did. Call-by-need evaluates it at most once and remembers the result; this is lazy evaluation, the strategy of Haskell.

The implementation device for non-strict evaluation is the thunk: a small record holding an unevaluated expression together with the environment needed to evaluate it. Forcing a thunk evaluates the expression. Under call-by-name a thunk is forced afresh each time; under call-by-need it caches its value after the first force. The interpreter below runs the same programs under all three strategies and counts how many times an expensive argument is evaluated.

Call-by-value, call-by-name and call-by-need in one interpreter, counting argument evaluations
# One interpreter, three parameter-passing strategies.  We count how many times each
# argument expression is actually evaluated.
#   e ::= n | x | ("+", e, e) | ("*", e, e) | ("if0", e, e, e) | ("fun", x, e) | ("app", e, e) | ("expensive", e)
class Thunk:
    def __init__(self, expr, env, memo):
        self.expr, self.env, self.memo, self.done, self.value = expr, env, memo, False, None
    def force(self, mode):
        if self.memo and self.done: return self.value          # call-by-need: reuse
        v = ev(self.expr, self.env, mode)
        if self.memo: self.done, self.value = True, v
        return v

COUNT = {"expensive": 0}
def ev(e, env, mode):
    if isinstance(e, int): return e
    if isinstance(e, str):
        v = env[e]
        return v.force(mode) if isinstance(v, Thunk) else v
    t = e[0]
    if t == "+": return ev(e[1], env, mode) + ev(e[2], env, mode)
    if t == "*": return ev(e[1], env, mode) * ev(e[2], env, mode)
    if t == "if0": return ev(e[2], env, mode) if ev(e[1], env, mode) == 0 else ev(e[3], env, mode)
    if t == "expensive":
        COUNT["expensive"] += 1; return ev(e[1], env, mode)
    if t == "fun": return ("closure", e[1], e[2], env)
    if t == "app":
        _, x, body, cenv = ev(e[1], env, mode)
        if mode == "value": arg = ev(e[2], env, mode)                 # evaluate now
        else: arg = Thunk(e[2], env, memo=(mode == "need"))           # delay
        return ev(body, {**cenv, x: arg}, mode)

square = ("fun", "x", ("*", "x", "x"))
ignore = ("fun", "x", 42)
arg = ("expensive", ("+", 3, 4))
for name, prog in (("square (expensive 7)", ("app", square, arg)), ("ignore (expensive 7)", ("app", ignore, arg))):
    for mode in ("value", "name", "need"):
        COUNT["expensive"] = 0
        result = ev(prog, {}, mode)
        print(f"{name:22} call-by-{mode:5}: result {result:3}, argument evaluated {COUNT['expensive']} time(s)")
Output
square (expensive 7)   call-by-value: result  49, argument evaluated 1 time(s)
square (expensive 7)   call-by-name : result  49, argument evaluated 2 time(s)
square (expensive 7)   call-by-need : result  49, argument evaluated 1 time(s)
ignore (expensive 7)   call-by-value: result  42, argument evaluated 1 time(s)
ignore (expensive 7)   call-by-name : result  42, argument evaluated 0 time(s)
ignore (expensive 7)   call-by-need : result  42, argument evaluated 0 time(s)

The results summarize the trade-off. For square, which uses its argument twice, call-by-name evaluates the argument twice; call-by-value and call-by-need evaluate it once. For ignore, which never uses its argument, call-by-value wastes an evaluation that the non-strict strategies skip. Call-by-need gets the best of both, at the cost of allocating and checking thunks, and of making it harder to predict when side effects happen. That last point is why lazy languages such as Haskell keep side effects out of ordinary expressions altogether.

Parameter passing in mainstream languages

Two more modes concern what the parameter refers to rather than when it is evaluated. With call-by-reference the parameter is an alias for the caller's variable, so assigning to it changes the caller's variable, as with C++ reference parameters. C has only call-by-value, so programs pass pointers when a function must change the caller's data. Java, Python and JavaScript are often described confusingly. All three are call-by-value, but for objects the value passed is a reference to the object. Reassigning the parameter has no effect on the caller, while mutating the shared object does. The phrase 'call-by-sharing', coined by Barbara Liskov for the CLU language, describes this exactly.

C passes everything by value; a pointer lets a function change the caller's variable
#include <stdio.h>

void by_value(int x)    { x = x + 1; }        /* changes a copy */
void by_pointer(int *p) { *p = *p + 1; }      /* C has no references; pass an address */

int main(void) {
    int n = 10;
    by_value(n);
    printf("after by_value:   n = %d\n", n);
    by_pointer(&n);
    printf("after by_pointer: n = %d\n", n);
    return 0;
}
Output
$ gcc -Wall toy.c -o toy
$ ./toy
after by_value:   n = 10
after by_pointer: n = 11
Java is call-by-value, where object values are references (call-by-sharing)
import java.util.ArrayList;
import java.util.List;

public class Passing {
    static void reassign(List<Integer> xs) { xs = new ArrayList<>(); xs.add(99); }
    static void mutate(List<Integer> xs)   { xs.add(99); }

    public static void main(String[] args) {
        List<Integer> a = new ArrayList<>(List.of(1, 2));
        reassign(a);
        System.out.println("after reassign: " + a);   // the caller's variable is untouched
        mutate(a);
        System.out.println("after mutate:   " + a);   // but the shared object changed
    }
}
Output
after reassign: [1, 2]
after mutate:   [1, 2, 99]

ALGOL 60's call-by-name allowed a famous trick, Jensen's device, in which a summation procedure is passed both an index variable and an expression mentioning it, so that each re-evaluation of the expression sees the updated index. It is elegant and confusing in equal measure, and it is one reason later languages abandoned call-by-name as a default. Scala keeps it as an option: a parameter declared as => T is passed by name, which lets library authors write functions that behave like new control structures.

Laziness in practice

Lazy evaluation makes infinite data structures possible. In Haskell, the list of all natural numbers or all primes is an ordinary value; only the part the program inspects is ever computed. It also lets programs be written as compositions of simple stages without worrying about wasted work. Python reaches for the same benefits with generators and the itertools module, which produce values one at a time on demand. Even strict languages contain small pieces of laziness: the && and || operators, conditional expressions and Python's and/or evaluate their second operand only if needed, which is why they cannot be written as ordinary functions in a strict language.

Lazy streams with Python generators: unbounded primes and Fibonacci numbers, plus short-circuit operators
# Lazy evaluation with generators: an infinite stream of primes, computed only on demand.
from itertools import count, islice, takewhile

def primes():
    """An unbounded sieve: keep a dict of the next composite for each prime found."""
    composites = {}
    for n in count(2):
        if n not in composites:
            yield n                       # n is prime; first composite to cross off is n*n
            composites[n * n] = [n]
        else:
            for p in composites.pop(n):
                composites.setdefault(n + p, []).append(p)

print("first 15 primes:", list(islice(primes(), 15)))
print("primes below 60:", list(takewhile(lambda p: p < 60, primes())))

# Laziness lets us define a stream in terms of itself.
def fibs():
    a, b = 0, 1
    while True:
        yield a
        a, b = b, a + b
print("first 12 Fibonacci numbers:", list(islice(fibs(), 12)))

# Short-circuit operators are a built-in form of laziness.
def noisy(v):
    print(f"  evaluated {v}")
    return v
print("False and noisy(True):", False and noisy(True))
print("True or noisy(False):", True or noisy(False))
Output
first 15 primes: [2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37, 41, 43, 47]
primes below 60: [2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37, 41, 43, 47, 53, 59]
first 12 Fibonacci numbers: [0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89]
False and noisy(True): False
True or noisy(False): True

Laziness has costs. Thunks use memory, and a long chain of unevaluated additions can consume far more memory than the final number would, a problem Haskell programmers call a space leak. The time at which a computation happens becomes hard to predict, which complicates performance tuning and makes side effects dangerous. Haskell therefore provides strictness annotations and functions such as seq and foldl' that force evaluation, and its compiler performs strictness analysis to evaluate eagerly whenever it can prove the result will be needed anyway.

Continuations

At any point during evaluation, there is a 'rest of the computation': what will be done with the value currently being computed. A continuation is that rest of the computation, made explicit as a function. In continuation-passing style (CPS), every function takes an extra argument k, its continuation, and instead of returning a result it calls k with the result. A CPS program never returns in the ordinary sense; every call is a tail call. Compilers for Scheme, ML and Kotlin's coroutines translate programs into CPS or something close to it, because it makes control flow explicit and easy to manipulate.

Factorial in direct style, in CPS, and with a trampoline that avoids stack overflow
# Continuation-passing style: every function takes an extra argument k, "what to do next".
import sys

def fact_direct(n):
    return 1 if n == 0 else n * fact_direct(n - 1)

def fact_cps(n, k):
    if n == 0:
        return k(1)
    return fact_cps(n - 1, lambda r: k(n * r))      # the pending multiplication moves into k

print("direct fact 10 =", fact_direct(10))
print("CPS fact 10    =", fact_cps(10, lambda r: r))

# Every call in CPS is a tail call, but CPython does not eliminate tail calls,
# so deep recursion still overflows.  A trampoline returns thunks instead of calling.
def fact_tramp(n, k):
    if n == 0:
        return lambda: k(1)
    return lambda: fact_tramp(n - 1, lambda r: lambda: k(n * r))

def run(thunk):
    while callable(thunk):
        thunk = thunk()
    return thunk

sys.setrecursionlimit(1000)
try:
    fact_direct(5000)
except RecursionError:
    print("direct fact 5000: RecursionError (stack depth limit)")
bits = run(fact_tramp(5000, lambda r: r)).bit_length()
print(f"trampolined CPS fact 5000: a {bits}-bit integer, no stack overflow")
Output
direct fact 10 = 3628800
CPS fact 10    = 3628800
direct fact 5000: RecursionError (stack depth limit)
trampolined CPS fact 5000: a 54233-bit integer, no stack overflow

A tail call is a call that is the last thing a function does. Languages that guarantee tail-call elimination, such as Scheme, run such calls without growing the stack, so loops can be written as recursion. CPython deliberately does not eliminate tail calls, partly to keep full stack traces for debugging, which is why the trampoline above returns thunks to a driver loop instead of calling directly. JavaScript's specification required proper tail calls in 2015, but only Safari's engine implemented them; the others declined, a reminder that a feature in a standard is not the same as a feature in use.

Control operators built from continuations

Once continuations are explicit, many control features become ordinary functions. An exception or early exit simply ignores the current continuation and calls an outer one instead. A generator saves its continuation when it yields and resumes it on the next request. Async and await save the continuation of an awaiting function and resume it when the awaited result arrives. Scheme exposes continuations directly with call/cc (call-with-current-continuation), which captures the current continuation as a first-class value; Racket, Ruby and Standard ML of New Jersey offer variants. The interpreter below adds an escape construct to a CPS interpreter in a single line.

A CPS interpreter in which escape abandons the current continuation, like an exception
# A CPS interpreter makes control operators easy.  Here "escape" aborts the whole
# computation with a value, the way an exception or an early return would.
def ev(e, env, k, top):
    if isinstance(e, int): return k(e)
    if isinstance(e, str): return k(env[e])
    t = e[0]
    if t in ("+", "*"):
        return ev(e[1], env, lambda a: ev(e[2], env, lambda b: k(a + b if t == "+" else a * b), top), top)
    if t == "if0":
        return ev(e[1], env, lambda c: ev(e[2] if c == 0 else e[3], env, k, top), top)
    if t == "escape":
        return ev(e[1], env, top, top)            # ignore k: jump straight to the top continuation
    if t == "let":
        return ev(e[2], env, lambda v: ev(e[3], {**env, e[1]: v}, k, top), top)

done = lambda v: ("finished", v)
aborted = lambda v: ("escaped", v)
# product of a list, escaping with 0 as soon as a zero is seen
def product(xs):
    e = 1
    for i, _ in enumerate(xs):
        e = ("*", ("if0", f"x{i}", ("escape", 0), f"x{i}"), e)
    env = {f"x{i}": v for i, v in enumerate(xs)}
    return ev(e, env, done, aborted)

print(product([3, 4, 5]))
print(product([3, 0, 5]))
Output
('finished', 60)
('escaped', 0)
Evaluation of 3 * (if x = 0 then escape 0 else x) * 5 drawn as a chain of boxes. Each box is a continuation waiting for a value: "multiply by 5", then "multiply by 3", then "finish". A normal return passes the value along the chain from left to right. An escape arrow jumps from the inner expression directly to the "escaped" box at the top, skipping the pending multiplications.
Continuations as a chain of pending work; an escape jumps over the pending steps

Continuations also give a precise account of the difference between statements such as return, break and continue: each one transfers control to a particular continuation saved at a particular point. Lesson 3's small-step semantics can be extended with such operators, and compilers lower all of them to jumps in the end.

Worked examples

Worked problem

Counting evaluations

Under call-by-value, call-by-name and call-by-need, how many times is g() evaluated in f(g()) where f(x) = x + x + x?

Show solution
  1. Call-by-value evaluates g() once before calling f, then uses the value three times.
  2. Call-by-name passes the expression g(), which is re-evaluated at each of the three uses.
  3. Call-by-need evaluates g() at the first use and reuses the cached value at the other two.

Answer: Once, three times and once respectively; if g() had a side effect such as printing, call-by-name would print three times.

Worked problem

Reference versus sharing

In Python, def f(xs): xs = xs + [4] and def g(xs): xs.append(4). After a = [1]; f(a); g(a), what is a?

Show solution
  1. Python passes a reference to the list object by value.
  2. In f, xs + [4] creates a new list and rebinds the local name xs; the caller's a is unaffected.
  3. In g, append mutates the shared list object, which the caller also refers to.

Answer: a is [1, 4]: only g's mutation is visible to the caller.

Worked problem

Converting to CPS

Convert def total(xs): return 0 if not xs else xs[0] + total(xs[1:]) to continuation-passing style.

Show solution
  1. Add a continuation parameter k and call it instead of returning.
  2. Base case: when xs is empty, call k(0).
  3. Recursive case: the pending work is "add xs[0] to the result", so pass a new continuation lambda r: k(xs[0] + r).
  4. Initial call: total_cps(xs, lambda r: r).

Answer: def total_cps(xs, k): return k(0) if not xs else total_cps(xs[1:], lambda r: k(xs[0] + r)).

Common mistakes

  • MistakePython and Java pass objects by reference.

    InsteadThey pass references by value (call-by-sharing): mutation is visible to the caller, but reassigning the parameter is not.

  • MistakeLazy evaluation is always faster.

    InsteadIt avoids unneeded work but adds thunk overhead and can cause space leaks.

  • MistakeCall-by-name and call-by-need are the same.

    InsteadCall-by-name re-evaluates the argument at each use; call-by-need caches the first result.

  • MistakeCPS automatically prevents stack overflow in Python.

    InsteadCPython does not eliminate tail calls; a trampoline or loop is still needed.

  • MistakeShort-circuit && can be written as an ordinary function in a strict language.

    InsteadA strict function evaluates both arguments; short-circuiting needs special evaluation rules or explicit thunks.

Summary and key terms

  • Strict languages evaluate arguments before calls; non-strict languages delay them.
  • Call-by-name re-evaluates at each use; call-by-need (laziness) evaluates once and caches.
  • Thunks implement delayed evaluation.
  • C is call-by-value; Java, Python and JavaScript pass object references by value (call-by-sharing); C++ adds reference parameters.
  • Laziness enables infinite streams but can cause space leaks and unpredictable timing.
  • Continuations make the rest of the computation explicit; in CPS every call is a tail call.
  • Exceptions, generators and async functions can all be explained as manipulations of continuations.
Strict evaluation
Evaluating all arguments before a function is called.
Call-by-name
Passing an unevaluated argument that is re-evaluated at each use.
Call-by-need
Passing an unevaluated argument that is evaluated at most once and cached.
Thunk
A delayed computation: an expression packaged with its environment.
Call-by-reference
Passing a parameter that aliases the caller's variable.
Call-by-sharing
Passing a reference to an object by value, so mutation is shared but reassignment is not.
Space leak
Excess memory use caused by unevaluated thunks being retained.
Continuation
The rest of a computation, represented as a function awaiting a value.
Tail call
A call that is the last action of a function, which can run without growing the stack.
Trampoline
A loop that repeatedly calls returned thunks to avoid deep recursion.

Practice problems

Work each problem on paper first, then reveal the answer to check.

  1. Is the function lambda x, y: x strict in y?

    Show answer

    No; it never uses y, so a non-strict language need not evaluate it.

  2. Under which strategy does f(print("hi")) print nothing if f ignores its argument?

    Show answer

    Call-by-name or call-by-need; call-by-value prints before the call.

  3. What does a thunk contain?

    Show answer

    An unevaluated expression and the environment needed to evaluate it (plus a cached value for call-by-need).

  4. In the C program, why did by_value leave n unchanged?

    Show answer

    C copies the argument; the function changed its own copy.

  5. Why is Java called call-by-value even for objects?

    Show answer

    The value passed is a reference; reassigning the parameter does not affect the caller.

  6. Give an advantage and a disadvantage of lazy evaluation.

    Show answer

    Advantage: avoids unneeded work and allows infinite structures. Disadvantage: thunk overhead, space leaks and unpredictable timing of effects.

  7. What is a continuation?

    Show answer

    A representation of the rest of the computation, waiting for the current value.

  8. Why does CPS make every call a tail call?

    Show answer

    Instead of returning and doing more work, each function passes the remaining work forward in its continuation.

  9. How does a trampoline avoid stack overflow?

    Show answer

    Functions return thunks instead of calling; a loop calls them one at a time, so the stack does not grow.

  10. How can an exception be expressed with continuations?

    Show answer

    Raising it ignores the current continuation and calls the continuation of the nearest handler.

Lesson quiz · 5 questions

Score 60% or more to complete this lesson.

Take the quiz