In Kotlin, you can use lazy evaluation mainly with:
lazy { ... }for lazily initialized propertiesSequencefor lazy collection-style pipelines- Short-circuiting operators/functions like
&&,||,any,first,take - 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 useasSequence()for lazy collection pipelinesany,firstOrNull,take, etc. for short-circuiting&&,||, and?:to avoid unnecessary calls- Lambdas like
() -> Stringto defer expensive argument construction getOrPutfor 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.
