Laziness, streams, and concurrency
Lesson 12 gave us recursion as the FP loop and showed that persistent structures make immutable updates cheap — a value is a tree whose unchanged parts are shared. This lesson adds one new lever: control over when evaluation happens. With lazy val and by-name parameters we defer work until it is needed; with a LazyList whose tail is a deferred recursion we describe infinite sequences and force only the prefix we use; we cash out a promise from Lesson 00 — that the pillars from 03–04 (immutable data, pure functions) are exactly what make sharing values across threads safe; and we extend pull-based laziness into streaming pipelines (Source → Flow → Sink) whose backpressure processes an unbounded effectful sequence in bounded memory.
map/flatMap).New capability: defer evaluation deliberately (
lazy val, by-name params), build and consume infinite sequences (LazyList), separate describing a computation from running it, explain precisely why pure functions over immutable data parallelize without locks, and read a streaming pipeline (Source → Flow → Sink) and say what backpressure buys you.lazy val. (2) Laziness needs purity — deferring is only safe when nothing happens. (3) LazyList: an infinite list whose tail is a deferred recursion. (4) Lazy pipelines fuse, so they avoid building intermediate collections. (5) Concurrency almost for free: no shared mutable state ⇒ no data races. (6) Future[A] as a first taste of asynchrony — and why it is eager, which sets up Lesson 14's lazy IO. (7) Streaming pipelines (Source → Flow → Sink) and backpressure: processing an unbounded effectful sequence in bounded memory.1 · Eager by default; lazy on request
Scala, like most languages, evaluates eagerly: an argument is computed before the function it is passed to runs. So in f(expensive()), expensive() runs even if f never looks at its argument. Two tools change when a value is computed.
A lazy val is computed at most once, on first access, then the result is cached. A by-name parameter, written x: => A, defers the argument: it is re-evaluated each time the body reads x, and never if the body never reads it. That second property gives us short-circuiting:
// Eager: BOTH arguments evaluated before `or` runs.
def orEager(a: Boolean, b: Boolean): Boolean = a || b
// By-name: `b` is a thunk; evaluated only if `a` is false.
def or(a: Boolean, b: => Boolean): Boolean =
if (a) true else b // if a is true, b is NEVER touched
def boom(): Boolean = { println("ran b!"); true }
or(true, boom()) // prints nothing, returns true
or(false, boom()) // prints "ran b!", returns true
// lazy val: computed once, then cached.
lazy val cfg = { println("loading"); loadConfig() }
// "loading" prints on the FIRST read of cfg, not before; later reads reuse it.
This is exactly how Scala's own && and || work. The point is not the operators — it is that we can now treat "the computation that produces a value" as a thing we hold and run later, rather than a value we already have.
2 · Laziness needs purity
Deferring, reordering, skipping, or caching an evaluation can only be done safely if evaluating it produces no observable effect beyond its return value — that is, if it is pure (Lesson 04). If b in or(a, b) writes a file or increments a counter, then whether we evaluate it changes the behavior of the whole program, and "skip it when a is true" silently drops an effect someone may have depended on.
This is the joint where the earlier pillars pay off. Immutable data (Lesson 03) means a deferred value cannot be changed out from under us between when we describe it and when we force it. Purity (Lesson 04) means when we force it is irrelevant to what we get. Together they make "compute this later, maybe, possibly on another thread" a safe transformation rather than a bug. Laziness is the first feature in this series that is only correct because the code is pure — keep that in mind for §5 and §6.
3 · LazyList: a list whose tail is a deferred recursion
Recall the cons-list from Lesson 12: a value is either Nil or head :: tail. A LazyList is the same shape, except the tail is by-name — it is not computed until you ask for it. Because the tail is a deferred recursion that produces another LazyList, the structure can be infinite: it is a finite description of an unbounded sequence.
val naturals: LazyList[Int] = LazyList.from(1) // 1, 2, 3, 4, ... forever
// Nothing has been computed yet. Build a pipeline, then force a prefix:
naturals.map(n => n * n).take(5).toList // List(1, 4, 9, 16, 25)
// A hand-rolled infinite stream: the tail is a deferred recursion.
def from(n: Int): LazyList[Int] = n #:: from(n + 1) // #:: is lazy cons
// `from(n + 1)` is NOT called until something forces the tail.
// Fibonacci as a self-referential lazy stream:
val fibs: LazyList[BigInt] =
BigInt(0) #:: BigInt(1) #:: fibs.zip(fibs.tail).map { case (a, b) => a + b }
fibs.take(8).toList // List(0, 1, 1, 2, 3, 5, 8, 13)
// Prime sieve, infinite, generate-and-filter:
def sieve(s: LazyList[Int]): LazyList[Int] =
s.head #:: sieve(s.tail.filter(_ % s.head != 0))
val primes = sieve(LazyList.from(2))
primes.take(5).toList // List(2, 3, 5, 7, 11)
The key idea, stated plainly: a lazy list separates the description of a computation from its execution. LazyList.from(1) describes all the naturals but runs nothing; .map and .filter extend the description; only a terminal operation like .take(n).toList forces evaluation — and it forces exactly what it needs and no more.
4 · Pipelines fuse: no intermediate collections
Lazy map/filter chains process the sequence element by element, pulling one value all the way through the pipeline before requesting the next. They never materialize a full intermediate collection. Contrast with an eager List, where each stage builds a complete new list in memory:
| Stage | Eager List | Lazy LazyList |
|---|---|---|
map(f) | allocates a full new list of all results | describes "apply f on demand"; allocates nothing yet |
filter(p) | allocates another full list | describes "keep those passing p on demand" |
take(5) | slices the already-built full list | pulls exactly 5 elements through the whole chain |
| Memory | O(n) per stage | O(1) live data + the result |
| Infinite input? | impossible — the first map never returns | fine — only the demanded prefix runs |
So (1 to 1000000).to(LazyList).map(_ * 2).filter(_ % 3 == 0).take(4).toList does only enough work to produce four elements, never allocating a million-element intermediate. This is the same map/filter you learned as functor operations (Lesson 08) — laziness changes only when they run, not what they mean.
val naturals = LazyList.from(1) // describes 1,2,3,... (computes NOTHING)
val evens = naturals.map(_ * 2) // describes 2,4,6,... (still NOTHING)
evens.take(5).toList // forces here -> List(2,4,6,8,10)
What ran? take(5).toList demands element 1: it pulls 1 from naturals, applies _ * 2 → 2. Demands element 2: pulls 2, → 4. …through element 5: pulls 5, → 10. Then take is satisfied and stops. Naturals 6, 7, 8, … and the multiplications on them are never evaluated — even though naturals and evens both describe infinitely many. Exactly five doublings happened. "Description vs execution" is now concrete: the program says infinite, the run does five.5 · Concurrency almost for free
Here is the deep payoff, and the reason this lesson sits next to laziness. A data race is two threads accessing the same memory location, at least one writing, without coordination — the classic source of nondeterministic concurrency bugs, normally fenced off with locks. A data race requires shared mutable state. Remove either word and it cannot occur:
So a pure map over an immutable List can be split across N threads — each handles a chunk, results are recombined — with no locks and no race, because nothing shared is ever written. (The "recombine" step is exactly an associative monoid combine from Lesson 09, which is why associativity is what enables parallel folds.) This is not a library trick; it is a direct consequence of the two pillars. Imperative code needs locks because it shares mutable state; functional code does not share mutable state, so it does not need locks. That is the real reason FP and parallelism fit together.
6 · Future[A]: a first taste of asynchrony
A Future[A] is a value that will hold an A once an asynchronous computation finishes. Crucially, it is a functor and monad (Lessons 08, 10): you map to transform the eventual result and flatMap to sequence dependent async steps — so you can compose asynchronous work in a for-comprehension without ever touching threads or locks directly:
import scala.concurrent.Future
import scala.concurrent.ExecutionContext.Implicits.global
def fetchUser(id: Int): Future[User] = ...
def fetchOrders(u: User): Future[Seq[Order]] = ...
val result: Future[Int] =
for {
user <- fetchUser(42) // flatMap
orders <- fetchOrders(user) // flatMap, depends on user
} yield orders.size // map
// No locks, no thread juggling — just functor/monad composition.
One catch, and it is the bridge to the final lesson: a Future is eager. The moment you write fetchUser(42), the request is already running — the effect has fired. So a Future is not a pure description you can pass around and run later; it is an effect already in flight, which means it is not referentially transparent (Lesson 04). That is precisely the gap Lesson 14 closes with the IO monad, which turns even side effects into lazy values you build purely and run only at the edge.
7 · Streaming pipelines and backpressure
A LazyList (§3–4) is pull-based, but in one narrow setting: a single consumer, in one thread, pulling pure data. Real systems stream something harder — an unbounded sequence of effects, with a producer and a consumer running independently and at different speeds: reading a 100 GB file, draining a socket, consuming a Kafka topic. Two failures lurk. If the producer outruns the consumer and you buffer the gap, memory grows without bound (an out-of-memory crash on a large input). If instead you block the producer on every element, you waste the parallelism that made it asynchronous. Neither "load it all into a List" nor "fire a Future per element" solves this: the first does not fit in memory, the second has no rate control.
The functional answer keeps the describe-then-run discipline you have seen three times now (by-name thunks, LazyList, and — next lesson — IO) and gives it a pipeline shape: Source → Flow → Sink.
LazyList.from.map, filter, grouped — the very functor operations from Lesson 08, now stages in a pipeline rather than methods on a collection.LazyList's .toList, it is what forces the pipeline to run.You connect a Source through some Flows to a Sink to describe a pipeline; like every other lazy value in this lesson, nothing runs until you run the whole graph. The new idea is backpressure: the Sink signals how much it is ready to consume, that demand propagates upstream through the Flows to the Source, and the Source produces only as fast as the slowest downstream stage can absorb. This is exactly the pull-based laziness of §3–4 — the consumer pulls, work happens on demand — generalized to independently running stages. The payoff is decisive: the pipeline runs in bounded memory regardless of input size, with no manual buffering and no locks, because the rate is coordinated by demand rather than by a shared mutable queue.
LazyList (§3–4) | Backpressured stream | |
|---|---|---|
| Elements | pure data | effectful (file/socket/queue) or pure |
| Stages | one consumer, in-process | independent stages, possibly across threads/machines |
| Rate control | n/a (single puller) | demand propagates upstream — the producer is throttled |
| Memory on huge input | O(1) live data | O(1) — bounded by the slowest stage, not the input size |
// pseudocode in the Source/Flow/Sink shape
val pipeline =
Source.fromFile("huge.txt") // emits lines, lazily, on demand
.mapConcat(line => line.split(" ")) // Flow: each line -> its words (map/filter, Lesson 08)
.map(_.toLowerCase) // Flow: normalize
.runFold(0)((count, _) => count + 1)// Sink: fold to a total — forces the run
Because the fold (Sink) pulls one line's worth at a time and that demand throttles the file read (Source), a 1 KB file and a 100 GB file run in the same fixed memory. No stage ever holds more than a few elements in flight. Compare the naive Source.fromFile(...).toList: it tries to materialize 100 GB of lines and dies. Backpressure is what makes "process an unbounded effectful sequence" safe — it is the streaming counterpart of the bounded-memory fusion you saw on LazyList in §4.The two production libraries are Akka/Pekko Streams — whose API is literally Source/Flow/Sink with built-in backpressure — and FS2 (Functional Streams for Scala). FS2 goes one step further: its stream is effect-typed, written Stream[IO, A] — a description of a stream of IO effects. That builds directly on the IO monad of Lesson 14, so the fully-pure version of streaming — effects and all, run only at the edge — is something you will only be able to read after the next lesson. Hold the Source→Flow→Sink picture; Lesson 14 supplies the IO that makes FS2's version of it referentially transparent.
Common mistakes / failure modes
lazy val that hides an effectlazy val x = { writeAudit(); 42 } — now when the audit write happens depends on the unpredictable moment of first access, which may be never, or on a different thread. Laziness over impure code makes timing a bug. Keep deferred computations pure (§2).val references the start of a long/infinite stream while you traverse it, every forced element stays reachable and cannot be garbage-collected — a memory leak. Consume it without keeping a reference to the head, or use an iterator (§3).Future { sideEffect() } runs immediately on creation, not when you call onComplete. Wrapping an effect in a Future does not defer it — it just moves it to another thread, now. Use IO (Lesson 14) for true deferral (§6).(1 to N).toList.map(...).take(5) with a huge N materializes the whole thing first. Reach for LazyList / view when you want take-only-what-you-need (§4).Source into an unbounded queue for a slow consumer trades a clean OOM for a slow one. A backpressured pipeline throttles the producer to the consumer's pace so memory stays bounded — don't hand-roll the buffer (§7).Checkpoint exercise
LazyList of perfect squares and take the first four. One way: LazyList.from(1).map(n => n * n).take(4).toList → List(1, 4, 9, 16); verify by tracing that only four multiplications run (§4 worked example). (b) Explain, in two sentences, why the same map over an immutable List can be run on N threads with no lock — name the two pillars (§5: the data is immutable so there is no write to race against; the function is pure so the threads share no mutable cell). Then state why doing the same with a Future per element still does not need locks, yet is not the same as a lazy description (§6).Where this points next
We can now defer computation, describe infinite sequences and force only what we need, and we understand why purity over immutable data makes concurrency safe. But Future exposed a crack: it is eager, so it is an effect already running, not a pure value. Lesson 14 closes the loop the whole series has been building toward. The IO monad makes even side effects into lazy values — descriptions of effects you compose purely with map/flatMap and run only once, at the very edge of the program. That is the imperative shell from Lesson 00 made concrete, and with type classes (trait + given/implicit) it assembles the entire functional-core / imperative-shell architecture from the parts of all fourteen prior lessons.
lazy val computes once and caches, a by-name parameter x: => A defers and can short-circuit, and a LazyList — a cons-cell whose tail is a deferred recursion (Lesson 12) — describes possibly-infinite sequences that a terminal take/toList forces exactly as far as needed, fusing pipelines so no intermediate collection is built. All of this is only safe because the deferred work is pure (Lesson 04) over immutable data (Lesson 03) — the same two pillars that, by eliminating shared mutable state, eliminate data races, so pure functions over immutable data parallelize across threads without locks. Future[A] is the first taste of composing asynchrony as a functor/monad in a for-comprehension, but it is eager — an effect already in flight — which is exactly the gap Lesson 14's lazy IO closes by making effects themselves into pure values run only at the edge.Interview prompts
- What is the difference between a
lazy valand a by-name parameter? (§1 — alazy valis evaluated once on first access then cached; a by-name parameterx: => Ais re-evaluated each time the body reads it and never if it isn't read, which is what enables short-circuiting like||.) - Why is laziness only safe over pure code? (§2 — deferring/reordering/skipping evaluation changes only when it runs; if running it has side effects, then whether/when it runs changes program behavior, so laziness over impure code is a bug. Immutability also guarantees the deferred value can't change before it's forced.)
- How can a
LazyListbe infinite, and what makes it terminate? (§3 — its tail is a by-name deferred recursion producing anotherLazyList, so it's a finite description of an unbounded sequence; a terminal operation liketake(n).toListforces exactly the demanded prefix.) - Trace what actually evaluates in
LazyList.from(1).map(_ * 2).take(5).toList. (§4 worked example — building the pipeline computes nothing;take(5).toListpulls elements 1–5 one at a time throughmap, performing exactly five doublings, then stops; the rest is never evaluated.) - Why do lazy pipelines avoid building intermediate collections? (§4 — lazy
map/filterdescribe per-element work and pull one element through the whole chain at a time, so no stage materializes a full list; eagerListallocates a complete new collection per stage.) - Why do pure functions over immutable data parallelize without locks? (§5 — a data race needs shared mutable state; immutable data has no write to race against and pure functions mutate nothing outside their result, so threads can't interfere — locks exist to guard mutable sharing that FP doesn't have.)
- Is a
Futurelazy? How does it differ from theIOcoming next? (§6 — no; aFuturestarts running on creation, so it's an effect already in flight and not referentially transparent. It composes as a functor/monad in for-comprehensions, but Lesson 14'sIOmakes effects lazy values you build purely and run once at the edge.) - What is backpressure, and what problem does the Source → Flow → Sink model solve that a
Listor a per-elementFuturedoes not? (§7 — backpressure is downstream demand propagating upstream so the producer runs only as fast as the slowest consumer; it lets an unbounded effectful sequence be processed in bounded memory. AListwon't fit; aFutureper element has no rate control. Akka/Pekko Streams and FS2'sStream[IO, A]implement it.)