Антипаттерны производительности API коллекций Kotlin

Kotlin Collections API является выразительным и богатым, но с большой силой приходит большая ответственность — есть определенные методы, которые могут привести к ненужной сложности времени и накладным расходам на выделение объектов.

Чтобы полностью понять контекст, обязательно сначала ознакомьтесь с предыдущей статьей.

1. Цепочка вызовов map()

Два последовательных вызова map() могут выглядеть невинными и оправданными с точки зрения удобочитаемости, но могут быть неприемлемы из-за влияния на производительность.

Давайте посмотрим на простой пример:

list
  .map { it.toString() }
  .map { it.toUpperCase() }

Все станет ясно, если мы разберем его по частям и взглянем на реализацию map():

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
}

Каждый вызов map() запускает новый O(n)-цикл for и создание нового объекта списка, который после завершения обработки удаляется сборщиком мусора. Это много? Это зависит™, но определенно лучше знать об этом компромиссе.

Оказывается, решение такое же тривиальное, как свертывание последовательных вызовов:

list.map { it.toString().toUpperCase() }

2. Цепочка вызовов map() и flatMap()

Как и в приведенном выше примере, всякий раз, когда вы видите комбинацию map() и flatMap(), драгоценные циклы ЦП можно сэкономить, включив их в один вызов flatMap().

Так:

list
  .map { it.toList() }
  .flatMap { it }

становится:

list.flatMap { it.toList() }

3. Цепочка map() и filterNotNull()

Если коллекция содержит элементы, допускающие значение NULL, может возникнуть соблазн сначала отфильтровать все значения NULL и только потом выполнять сопоставление:

val list = listOf(1, 2, 3, null)

list
  .filterNotNull()
  .map { it.toString() }

К сожалению, из-за энергичного характера коллекций Kotlin это опять же приводит к большому количеству ненужных накладных расходов:

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() создаст новую коллекцию, переберет существующую и переупаковает ненулевые элементы в новую, а затем вернет ее.
  • map() создаст новую коллекцию, переберет существующую, применит сопоставление и сохранит сопоставленные элементы в новой коллекции, а затем вернет ее.

Эти две операции можно объединить и оптимизировать, используя метод mapNotNull():

list.mapNotNull { it.toString() }

Это не только удобно, но и позволяет избежать создания избыточного недолговечного объекта коллекции и выполнения избыточных итераций.

4. Объединение map()/flatMap() с last()/first()

Всякий раз, когда мы хотим получить один элемент, нет необходимости сначала обрабатывать все элементы в коллекции, нужно только выбрать последний/первый:

list
  .map { it * 42 }
  .last()

Вместо этого лучше получить первый/последний элемент, а затем применить преобразование один раз:

list
  .last() * 42

Или, если мы хотим сохранить структуру связанных вызовов, мы можем использовать let():

list
  .last()
  .let { it * 42 }

5. Цепочка filter() и first()/last()

Всякий раз, когда мы хотим отфильтровать определенные элементы, а затем выбрать первый/последний из них, нет необходимости сначала обрабатывать все элементы:

val list = listOf(1, 2, 3)

list
  .filter { it % 2 == 1 }
  .last() // first()

Мы можем добиться большего успеха, просто используя специальный параметризованный метод last(predicate: (T) -> Boolean):

list.last { it % 2 == 1 }

Оказывается, этот фактически запускает обратную итерацию с конца коллекции:

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. Цепочка filter() и count()

Всякий раз, когда необходимо вычислить все вхождения элементов, соответствующих заданному предикату, нет необходимости сначала создавать отфильтрованную коллекцию с помощью filter(), а только затем применять count():

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

Вышеуказанное может быть просто достигнуто с помощью специальной перегрузки count(), которая избавит нас от создания нового объекта:

list.count { it % 2 == 1 }

Ключевые вынос

Kotlin Collection API является мощным и лучше знать о компромиссах, связанных с его рвением.

Фрагменты кода, как всегда, можно найти на GitHub.

Похожие записи

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *