jonnifyfsharp

Dive C2.07.2

Deferred execution & laziness

The pipeline is a recipe, not a run. Nothing happens until something asks for an element.

A LINQ query does no work when you write it. Where(…).Select(…) builds an object that describes the transforms; the elements flow only when something enumerates it — a foreach, a ToList, a First. F#'s seq is the same lazy, pull-based sequence. The payoff is that a seq can be infinite: define every multiple of three, then ask for the first four, and the source yields exactly the elements those four require and stops. The cost is the day-one trap both languages share — enumerate twice and the pipeline runs twice. This dive shows both.

C2.07.2 · part 1

Building a pipeline runs nothing

In C#, a yield-based IEnumerable is lazy: the method body runs a step at a time as the caller pulls, and ToList() forces the whole thing. F#'s seq works the same way. The pipeline below describes a filter and a map; until Seq.take and Seq.toList ask for elements, no predicate runs and no element is squared.

C# — a yield iterator is lazy; ToList forces it

IEnumerable<int> Naturals() { var i = 0; while (true) yield return i++; }

var q = Naturals().Where(x => x % 3 == 0).Take(4);   // nothing has run yet
var got = q.ToList();                               // [0, 3, 6, 9] — now it runs

F# — a seq is lazy; Seq.toList forces it

Seq.initInfinite (fun i -> i)
|> Seq.filter (fun x -> x % 3 = 0)
|> Seq.take 4                          // still a recipe — nothing forced
|> Seq.toList
// val it : int list = [0; 3; 6; 9]
Seq.initInfinite
builds an unbounded sequence from an index function; it never ends, and that is fine because it is lazy.
Seq.take n
yields the first n elements and stops asking — it does not evaluate the rest.
force point
Seq.toList / Seq.toArray enumerate the sequence and build a concrete collection; that is when the work happens.

Pull take n from an infinite seq

The source is Seq.initInfinite (fun i -> i) — every natural number — filtered to the multiples of three. Raise take n and watch the counter: the source is pulled only as far as the n kept elements require, then stops. The pulled cells are the source elements actually evaluated; the taken cells are the multiples of three that survive. Everything past the last pull is never touched.

initInfinite → filter (x%3=0) → take n · pulled vs idle

4
source index i = … ∞ (never pulled)
take 4 = [0; 3; 6; 9] · source elements pulled: 10 (indices 0–9) · the rest of the infinite seq is never evaluated
Without JavaScript: take n, its result, and the source elements pulled
take n result source pulled
1[0]1
2[0; 3]4
3[0; 3; 6]7
4[0; 3; 6; 9]10

An infinite source is safe because a seq evaluates on demand. The pipeline pulls the minimum the result needs and leaves the rest of the sequence unevaluated — a recipe that runs only as far as it is read.

C2.07.2 · part 2

The day-one trap: enumerate twice, run twice

Because a seq is a recipe, enumerating it again re-runs the recipe. That is invisible for a pure pipeline, but if the source has a side effect — a database read, a console print, a counter — the effect happens once per enumeration. The fix is the same in both languages: materialise once with Seq.toList / Seq.toArray, or memoise the lazy sequence with Seq.cache so the source runs at most once.

let mutable calls = 0
let pipe = seq { 1..3 } |> Seq.map (fun x -> calls <- calls + 1; x * x)
Seq.toList pipe                       // [1; 4; 9]
Seq.toList pipe                       // [1; 4; 9]  — ran again
calls                                 // val it : int = 6  (the map ran twice)

let mutable calls2 = 0
let cached = (seq { 1..3 } |> Seq.map (fun x -> calls2 <- calls2 + 1; x * x)) |> Seq.cache
Seq.toList cached
Seq.toList cached
calls2                                // val it : int = 3  (Seq.cache ran the source once)
Reach for Seq.cache or a one-time Seq.toList whenever the source has a side effect or is expensive to produce, and you read it more than once. A pure pipeline over a pure source needs neither — re-running it gives the same answer at only the cost of the work.
C2.07.2 · part 3

Same laziness, both languages

Nothing here is new to a LINQ user; it is the same deferred model named in F# terms. A seq is IEnumerable<T>, deferred until enumerated, re-runnable, and cacheable. The one habit to carry across is the force point: know where Seq.toList sits, because that is the line where the recipe becomes work.

C# habit

A LINQ query is deferred; ToList() forces it, and re-enumerating a yield iterator re-runs the body.

F# form

A seq is deferred; Seq.toList forces it, and Seq.cache memoises a side-effecting source.

Where this leads

Pipelines and laziness cover the LINQ method syntax whole. C# has a second face — the query syntax, from x in … where … select … — and F# answers it with query { }, a computation expression that compiles to the same seq operations. That is the last dive.

References

Sources

Related in this course