How do I use lazy evaluation and avoid unnecessary computation in Kotlin?

In Kotlin, you can use lazy evaluation mainly with:

  1. lazy { ... } for lazily initialized properties
  2. Sequence for lazy collection-style pipelines
  3. Short-circuiting operators/functions like &&, ||, any, first, take
  4. Lambdas to defer expensive work until needed

1. Lazy property initialization with lazy

Use lazy when a value is expensive to create and may not always be needed.

val expensiveValue: String by lazy {
    println("Computing expensive value...")
    loadExpensiveData()
}

fun loadExpensiveData(): String {
    return "Result"
}

fun main() {
    println("Before access")

    // Computation happens here, on first access
    println(expensiveValue)

    // Reuses cached value, does not recompute
    println(expensiveValue)
}

Output:

Before access
Computing expensive value...
Result
Result

lazy computes the value only once by default.


2. Lazy collection processing with Sequence

Kotlin collections like List process intermediate operations eagerly:

val result = listOf(1, 2, 3, 4, 5)
    .map {
        println("map $it")
        it * 2
    }
    .filter {
        println("filter $it")
        it > 5
    }
    .first()

With a regular List, map creates a full intermediate list before filter runs.

Use asSequence() to process elements one at a time:

val result = listOf(1, 2, 3, 4, 5)
    .asSequence()
    .map {
        println("map $it")
        it * 2
    }
    .filter {
        println("filter $it")
        it > 5
    }
    .first()

println(result)

This stops as soon as first() finds a matching value.

For the example above, it processes only as much as needed:

map 1
filter 2
map 2
filter 4
map 3
filter 6
6

It does not process 4 or 5.


3. Prefer terminal operations that short-circuit

Some operations stop early when the answer is known.

Examples:

val numbers = listOf(1, 2, 3, 4, 5)

val hasEven = numbers.any { it % 2 == 0 }
val firstLarge = numbers.firstOrNull { it > 3 }
val firstThree = numbers.take(3)

These can avoid unnecessary work.

With sequences, this becomes especially useful:

val firstMatch = numbers
    .asSequence()
    .map { expensiveTransform(it) }
    .firstOrNull { it.isValid() }

This avoids transforming every element if a valid one appears early.


4. Use boolean short-circuiting

Kotlin’s && and || operators short-circuit.

if (user != null && user.isActive) {
    println("Active user")
}

user.isActive is checked only if user != null.

Another example:

if (cachedResult != null || computeFallback()) {
    println("We have a result")
}

computeFallback() runs only if cachedResult != null is false.


5. Defer expensive work with lambdas

If a function may not need a value, pass a lambda instead of the already-computed value.

Avoid this:

fun logDebug(message: String) {
    if (isDebugEnabled()) {
        println(message)
    }
}

logDebug("Result: ${expensiveComputation()}")

The expensive computation happens before logDebug is called.

Prefer this:

fun logDebug(message: () -> String) {
    if (isDebugEnabled()) {
        println(message())
    }
}

logDebug {
    "Result: ${expensiveComputation()}"
}

Now expensiveComputation() runs only if debug logging is enabled.


6. Use getOrPut for lazy map values

If you cache computed values in a map, use getOrPut:

val cache = mutableMapOf<String, Data>()

fun getData(key: String): Data {
    return cache.getOrPut(key) {
        loadData(key)
    }
}

loadData(key) only runs if the key is missing.


7. Build custom lazy sequences

You can create sequences using sequence { ... } and yield.

val generatedNumbers = sequence {
    println("Generating 1")
    yield(1)

    println("Generating 2")
    yield(2)

    println("Generating 3")
    yield(3)
}

val first = generatedNumbers.first()

println(first)

Only the first value is generated:

Generating 1
1

8. Be careful: sequences are not always faster

Use Sequence when:

  • You have a long chain of operations
  • You process large collections
  • You use short-circuiting terminal operations like first, any, take
  • You want to avoid intermediate collections

Regular collections may be better when:

  • The collection is small
  • The pipeline is simple
  • You need the final result as a list anyway
  • Performance overhead from sequences outweighs savings

Example where Sequence is useful:

val result = users
    .asSequence()
    .filter { it.isActive }
    .map { loadProfile(it) }
    .firstOrNull { it.hasPremiumAccess }

Example where a regular list is fine:

val names = users.map { it.name }

Common patterns

Lazy property

class ReportService {
    private val reportTemplate by lazy {
        loadTemplateFromDisk()
    }

    fun generateReport(): Report {
        return Report(reportTemplate)
    }
}

Lazy pipeline

val result = items
    .asSequence()
    .filter { shouldKeep(it) }
    .map { transform(it) }
    .take(10)
    .toList()

Lazy fallback

val value = cachedValue ?: computeExpensiveFallback()

computeExpensiveFallback() only runs if cachedValue is null.


Summary

Use:

  • by lazy { ... } for values initialized on first use
  • asSequence() for lazy collection pipelines
  • any, firstOrNull, take, etc. for short-circuiting
  • &&, ||, and ?: to avoid unnecessary calls
  • Lambdas like () -> String to defer expensive argument construction
  • getOrPut for lazy cache population

A good rule of thumb:

val result = data
    .asSequence()
    .filter { cheapCheck(it) }
    .map { expensiveTransform(it) }
    .firstOrNull { finalCheck(it) }

Filter early, transform late, and stop as soon as you have what you need.

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.