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.
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
nelements and stops asking — it does not evaluate the rest. - force point
Seq.toList/Seq.toArrayenumerate 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
| 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.
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)
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.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
- Microsoft — F# documentation — sequences, laziness, and the
Seqmodule's force points. - FSharp.Core API reference —
Seq.initInfinite,Seq.take,Seq.cache,Seq.toList. - Microsoft — .NET documentation — LINQ deferred execution and
yielditerators on the CLR.
Related in this course
- C2.07.3 — Query expressions — the query-syntax form over the same lazy
seq. - C1.07 — Lists, arrays & the lazy seq —
seqand laziness in language depth. - C2 — F# for C# developers — each C# habit, translated to its idiomatic F# form.