Recommended resources
The freely available resources this lesson will direct you to.
Hello! Welcome back to our module on Evaluation Strategies.
In our last lesson, we contrasted call-by-value with the historically significant but complex call-by-name strategy. We saw that call-by-name's key feature was delaying evaluation, but its major drawback was re-evaluating an expression on every use, making it inefficient and hard to reason about.
Today, we'll explore the modern evolution of this idea: lazy evaluation. This strategy, also known as call-by-need, refines call-by-name by evaluating an expression only when its value is first required, and then saving (or memoizing) the result for all future uses. This gives us the best of both worlds: we avoid unnecessary computations without the cost of repeated evaluation.
This brings us to today's learning outcome: Distinguish eager evaluation from lazy evaluation, comparing default strategies in languages like Python versus Haskell.
We will:
- Define eager and lazy evaluation.
- Examine Python's eager-by-default strategy and its tools for optional laziness.
- Explore Haskell's lazy-by-default approach and the powerful abstractions it enables.
- Analyze the critical trade-offs between these two strategies.
1. Eager vs. Lazy: The Fundamental Distinction
At its core, the difference between eager and lazy evaluation is about when a computation is performed.
-
Eager Evaluation (or Strict Evaluation): This is the strategy you are most familiar with from languages like Python, C++, and Java. Arguments to a function are fully evaluated before the function is called. The function receives the final values. It's simple and predictable: the order of evaluation follows the order of the code.
-
Lazy Evaluation (or Non-Strict Evaluation): This strategy, famously the default in Haskell, delays the evaluation of an expression until its value is actually needed. When the value is first required, the expression is evaluated, and the result is cached. Any subsequent time the value is needed, the cached result is returned instantly without re-computation.
This distinction is more than a minor implementation detail; it fundamentally changes how you can structure programs and reason about performance.
To get a more formal grasp of the terminology and the core trade-offs, let's start with a short reading.
What is the trade-off between Lazy and Strict/Eager ...
This Stack Overflow answer provides an excellent, nuanced discussion of the trade-offs between lazy and eager evaluation. It also clarifies the subtle difference between the semantic concepts (strict/non-strict) and the implementation strategies (eager/lazy).
Please read the section 'A quick note on terminology' and the main answer to question 1, 'Is lazy evaluation always good...'. Focus on the performance overhead of laziness and the difficulty of reasoning about side effects.
2. Python's Approach: Eager by Default, Lazy by Choice
Python is an eager language. Consider this simple code:
import time
def heavy_computation():
print("Starting heavy computation...")
time.sleep(2)
print("...finished heavy computation.")
return 42
def process_data(value):
print("Processing data.")
# Note: we don't even use 'value' here
return 100
# Eager evaluation in action
result = process_data(heavy_computation())
# Output:
# Starting heavy computation...
# ...finished heavy computation.
# Processing data.
As you can see, heavy_computation() is executed immediately and its result is passed to process_data, even though process_data never uses it. This is the essence of eager evaluation.
However, Python provides tools to opt into lazy evaluation where it's beneficial. The most important of these are generators.
A classic example of this design choice is the evolution of the range() function.
The Wikipedia article on Lazy Evaluation provides a concise and direct comparison using Python's range() function as an example.
Read the section on 'Python'. It contrasts the eager range() in Python 2 (which created a full list in memory) with the lazy range() object in Python 3 (which generates numbers on demand).
This shift in Python 3 reflects a key benefit of laziness: memory efficiency when dealing with large or potentially infinite sequences. Generators are Python's primary way to work with such sequences lazily.
To see how you can create and use your own generators, let's watch a video.
This video by Philipp Hagenlocher explains how lazy evaluation can be achieved in Python, focusing on the built-in generator mechanism.
Please watch from 10:17 to 13:30. This covers 'Infinite Data Structures with Python Generators' and 'Working with Infinite Generators'. Focus on how the yield keyword creates a generator that produces values on demand, and how you can compose generators with functions like map and filter.
In Python, you explicitly create a generator to get lazy behavior. Now, let's see what happens when laziness is the default.
3. Haskell's Approach: Lazy by Default
In Haskell, the situation is reversed. Laziness is the default, and eagerness is the exception. Every value is, by default, a "promise" to compute a result (a thunk) that is only fulfilled when another computation demands it.
This enables a very different style of programming, where you can define and manipulate conceptually infinite data structures as if they were concrete.
To get a feel for this, we'll watch a video where the presenter simulates Haskell-style lazy evaluation in TypeScript. Given your familiarity with JavaScript/TypeScript, this should provide an intuitive mechanical understanding of what's happening under the hood.
I programmed in TypeScript like in Haskell (Lazy Evaluation)
In this video, Tsoding provides a hands-on demonstration of lazy evaluation by building lazy constructs from scratch to mimic Haskell's behavior.
Please watch the following three segments: Introduction (0:03 - 12:22): Pay attention to how he contrasts a normal 'eager sum' with a 'lazy sum' by wrapping arguments in functions to delay their execution. Avoiding Heavy Computations (12:22 - 19:20): The hang function example here is a classic demonstration of a key benefit of lazy evaluation—if an argument isn't used, it's never evaluated. Infinite Data Structures (30:49 - 44:39): Focus on the implementation of the infinite range function. This shows how lazy evaluation makes it possible to define a list that never ends, because elements are only computed as they are accessed.
As the video demonstrates, a lazy-by-default model allows you to compose functions on infinite data structures, with the runtime automatically doing the minimum work necessary. For example, you can define the infinite list of all even numbers and then ask for the first ten, and the program will only compute what's needed to produce those ten numbers.
4. The Trade-Offs: When to be Eager or Lazy?
Neither strategy is universally superior. The choice involves a fundamental trade-off between performance predictability and abstractive power. Your background in economics and system design will find this balancing act familiar.
Here's a summary of the key trade-offs:
| Aspect | Eager Evaluation (e.g., Python default) | Lazy Evaluation (e.g., Haskell default) |
|---|---|---|
| Performance | Predictable. Computation cost is paid upfront. Easier to reason about time and memory usage. | Unpredictable. Performance depends on evaluation patterns. Can be faster if computations are skipped, but has overhead for creating and managing thunks. |
| Side Effects | Easier to manage. The order of operations is explicit, making it clear when I/O or state changes will occur. | Difficult to manage. The order of evaluation is not obvious, making side effects highly unpredictable. This is why lazy languages strongly encourage pure functions. |
| Data Structures | Limited to finite data structures. Handling large datasets requires special constructs like generators. | Natively supports infinite data structures. Allows for highly decoupled code where a "producer" can generate an infinite structure and a "consumer" takes only what it needs. |
| Memory Usage | Can be high if large, unused data structures are created. | Can be more memory-efficient by not computing unused values. However, it can also lead to space leaks, where a chain of unevaluated thunks holds onto large amounts of memory. |
| Control Flow | Control structures (if, while) are built-in language primitives. |
Control structures can often be defined as regular functions, since their arguments are not evaluated prematurely. |
The Stack Overflow answer you read earlier touched on many of these points. The risk of "space leaks" in lazy languages is a particularly subtle but important downside, which the Tsoding video also demonstrated when his range function initially caused a stack overflow by building up a giant chain of unevaluated computations.
Conclusion
Today we've explored the crucial distinction between eager and lazy evaluation, two fundamentally different approaches to executing a program.
Key Takeaways:
- Eager evaluation computes values immediately. It's the default in most languages like Python. It offers predictable performance and is easy to reason about, especially with side effects.
- Lazy evaluation defers computation until a value is needed, then caches the result. It's the default in languages like Haskell. It enables powerful abstractions like infinite data structures and can improve performance by avoiding unnecessary work, but at the cost of predictability and complexity when side effects are involved.
- Languages often provide a way to access the "other" strategy. Python (eager) has generators for laziness. Haskell (lazy) has mechanisms to force strictness (eagerness) for performance tuning.
Preview of the Next Lesson:
We've now defined call-by-value, call-by-name, and call-by-need (lazy evaluation). In the next lesson, we will analyze how these evaluation strategies affect program behavior, termination, and side effects using more complex comparative examples. We'll see how a seemingly simple function can produce different results, or even fail to terminate, depending entirely on the evaluation strategy used.