Остерегайтесь привычек Java Stream API при написании кода Kotlin

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

Болезненный дизайн коллекций Java

В Java, хотя мы могли легко реализовать наши собственные неизменяемые структуры данных или просто использовать предоставленные неизменяемые представления, их ежедневное использование было затруднительно из-за устаревшего дизайна API коллекций.

Например, представьте, что вы реализуете новую неизменяемую структуру данных для Java и пытаетесь реализовать следующие методы:

boolean add(E e);
boolean remove(Object o);
void clear();
boolean removeAll(Collection<?> c);
// ...

Единственный разумный способ — просто запретить их использование:

public E set(int index, E element) {
    throw new UnsupportedOperationException();
}
public void add(int index, E element) {
    throw new UnsupportedOperationException();
}
public E remove(int index) {
    throw new UnsupportedOperationException();
}
// and so on ...

И так будет всегда, пока в Java реализована обратная совместимость (хотя в последних релизах это было не очень хорошо).

Это одна из причин, по которой единственным разумным способом обогатить API было введение новой абстракции — Stream API. Потоки не являются коллекциями, поэтому API можно разрабатывать с нуля.

Ленивый API потоковой передачи

Помимо нового причудливого функционального API, основным преимуществом java.util.stream.Stream была ленивость, которая делала их почти такими же производительными, как стандартные императивные решения, и позволяла работать с потенциально бесконечными последовательностями.

Рассмотрим следующий пример:

Optional<Integer> resultStream = list.stream()
  .map(i -> i * 2)
  .filter(i -> i > 1)
  .findAny();

Сначала может возникнуть соблазн подумать, что оптимистичная временная сложность равна O(2*N), в то время как императивная альтернатива обеспечивает O(1), будучи менее подверженной ошибкам и менее читабельной:

Optional<Integer> resultLoop = Optional.empty();
for (Integer i : list) {
    Integer integer = i * 2;
    if (integer > 1) {
        resultLoop = Optional.of(integer);
        break;
    }
}

Даже значительного улучшения в ремонтопригодности/читабельности кода недостаточно, чтобы оправдать влияние на производительность.

К счастью, благодаря ленивому вычислению оба примера обеспечивают оптимистическую временную сложность O(1) (совпадает первый встреченный элемент) и пессимистическую O(N) (нет совпадений элементов). Потоки просто берут элементы один за другим и проталкивают их. вся операционная цепочка, позволяющая закоротить, как только будет получен результат.

Ловушка API экспрессивных коллекций Kotlin

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

Отличительной особенностью коллекций в Kotlin является то, что они неизменяемы по умолчанию и имеют очень богатый функциональный API, поэтому нам больше не нужно обращаться к Stream API, поскольку наши коллекции уже предоставляют всю эту функциональность.

Итак, если бы мы попытались воссоздать приведенный выше пример, мы могли бы просто написать:

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

list
  .map(expensiveOperation())
  .map(anotherExpensiveOperation())
  .first()

Красиво и чисто, но есть одна загвоздка — давайте измерим время выполнения.

Как вы думаете, что будет в результате, если предположить, что и дорогаяОперация(), и другаяЭкспенсивнаяОперация() длятся ровно одну секунду?

Коллекции Kotlin не ленивы, поэтому операция заняла около 10 секунд, что соответствует оптимистичной временной сложности O (2 * N), где N = 5.

С большой силой приходит и большая ответственность — добавление каждого нового вызова map/flatMap/filter/… может негативно сказаться на временной сложности вашего решения в определенных сценариях, потому что каждый из них просто создает новый экземпляр коллекции, просто перебирая предыдущий — они также не поддерживают короткое замыкание.

Возвращаем лень

Всем хороших новостей!

Естественно, Kotlin предоставил собственный способ достижения лени в ситуациях, подобных приведенным выше, путем введения ленивых последовательностей. Мы можем преобразовать каждый Iterable в ленивую Sequence, которая является альтернативой Kotlin Stream, с помощью метода asSequence():

val list = listOf(1, 2, 3, 4, 5).asSequence()

list
  .map(expensiveOperation())
  .map(anotherExpensiveOperation())
  .first()

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

Приведенные выше фрагменты кода можно найти на GitHub.

Ключевые выводы

Коллекции Kotlin не ленивы по своей природе, как Java Stream API.
Если вы хотите лени, используйте asSequence().

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

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

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