JavaRush/Courses/Kotlin SELF/Sequence: laziness and terminal operations

Sequence: laziness and terminal operations

Open

1. Why you need Sequence

When you start writing chains like filter → map → filter → map, you get a pleasant feeling that the code now resembles a normal human thought: “take expenses, keep only the needed ones, transform them, compute.” But chains on regular collections have a hidden side: many steps create intermediate collections “right now.” On small datasets that’s not a problem at all, but on large ones it can turn into extra work and extra memory. That’s exactly where Sequence comes in: it lets you describe a chain so that computations happen on demand—when we actually request the result.

Important: Sequence is not a “magic speed-up button,” but a different execution model. Today our goal is to learn to understand when a chain is actually executed, and why sometimes it feels like the program “did nothing.”

What Sequence is and how to get it

If a List collection is like a ready plate of pasta (you can eat it right away, count noodles, sort noodles, argue with friends that the noodles are not sorted), then Sequence is more like a recipe: “take ingredients, strain, chop, saute…”. A recipe by itself doesn’t fill you up. What fills you up is the moment when you start cooking.

From Kotlin’s point of view, Sequence<T> is an interface that you can iterate over (via Iterator). That is, Sequence is suitable for a “left-to-right pass,” but it does not promise the convenience of random access by index (it’s not a list).

The most important rule of the day sounds like this:

Sequence is lazy: intermediate steps are not executed immediately. They run only when a terminal operation appears—one that asks for a result.

How to switch a collection into lazy mode with asSequence()

When we have a collection and we want to apply the lazy model to it, we usually do this:

fun main() {
    val xs = listOf(1, 2, 3, 4)

    val ys = xs.asSequence()
        .filter { it % 2 == 0 }
        .map { it * 10 }
        .toList()

    println(ys) // [20, 40]
}

Notice the toList() at the end: it’s not “cosmetics,” but the moment when we say: “Ok, Kotlin, now actually compute it and give me a regular list.”

In Kotlin documentation and examples, they typically show exactly this pattern: a chain on Sequence, then toList() to get the final collection.

3. Intermediate and terminal operations

Now comes an important idea that saves hours of debugging and a couple of nerve cells (which, as we know, are immutable for programmers too).

Intermediate operations are the ones that return Sequence again and keep building the “pipeline”: map, filter, distinct, sorted (yes, sequences have variants), take, drop, and many others. They don’t have to perform computations right now—they only add a “stage to the pipeline.”

Terminal operations are the ones that request a result: toList(), toSet(), count(), first() / firstOrNull(), find(), fold(), sum() (in various forms), etc. A terminal operation triggers execution: elements start flowing through the pipeline until the result is produced.

For convenience, let’s summarize this in a table. It’s not “complete across the entire standard library,” but it provides the correct mental model.

Operation On Sequence, it’s usually… Returns Triggers computation?
map { ... }
intermediate
Sequence<R>
no
filter { ... }
intermediate
Sequence<T>
no
take(n)
intermediate
Sequence<T>
no
toList()
terminal
List<T>
yes
count()
terminal
Int
yes
firstOrNull()
terminal
T?
yes
fold(initial) { ... }
terminal
R
yes

If you keep exactly this table in mind, most of the mysteries around Sequence disappear.

4. Why “nothing happened”

This is a classic situation: a student writes a chain, expects something to run inside filter or map right away, then runs the program—and… silence. It feels like Kotlin ignored your code. Kotlin can do a lot, of course, but mind reading hasn’t shipped yet: you simply didn’t request a result.

Let’s look at an example where we explicitly print inside filter and map to “see the execution.”

fun main() {
    val xs = listOf(1, 2, 3, 4)

    val seq = xs.asSequence()
        .filter { x ->
            println("filter: $x")
            x % 2 == 0
        }
        .map { x ->
            println("map: $x")
            x * 10
        }

    println("before toList")          // before toList
    val ys = seq.toList()
    println("after toList: $ys")      // after toList: [20, 40]
}

The output order matters here. First you’ll see "before toList", and only then will "filter: ..." and "map: ..." start printing, because before toList() the chain was only “described.”

And now—the same “painful” example, where the terminal operation is missing:

fun main() {
    val xs = listOf(1, 2, 3)

    xs.asSequence()
        .filter { x ->
            println("check: $x")
            x > 1
        }

    println("done") // done
}

The only output will be "done". The lines "check: ..." won’t appear at all, because you created a Sequence, added an intermediate filter step, and then… threw it all into the trash. Formally, you built a pipeline and immediately went home without turning the machine on.

5. Early terminal operations and materialization

In this section, it’s important to carefully land the idea: a terminal operation triggers execution, but it doesn’t always need to process everything to the end. For example, toList() does collect all result elements, but firstOrNull() may stop at the first matching element.

This is not “optimization for the sake of optimization,” it’s simply the meaning of the operation: if I asked for “the first matching one,” why would I keep iterating?

Let’s look at a demonstration:

fun main() {
    val xs = listOf(1, 2, 3, 4, 5)

    val firstBigEven = xs.asSequence()
        .filter { x ->
            println("filter: $x")
            x % 2 == 0
        }
        .map { x ->
            println("map: $x")
            x * 10
        }
        .firstOrNull { value ->
            println("firstOrNull predicate: $value")
            value >= 30
        }

    println("result: $firstBigEven") // result: 40
}

Notice: the sequence is not required to process all elements up to 5. It will iterate until it finds an answer that satisfies firstOrNull { ... }.

toList() as materialization

Here it’s useful to say in plain language that toList() is like the “cook it and put it on a plate” button. While you’re in Sequence, you have a recipe and a pipeline. After toList(), you get a concrete List, which you can index into, print, sort in the usual ways, pass to places that expect a List, and generally live a familiar life.

A “typical” back-and-forth transition example:

fun main() {
    val raw = listOf("  food:120  ", " taxi:250", "food:80")

    val cleaned = raw.asSequence()
        .map { it.trim() }
        .filter { it.isNotEmpty() }
        .toList()

    println(cleaned) // [food:120, taxi:250, food:80]
}

After toList() you’re back in a normal list. And that’s fine: in real programs, Sequence is often used as an “internal processing mode,” while outward-facing results are returned as regular collections or final numbers/strings.

6. Sequence in the BudgetBuddy application

Now we’ll carefully continue our educational console application (let’s call it BudgetBuddy): it stores expenses and can build reports. Let me remind you of the data-structure idea without OOP: expenses can be stored as Triple(category, amount, note). It’s not “perfect,” but for the current stage of the course it’s enough.

Let’s start with a small data stub:

typealias Expense = Triple<String, Int, String>

fun main() {
    val expenses = listOf(
        Expense("food", 120, "lunch"),
        Expense("taxi", 250, "airport"),
        Expense("food", 80, "coffee"),
        Expense("books", 500, "kotlin"),
    )

    println(expenses.size) // 4
}

Example report by category "food"

Let’s build a chain that:

first keeps only "food", then converts an expense into a report line string, and at the end collects a list of strings.

typealias Expense = Triple<String, Int, String>

fun main() {
    val expenses = listOf(
        Expense("food", 120, "lunch"),
        Expense("taxi", 250, "airport"),
        Expense("food", 80, "coffee"),
    )

    val lines = expenses.asSequence()
        .filter { (cat, _, _) -> cat == "food" }
        .map { (cat, amount, note) -> "$cat: $amount ($note)" }
        .toList()

    println(lines) // [food: 120 (lunch), food: 80 (coffee)]
}

Why is Sequence even appropriate here if there isn’t much data? The honest answer: at this scale, it doesn’t matter much. But it’s a great way to get used to the model: “intermediate steps don’t execute until there’s a terminal one.”

“I added filter, but nothing changed”

One of the typical mistakes in BudgetBuddy reports looks like this: a student builds a Sequence, expects it to somehow “update the list by itself,” but the chain is neither saved anywhere nor materialized.

Here’s an example of the wrong approach (it compiles and does nothing—just like some students on Monday morning):

typealias Expense = Triple<String, Int, String>

fun main() {
    val expenses = mutableListOf(
        Expense("food", 120, "lunch"),
        Expense("taxi", 250, "airport"),
    )

    expenses.asSequence()
        .filter { (cat, _, _) -> cat == "food" }

    println(expenses.size) // 2
}

Why 2 and not 1? Because filter on a Sequence does not “remove from the list,” it creates a new sequence (a selection recipe). You didn’t use it and didn’t request a result.

The correct approach is to get the result and use it explicitly:

typealias Expense = Triple<String, Int, String>

fun main() {
    val expenses = listOf(
        Expense("food", 120, "lunch"),
        Expense("taxi", 250, "airport"),
    )

    val foods = expenses.asSequence()
        .filter { (cat, _, _) -> cat == "food" }
        .toList()

    println(foods.size) // 1
}

7. Sequence and side effects

When you see laziness, it’s tempting to use println inside map/filter not only for learning, but also “for real”: logging, counting something in an external variable, updating state. It’s possible, but it very quickly turns into a trap.

The problem is that in the lazy model you can no longer “intuitively” say when exactly your code will run. It will run when someone requests a result, and exactly as many times as actually needed. If you accidentally call a terminal operation twice (for example, call toList() twice on the same sequence that you rebuilt), your side effect will happen twice too. And then you’ll think Kotlin “duplicates elements,” when in fact your logic is being duplicated.

So the good habit is: keep map/filter pure (no side effects), and if you need to “see what’s happening,” do it either temporarily via println or with a separate step that doesn’t affect the meaning of the program.

8. Typical mistakes when working with Sequence

Mistake #1: no terminal operation—“nothing happened.”
This is the most popular trap. You build asSequence().filter { ... }.map { ... }, and then you expect the list to have already “become different.” But Sequence is a lazy recipe, not a ready result. You need a terminal step: toList(), count(), firstOrNull(), fold(), and so on.

Mistake #2: expecting Sequence to “change the original collection.”
filter and map don’t remove elements from your MutableList and don’t rewrite it. They create a new result (or a new “pipeline”). If you need to actually delete something—that’s a different task and different code, and Sequence is not a magic broom here.

Mistake #3: side effects inside map/filter and the confusion that follows.
When inside map you print something, increment a counter, write to a file, or change an external variable, you tie the program’s meaning to the moment of evaluation. In the lazy model, the evaluation moment depends on the terminal operation, and the code becomes harder to predict and debug.

Mistake #4: turning Sequence into a “religion.”
Sometimes a student starts inserting asSequence() into every chain because it looks “professional.” On small data, this often brings no benefit, and sometimes even hurts readability: it becomes harder to understand where exactly you obtain the result. It’s much better to keep a simple rule in mind: Sequence is useful when lazy execution of the chain matters; otherwise, regular operations on List remain an excellent, readable tool.

Mistake #5: forgetting that the result is needed as a collection, and not materializing it.
A very common story in reports: you built a Sequence<String>, and then try to print it as if it were a List<String>, or pass it where a list is expected. The solution is simple: add toList() (or another terminal step) at the end and work with the result.

1
Task
Kotlin SELF,  25 level,  2 lecture
Locked
Bonus filter
Bonus filter
1
Task
Kotlin SELF,  25 level,  2 lecture
Locked
Lazy pipeline
Lazy pipeline
1
Task
Kotlin SELF,  25 level,  2 lecture
Locked
Quick search
Quick search
1
Task
Kotlin SELF,  25 level,  2 lecture
Locked
Expense cleanup
Expense cleanup
Комментарии
  • популярные
  • новые
  • старые
Для того, чтобы оставить комментарий Вы должны sign in
This page has no comments yet