1. Введение
Java 8 ввела концепцию потоков в иерархию коллекций. Они позволяют выполнять очень мощную обработку данных очень удобным для чтения способом, используя некоторые концепции функционального программирования, чтобы заставить процесс работать.
Мы рассмотрим, как мы можем достичь той же функциональности, используя идиомы Kotlin. Мы также рассмотрим функции, которые недоступны в обычном Java.
2. Java против Kotlin
В Java 8 новый fancy API можно использовать только при взаимодействии с экземплярами java.util.stream.Stream.
Хорошо то, что все стандартные коллекции – все, что реализует java.util.Collection – имеют определенный метод stream(), который может создавать экземпляр Stream.
Важно помнить, что Stream не является коллекцией. Он не реализует java.util.Collection и не реализует ни одну из обычных семантик коллекций в Java. Это больше похоже на одноразовый итератор в том смысле, что он является производным от коллекции и используется для работы с ней, выполняя операции над каждым видимым элементом.
В Kotlin все типы коллекций уже поддерживают эти операции без необходимости их предварительного преобразования. Преобразование необходимо только в том случае, если семантика коллекции неверна – например, набор содержит уникальные элементы, но не упорядочен.
Одним из преимуществ этого является то, что нет необходимости в первоначальном преобразовании из коллекции в поток и нет необходимости в окончательном преобразовании из потока обратно в коллекцию – с использованием вызовов collect().
Например, в Java 8 нам пришлось бы написать следующее:
someList
.stream()
.map() // some operations
.collect(Collectors.toList());
Эквивалент в Kotlin очень прост:
someList
.map() // some operations
Кроме того, потоки Java 8 также не подлежат повторному использованию. После того, как поток израсходован, его нельзя использовать снова.
Например, следующее не сработает:
Stream<Integer> someIntegers = integers.stream();
someIntegers.forEach(...);
someIntegers.forEach(...); // an exception
В Kotlin тот факт, что все это просто обычные коллекции, означает, что эта проблема никогда не возникает. Промежуточное состояние может быть присвоено переменным и быстро передано в общий доступ, и оно работает так, как мы и ожидали.
3. Ленивые последовательности
Одной из ключевых особенностей потоков Java 8 является то, что они вычисляются лениво. Это означает, что не будет выполнено больше работы, чем необходимо.
Это особенно полезно, если мы выполняем потенциально дорогостоящие операции с элементами в потоке или это позволяет работать с бесконечными последовательностями.
Например, IntStream.generate создаст потенциально бесконечный поток целых чисел. Если мы вызовем для него функцию findFirst(), мы получим первый элемент, а не запустим бесконечный цикл.
В Kotlin коллекции являются нетерпеливыми, а не ленивыми. Исключением здесь является Sequence, которая выполняет ленивую оценку.
Это важное различие, на которое следует обратить внимание, как показано в следующем примере:
val result = listOf(1, 2, 3, 4, 5)
.map { n -> n * n }
.filter { n -> n < 10 }
.first()
Версия Kotlin выполнит пять операций map(), пять операций filter(), а затем извлечет первое значение. Версия Java 8 выполнит только одну map() и один filter(), потому что с точки зрения последней операции больше ничего не требуется.
Все коллекции в Kotlin могут быть преобразованы в отложенную последовательность с помощью метода as Sequence().
Использование последовательности вместо списка в приведенном выше примере выполняет то же количество операций, что и в Java 8.
4. Потоковые операции Java 8
В Java 8 потоковые операции разбиты на две категории:
- intermediate and
- terminal
Промежуточные операции, по сути, лениво преобразуют один поток в другой – например, поток всех целых чисел в поток всех четных целых чисел.
Параметры терминала являются заключительным шагом цепочки методов Stream и запускают фактическую обработку.
В Kotlin такого различия нет. Вместо этого все это просто функции, которые принимают коллекцию в качестве входных данных и выдают новый результат.
Обратите внимание, что если мы используем готовую коллекцию в Kotlin, то эти операции вычисляются немедленно, что может быть удивительно по сравнению с Java. Если нам нужно, чтобы это было лениво, не забудьте сначала преобразовать в последовательность.
4.1. Промежуточные операции
Почти все промежуточные операции из Java 8 Streams API имеют эквиваленты в Kotlin. Однако это не промежуточные операции – за исключением случая класса Sequence – поскольку они приводят к полностью заполненным коллекциям в результате обработки входной коллекции.
Из этих операций есть несколько, которые работают точно так же – filter(), map(), flatMap(), distinct() и sorted() – и некоторые, которые работают одинаково, только с разными именами – limit() теперь take, а skip() теперь drop(). Например:
val oddSquared = listOf(1, 2, 3, 4, 5)
.filter { n -> n % 2 == 1 } // 1, 3, 5
.map { n -> n * n } // 1, 9, 25
.drop(1) // 9, 25
.take(1) // 9
Это вернет единственное значение “9” – 3².
Некоторые из этих операций также имеют дополнительную версию – с суффиксом слова “To” – которая выводится в предоставленную коллекцию вместо создания новой.
Это может быть полезно, например, для обработки нескольких входных коллекций в одну и ту же выходную коллекцию:
val target = mutableList<Int>()
listOf(1, 2, 3, 4, 5)
.filterTo(target) { n -> n % 2 == 0 }
Это вставит значения “2” и “4” в список “target”.
Единственной операцией, которая обычно не имеет прямой замены, является peek() – используется в Java 8 для перебора записей в потоке в середине конвейера обработки без прерывания потока.
Если мы используем ленивую последовательность вместо активной коллекции, то есть функция on Each(), которая напрямую заменяет функцию peek. Однако она существует только в этом одном классе, и поэтому нам нужно знать, какой тип мы используем, чтобы она работала.
Существуют также некоторые дополнительные вариации стандартных промежуточных операций, которые облегчают жизнь. Например, операция фильтрации имеет дополнительные версии filterNotNull(), filterIsInstance(), filterNot() и filterIndexed().
Например:
listOf(1, 2, 3, 4, 5)
.map { n -> n * (n + 1) / 2 }
.mapIndexed { (i, n) -> "Triangular number $i: $n" }
Это приведет к получению первых пяти треугольных чисел в виде “Треугольное число 3: 6”
Еще одно важное отличие заключается в том, как работает операция flatMap. В Java 8 эта операция требуется для возврата экземпляра Stream, тогда как в Kotlin она может возвращать любой тип коллекции. Это облегчает работу с ним.
Например:
val letters = listOf("This", "Is", "An", "Example")
.flatMap { w -> w.toCharArray() } // Produces a List<Char>
.filter { c -> Character.isUpperCase(c) }
В Java 8 вторую строку нужно было бы обернуть в Arrays.to Stream(), чтобы это сработало.
4.2. Терминальные операции
Все стандартные терминальные операции из Java 8 Streams API имеют прямые замены в Kotlin, за единственным исключением collect.
У пары из них действительно разные названия:
- anyMatch() -> any()
- allMatch() -> all()
- noneMatch() -> none()
У некоторых из них есть дополнительные варианты для работы с тем, как Kotlin отличается – есть first() и firstOrNull(), где first выдает, если коллекция пуста, но в противном случае возвращает тип, не имеющий значения null.
Интересным случаем является collect. Java 8 использует это, чтобы иметь возможность собирать все элементы потока в некоторую коллекцию, используя предоставленную стратегию.
Это позволяет предоставить произвольный сборщик, который будет предоставлен каждому элементу в коллекции и выдаст какой-либо вывод. Они используются из вспомогательного класса Collectors, но при необходимости мы можем написать свой собственный.
В Kotlin есть прямые замены почти для всех стандартных коллекторов, доступных непосредственно в качестве элементов самого объекта collection – нет необходимости в дополнительном шаге при предоставлении коллектора.
Единственным исключением здесь являются методы summarizingDouble/summarizingInt/summarizingLong, которые позволяют получить среднее значение, count, min, max и sum за один раз. Каждый из них может быть создан по отдельности, хотя это, очевидно, имеет более высокую стоимость.
В качестве альтернативы, мы можем управлять этим с помощью цикла for-each и при необходимости обрабатывать его вручную – маловероятно, что нам понадобятся все 5 из этих значений одновременно, поэтому нам нужно реализовать только те, которые важны.
5. Дополнительные операции в Kotlin
Kotlin добавляет некоторые дополнительные операции к коллекциям, которые невозможны в Java 8 без их самостоятельной реализации.
Некоторые из них являются просто расширениями стандартных операций, как описано выше. Например, можно выполнить все операции таким образом, чтобы результат был добавлен в существующую коллекцию, а не возвращал новую коллекцию.
Во многих случаях также возможно предоставить лямбде не только рассматриваемый элемент, но и индекс элемента – для упорядоченных коллекций, и поэтому индексы имеют смысл.
Существуют также некоторые операции, которые явно используют преимущества нулевой безопасности Kotlin – например; мы можем выполнить filterNotNull() для списка<String?> чтобы вернуть список<String>, где все нулевые значения удалены.
Фактические дополнительные операции, которые могут быть выполнены в Kotlin, но не в потоках Java 8, включают:
- zip() и unzip() - используются для объединения двух коллекций в одну последовательность пар и, наоборот, для преобразования коллекции пар в две коллекции
- associate - используется для преобразования коллекции в карту путем предоставления лямбды для преобразования каждой записи в коллекции в пару ключ/значение в результирующей карте
Например:
val numbers = listOf(1, 2, 3)
val words = listOf("one", "two", "three")
numbers.zip(words)
В результате получается список<Pair<Int, String>> со значениями от 1 до “one”, от 2 до “two” и от 3 до “three”.
val squares = listOf(1, 2, 3, 4,5)
.associate { n -> n to n * n }
Это создает карту<Int, Int>, где ключами являются числа от 1 до 5, а значениями - квадраты этих значений.
6. Завершение
Большинство потоковых операций, к которым мы привыкли в Java 8, могут быть непосредственно использованы в Kotlin в стандартных классах Collection, без необходимости предварительного преобразования в Stream.
Кроме того, Kotlin добавляет больше гибкости в то, как это работает, добавляя больше операций, которые можно использовать, и больше вариаций существующих операций.
Однако по умолчанию Kotlin нетерпелив, а не ленив. Это может привести к выполнению дополнительной работы, если мы не будем осторожны с используемыми типами коллекций.
Оригинал статьи: https://www.baeldung.com/kotlin/java-8-stream-vs-kotlin