Kotlin Collections' API Performance Anti-Patterns
Want to learn more about Kotlin Collections? Check out this post to learn more about Kotlin Collections' API performance and anti-patterns.
Join the DZone community and get the full member experience.
Join For FreeKotlin’s collections API is expressive and rich — but with great power comes great responsibility. There are certain practices that can cause unnecessary time-complexity and object allocation overhead.
To fully understand the context, make sure to check the previous article, first:
1. Chaining map() Calls
Two consecutive map()
calls might look innocent and justified from the readability point of view, but they might not be acceptable because of the performance impact.
Let’s have a look at a simple example:
list
.map { it.toString() }
.map { it.toUpperCase() }
Everything becomes clear once we break it apart and take a sneak peek at the map()
implementation:
val map = list.map { it.toString() }
val anotherMap = map.map { it.toUpperCase() }
public inline fun <T, R, C : MutableCollection<in R>> Iterable<T>.mapTo(destination: C, transform: (T) -> R): C {
for (item in this) destination.add(transform(item))
return destination
}
Every map()
call triggers a new O(n)-running for loop and the creation of a new list object that gets garbage-collected after processing is finished. Is it a lot? It depends — but, it’s definitely better to be aware of that trade-off.
It turns out that the solution is as trivial as collapsing consecutive calls:
list.map { it.toString().toUpperCase() }
2. Chaining map() and flatMap() Calls
As in the example above, whenever you see a combination of map()
and flatMap()
, the precious CPU cycles can be spared by incorporating them into a single flatMap()
call.
So:
list
.map { it.toList() }
.flatMap { it }
becomes:
list.flatMap { it.toList() }
3. Chaining map() and filterNotNull()
If a collection contains nullable elements, it might be tempting to filter out all nulls first, and only then, you can perform mapping:
val list = listOf(1, 2, 3, null)
list
.filterNotNull()
.map { it.toString() }
Unfortunately, due to the eager nature of Kotlin collections, again, this generates a lot of unnecessary overhead:
public fun <C : MutableCollection<in T>, T : Any> Iterable<T?>.filterNotNullTo(destination: C): C {
for (element in this) if (element != null) destination.add(element)
return destination
}
-
filterNotNull()
will create a new collection, iterate over the existing one, and repackage non-null elements into the new one, then return it -
map()
will create a new collection, iterate over the existing one, apply mapping, and store mapped elements in a new collection, then return it
Those two operations could be merged and optimized by leveraging the mapNotNull()
method:
list.mapNotNull { it.toString() }
That’s not only convenient but avoids creating a redundant, short-lived collection object and performing redundant iterations.
4. Chaining map()/flatMap() With last()/first()
Whenever we want to obtain a single element, there’s no need to process all elements in a collection first, only to pick the last/first one:
list
.map { it * 42 }
.last()
Instead, it’s a better idea to obtain the first/last element, and then apply the transformation once:
list
.last() * 42
Or, if we want to keep a chained calls structure, we can leverage let()
:
list
.last()
.let { it * 42 }
5. Chaining filter() and first()/last()
Whenever we want to filter out certain elements and then pick first/last of them, there’s no need to process all elements first:
val list = listOf(1, 2, 3)
list
.filter { it % 2 == 1 }
.last() // first()
We can do much better just by leveraging a dedicated parameterized last(predicate: (T) -> Boolean) method:
list.last { it % 2 == 1 }
It turns out that this one actually starts the backward iteration from the end of a collection:
public inline fun <T> List<T>.last(predicate: (T) -> Boolean): T {
val iterator = this.listIterator(size)
while (iterator.hasPrevious()) {
val element = iterator.previous()
if (predicate(element)) return element
}
throw NoSuchElementException("List contains no element matching the predicate.")
}
6. Chaining filter() and count()
Whenever there’s a need to calculate all occurrences of items matching a given predicate, there’s no need to create a filtered collection using filter()
first, and only then applycount()
:
list
.filter { it % 2 == 1 }
.count()
The above can be simply achieved with a dedicated count()
overload that will spare us the creation of a new object:
list.count { it % 2 == 1 }
Conclusion
The Kotlin Collection API is a powerful and better way to be aware of the trade-offs associated with its eagerness.
Code snippets, as always, can be found on GitHub.
Published at DZone with permission of Grzegorz Piwowarek, DZone MVB. See the original article here.
Opinions expressed by DZone contributors are their own.
Comments