Skip to main content

Kotlin Lazy Evaluation

When you're building applications that deal with large data sets or complex computations, performance matters. One powerful technique offered by functional programming is lazy evaluation - a strategy that defers computation until the results are actually needed. In this guide, we'll explore how Kotlin implements lazy evaluation and how you can use it to write more efficient code.

What is Lazy Evaluation?

Lazy evaluation (also called "non-strict evaluation") is a computation strategy where expressions aren't evaluated until their values are actually needed. This contrasts with eager evaluation (or "strict evaluation"), which computes values immediately.

In Kotlin, lazy evaluation provides several benefits:

  • Performance optimization: Avoiding unnecessary computations that might never be used
  • Working with infinite sequences: Processing potentially unlimited data streams
  • Memory efficiency: Not storing entire collections in memory at once
  • Cleaner code: Separating the description of computations from their execution

Lazy Properties in Kotlin

One of the simplest ways to experience lazy evaluation in Kotlin is through the lazy() delegate:

kotlin
val expensiveValue: String by lazy {
println("Computing expensive value...")
// Simulate complex computation
Thread.sleep(1000)
"Result of expensive computation"
}

fun main() {
println("Program started")
// expensiveValue is not computed yet
println("About to access expensiveValue for the first time")
println(expensiveValue) // First access triggers computation
println("Accessing expensiveValue again")
println(expensiveValue) // Uses cached result, doesn't recompute
}

Output:

Program started
About to access expensiveValue for the first time
Computing expensive value...
Result of expensive computation
Accessing expensiveValue again
Result of expensive computation

In this example:

  1. The value isn't computed until we first access it
  2. The result is cached after the first access
  3. Subsequent accesses use the cached value

The lazy() function returns a delegate that implements lazy initialization, which is useful for properties that might be expensive to create but not always needed.

Lazy Collections with Sequences

Kotlin provides Sequence as a counterpart to standard collections, offering lazy evaluation for collection operations.

The Problem with Eager Collections

Let's first see the standard collection approach, which is eager:

kotlin
fun main() {
val numbers = (1..10).toList()

val result = numbers
.map {
println("Map: $it")
it * 2
}
.filter {
println("Filter: $it")
it > 5
}
.take(3)

println("Before using the result")
println("Result: $result")
}

Output:

Map: 1
Map: 2
Map: 3
Map: 4
Map: 5
Map: 6
Map: 7
Map: 8
Map: 9
Map: 10
Filter: 2
Filter: 4
Filter: 6
Filter: 8
Filter: 10
Filter: 12
Filter: 14
Filter: 16
Filter: 18
Filter: 20
Before using the result
Result: [6, 8, 10]

Notice how all mapping operations execute first, then all filter operations, even though we only needed three elements.

Sequences: Lazy Collections

Now, let's convert to sequences for lazy evaluation:

kotlin
fun main() {
val numbers = (1..10).asSequence()

val result = numbers
.map {
println("Map: $it")
it * 2
}
.filter {
println("Filter: $it")
it > 5
}
.take(3)
.toList() // Terminal operation

println("Result: $result")
}

Output:

Map: 1
Filter: 2
Map: 2
Filter: 4
Map: 3
Filter: 6
Map: 4
Filter: 8
Map: 5
Filter: 10
Map: 6
Filter: 12
Result: [6, 8, 10]

The difference is striking! With sequences:

  1. Operations are applied to each element one at a time (horizontally rather than vertically)
  2. Processing stops once we have the three elements we need
  3. The remaining elements are never processed, saving computation time

Creating Sequences

There are multiple ways to create sequences in Kotlin:

  1. From a collection: listOf(1, 2, 3).asSequence()
  2. Using generateSequence:
kotlin
val fibonacci = generateSequence(Pair(0, 1)) { Pair(it.second, it.first + it.second) }
.map { it.first }

fun main() {
// First 10 Fibonacci numbers
println(fibonacci.take(10).toList())
}

Output:

[0, 1, 1, 2, 3, 5, 8, 13, 21, 34]
  1. Using sequence builder:
kotlin
val primes = sequence {
yield(2) // First prime
var number = 3

// Generate primes using a simple primality test
while (true) {
if ((2 until number).none { number % it == 0 }) {
yield(number)
}
number += 2 // Only check odd numbers
}
}

fun main() {
// First 10 prime numbers
println(primes.take(10).toList())
}

Output:

[2, 3, 5, 7, 11, 13, 17, 19, 23, 29]

Real-World Applications

Example 1: Processing Large Files

When working with large files, you don't want to load the entire file into memory:

kotlin
import java.io.File

fun processLargeFile(path: String) {
File(path).useLines { lines ->
lines
.filter { it.isNotBlank() }
.map { it.trim() }
.filter { it.startsWith("DATA:") }
.map { it.substringAfter("DATA:").trim() }
.take(10) // Only process first 10 matching lines
.forEach { println("Processing data: $it") }
}
}

The useLines function processes the file as a sequence of lines, allowing lazy evaluation without loading the whole file at once.

Example 2: Infinite Data Generation with Caching

Let's create an expensive calculation that's lazily computed and cached:

kotlin
class DataService {
// Simulate database or external API
private fun fetchDataFromRemote(id: Int): String {
println("Fetching data for ID $id from remote source...")
Thread.sleep(1000) // Simulate network delay
return "Data for ID $id"
}

// Cache using lazy delegates
private val cache = mutableMapOf<Int, Lazy<String>>()

fun getData(id: Int): String {
// Create or retrieve lazy delegate for this ID
val lazyData = cache.getOrPut(id) {
lazy { fetchDataFromRemote(id) }
}
return lazyData.value
}
}

fun main() {
val service = DataService()

println("First request for ID 1:")
println(service.getData(1))

println("Second request for ID 1:")
println(service.getData(1)) // Uses cached value

println("First request for ID 2:")
println(service.getData(2))
}

Output:

First request for ID 1:
Fetching data for ID 1 from remote source...
Data for ID 1
Second request for ID 1:
Data for ID 1
First request for ID 2:
Fetching data for ID 2 from remote source...
Data for ID 2

This pattern combines lazy evaluation with caching for optimal performance.

Lazy Evaluation vs. Eager Evaluation: When to Use Each

Lazy EvaluationEager Evaluation
Large or infinite data setsSmall, finite collections
When only a subset of results is neededWhen all results are needed
When operations are expensiveWhen operations are inexpensive
For improved memory efficiencyWhen immediate execution is required
For separating definition from executionWhen simpler code is preferred

Common Pitfalls and Best Practices

  1. Don't overuse: Lazy evaluation adds complexity; use it when you'll benefit from delayed computation.

  2. Be careful with side effects: Side effects in lazy operations can lead to unexpected behavior:

kotlin
val numbers = generateSequence(1) { it + 1 }
val squaredNumbers = numbers.map {
println("Computing square of $it")
it * it
}

println("Sequence created, taking first 3 elements:")
println(squaredNumbers.take(3).toList())

Output:

Sequence created, taking first 3 elements:
Computing square of 1
Computing square of 2
Computing square of 3
[1, 4, 9]
  1. Don't reuse terminal sequences: Once a terminal operation is applied to a sequence, it shouldn't be reused.

  2. Use sequences for chained operations: If you're applying multiple transformations, sequences can be more efficient.

Summary

Lazy evaluation in Kotlin offers powerful tools for writing efficient code that computes values only when needed. Through the lazy() delegate and sequences, you can:

  • Defer expensive computations until necessary
  • Work with potentially infinite data structures
  • Process large data sets without excessive memory usage
  • Separate the description of computation from its execution

Mastering lazy evaluation is an important step in functional programming with Kotlin, allowing you to write more efficient and expressive code.

Additional Resources

Exercises

  1. Create a lazy-evaluated sequence representing the first 100 prime numbers and measure its performance compared to an eager approach.

  2. Implement a custom caching mechanism using lazy() delegates for an API client that avoids redundant network calls.

  3. Write a function that uses sequences to efficiently find the first 5 words longer than 10 characters in a large text file without loading the entire file into memory.

  4. Create a lazy-evaluated Fibonacci sequence and use it to find the first Fibonacci number greater than 1,000,000.



If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)