Skip to Content
Course content

40: Sequences vs Collections: Lazy Evaluation

Click on the "Edit" button in the top corner of the screen to edit your slide content.

Imagine you're running a sandwich shop. You have two ways to handle a rush of ten customers who all want the same "Custom Veggie Deluxe."

The first way—the eager approach—is to prep everything in bulk. You slice all twenty cucumbers for all ten sandwiches, then you slice all twenty tomatoes, then you lay out all ten pieces of bread. Only after every single vegetable is sliced and piled into massive bowls do you actually start assembling the sandwiches. If the first customer suddenly changes their mind and wants a turkey sandwich instead, you've wasted a lot of effort slicing cucumbers they'll never eat.

The second way—the lazy approach—is to work one sandwich at a time. You take one piece of bread, slice one cucumber, slice one tomato, and hand the finished sandwich to the customer. You don't even touch the second cucumber until the first sandwich is out the door. If the customer cancels, you've only lost a few seconds of work, not a whole mountain of sliced veggies.

The Cost of Eager Collections

In Kotlin, standard List operations like filter and map are eager. Every time you call one of these functions, Kotlin creates a brand new list in memory to hold the results before moving to the next step. This is fine for a list of ten items, but it's a disaster for a list of ten thousand.

val users = loadMillionUsers() // A massive list
val result = users
    .filter { it.isActive }   // Creates a new intermediate list
    .map { it.name }          // Creates another intermediate list
    .take(3)                  // Finally, we only keep 3 items

Look at that chain. We might have filtered a million users down to 500,000, then mapped those 500,000 names into a new list, all just to throw away 499,997 of them because we only needed the first three. I've seen this cause "Out of Memory" errors in production systems that seemed perfectly simple on paper.

How Sequences Shortcut the Pipeline

This is where Sequence comes in. When you call .asSequence(), you're telling Kotlin: "Don't do any work yet. Just remember the plan."

A Sequence doesn't store data; it describes a process. When you call a "terminal operation" (like toList() or first()), the sequence starts pulling items through the pipeline one by one.

val result = users.asSequence()
    .filter { it.isActive }   // No list created; just a rule added to the pipeline
    .map { it.name }          // No list created; just another rule
    .take(3)                  // The trigger!
    .toList()                 // Terminal operation

In this version, Kotlin takes the first user. Is it active? Yes. Map the name. Put it in the result list. Repeat. As soon as the result list hits three items, the entire process stops. It doesn't matter if there are a million other users in the source list; the sequence never even looks at them.

Choosing Your Tool

You might be wondering, "Why not just use sequences for everything?" Because there's a trade-off. Sequences have a bit of overhead to set up the lazy machinery. If you're dealing with a small list (say, under 1,000 items) and you're performing simple operations, a standard List is actually faster.

I usually follow this rule of thumb: if the collection is large, or if the operations inside filter or map are computationally expensive (like hitting a database or doing complex regex), go with a Sequence. If you're just flipping a few booleans on a small list, stick to the standard Collection.




📋 Practical Task

Optimizing a Large Transaction Log Parser

You have been given a piece of code that processes a massive list of transaction strings. The current implementation is causing the application to lag and consume excessive memory because it processes every single log entry even when it only needs the first few matches.

The Goal: Refactor the findHighValueTransactions function to use Sequence so that it stops processing as soon as it finds the required number of matches.


data class Transaction(val id: String, val amount: Double)

fun parseLog(log: String): Transaction {
    // Simulate a slow parsing process
    Thread.sleep(1) 
    val parts = log.split(",")
    return Transaction(parts[0], parts[1].toDouble())
}

fun findHighValueTransactions(logs: List<String>): List<Transaction> {
    return logs
        .map { parseLog(it) }
        .filter { it.amount > 1000.0 }
        .take(5)
}

fun main() {
    val massiveLogList = List(10000) { "TXN$it,${(0..2000).random()}" }
    val startTime = System.currentTimeMillis()
    
    val highValue = findHighValueTransactions(massiveLogList)
    
    val endTime = System.currentTimeMillis()
    println("Found ${highValue.size} transactions in ${endTime - startTime}ms")
}

Instructions: Modify findHighValueTransactions to utilize .asSequence() and ensure you call the appropriate terminal operation to return a List. Observe how the execution time drops significantly because the code no longer parses all 10,000 logs just to find the first 5 high-value ones.

Rating
0 0

There are no comments for now.

to be the first to leave a comment.