Press "Enter" to skip to content

Kotlin Collections API Performance Antipatterns

Kotlin’s Collections API is expressive and rich – but with great power comes great responsibility – there are certain practices that can cause an unnecessary time-complexity and object allocation overhead.

To fully understand the context, make sure to check the previous article first:

Kotlin: Beware of Java Stream API Habits

1. Chaining map() Calls

Two consecutive map() calls might look innocent and be justified from the readability point of view, but 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 a 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(), 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 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
}
  1. filterNotNull() will create a new collection, iterate over the existing one, and repackage non-null elements into the new one, then return it
  2. 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 allows avoiding creating redundant short-lived collection objects 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 apply count():

list
  .filter { it % 2 == 1 }
  .count()

The above can be simply achieved with a dedicated count() overload that will spare us a creation of a new object:

list.count { it % 2 == 1 }

Key Takeaway

Kotlin Collection API is powerful and better to be aware of trade-offs associated with its eagerness.

Code snippets, as always, can be found on GitHub.




If you enjoyed the content, consider supporting the site: