skip to content

Sequences are lazily/deferred-evaluated. What surprising behaviors or pitfalls does this deferral cause around side effects, single-iteration, and exception timing?

level: seniorimportance: should knowfreq 40%

answer

  1. Work deferred to the terminal op, on its thread
  2. Two terminals re-run the whole pipeline (side effects double)
  3. One-shot/constrainOnce sources throw on second iteration
  4. Exceptions surface at the terminal call site
  5. Keep lambdas pure; materialize to snapshot

basics

~20 s

Because work is deferred until a terminal op, side effects in map/filter don't run when you write them, exceptions surface later, and some sequences can only be iterated once or re-run their work on each terminal call.

solid answer

~50 s

Deferral means intermediate operators capture lambdas but don't execute; the work happens at the terminal op, on the thread that calls it. Pitfalls: (1) **Side-effect timing** — logging/IO inside map runs at terminal time, not at definition time, which surprises people relying on ordering. (2) **Re-evaluation** — a `Sequence` is a recipe; calling two terminals on the same sequence value (e.g. `count()` then `toList()`) re-runs the whole pipeline twice, re-firing side effects. (3) **Single-use sources** — sequences built from a one-shot iterator (like a Stream or `Iterator.asSequence()`) throw `IllegalStateException: This sequence can be consumed only once`. (4) **Exception timing** — an exception in a lambda is thrown from the terminal call, not where the operator was written, complicating stack traces and try/catch placement. (5) **Capturing mutable state** in lambdas is dangerous because of deferred, possibly repeated execution.

code

kotlin · 11 lines
kotlin
val seq = (1..3).asSequence().onEach { println("touch $it") }

println("-- count --")
seq.count()    // touch 1, touch 2, touch 3

println("-- toList --")
seq.toList()   // touch 1, touch 2, touch 3  (re-evaluated!)

// Snapshot to consume multiple times safely:
val materialized = seq.toList()
materialized.size; materialized.first()  // no re-evaluation

go deeper

for a junior

Knows work happens at the terminal, not when map is written.

for a middle

Identifies re-evaluation on multiple terminals and that side effects can double.

for a senior

Covers one-shot/constrainOnce sources, exception timing at the terminal, and the purity requirement for lambdas.

for a principal

Sets team conventions (pure pipelines, materialize-to-snapshot, terminal-scoped error handling) and reasons about correctness risks when refactoring eager↔lazy.

## Deferred execution: the root cause Intermediate sequence operators (`map`, `filter`, …) only **store** their lambda inside a wrapper. Real execution is **deferred** to a **terminal operation** (`toList`, `count`, `forEach`, `first`). Everything below follows from that. ## Pitfall 1 — side effects fire late ```kotlin val s = listOf(1, 2, 3).asSequence().map { log("mapping $it"); it * 2 } println("built") // prints first — nothing logged yet val r = s.toList() // NOW the 'mapping' logs fire ``` If you assumed the mapping logged during construction, you are wrong. ## Pitfall 2 — terminals re-run the whole pipeline A `Sequence` value is a *plan*, not a cached result. Each terminal re-pulls from the source: ```kotlin val s = (1..3).asSequence().onEach { println("touch $it") } s.count() // touch 1, touch 2, touch 3 s.toList() // touch 1, touch 2, touch 3 -- AGAIN ``` Side effects double; expensive upstream work repeats. Materialize once (`toList()`) if you need to consume multiple times. ## Pitfall 3 — single-iteration / one-shot sources Sequences backed by a consumable iterator can only be iterated once. The constrained constructor (`Iterable.asSequence()` on a one-shot iterable, or `Iterator<T>.asSequence()`) yields a sequence that throws on a second pass: ```kotlin val once = sequenceOf(1, 2, 3).constrainOnce() once.toList() // ok once.toList() // IllegalStateException: This sequence can be consumed only once. ``` ## Pitfall 4 — exception timing & stack traces An exception thrown inside a `map` lambda surfaces from the **terminal** call site, not where `map` was written. `try/catch` must wrap the terminal operation, and stack traces point at the consumer, which can mislead debugging. ```kotlin val s = listOf(0).asSequence().map { 1 / it } // no throw here try { s.toList() } catch (e: ArithmeticException) { /* caught HERE */ } ``` ## Pitfall 5 — capturing mutable state Because execution is deferred and may repeat across terminals, lambdas that mutate or read external mutable state can observe stale/changed values or accumulate incorrectly. Keep sequence lambdas pure. ## Takeaways - Treat a `Sequence` as lazy and re-runnable; materialize with `toList()` when you need a stable, multiply-consumed snapshot. - Put `try/catch` around the terminal. - Avoid side effects in intermediate operators; prefer `onEach` only when you understand it fires per terminal.

  • How do you safely consume a sequence's results multiple times without re-running upstream work?
    Materialize once with toList()/toSet() and reuse the resulting collection; collections cache their elements.
  • Where should you place try/catch for an exception thrown inside a sequence's map lambda?
    Around the terminal operation (e.g. toList/forEach), because that's where deferred evaluation actually runs the lambda.
  • What does constrainOnce() do?
    Wraps a sequence so it can be iterated only once; a second terminal throws IllegalStateException.

saying these in an interview costs you the question

  • Assuming a Sequence caches results so multiple terminals are free
  • Wrapping try/catch around the map call instead of the terminal
  • Believing every Sequence can be iterated unlimited times
  • Putting important side effects in intermediate operators
  • Thinking side effects fire when the operator is written, not at the terminal

context