Analogies de l'API Java 8 Stream dans Kotlin

1. Introduction

Java 8 a introduit le concept de Streams dans la hiérarchie des collections. Ceux-ci permettent un traitement très puissant des données d'une manière très lisible, en utilisant certains concepts de programmation fonctionnelle pour faire fonctionner le processus.

Nous allons étudier comment pouvons-nous obtenir la même fonctionnalité en utilisant les idiomes de Kotlin. Nous examinerons également les fonctionnalités qui ne sont pas disponibles en Java standard.

2. Java contre Kotlin

Dans Java 8, la nouvelle API sophistiquée ne peut être utilisée que lors de l'interaction avec les instances java.util.stream.Stream .

La bonne chose est que toutes les collections standard - tout ce qui implémente java.util.Collection - ont une méthode particulière stream () qui peut produire une instance Stream .

Il est important de se rappeler que le Stream n'est pas une collection. Il n'implémente pas java.util.Collection et n'implémente aucune des sémantiques normales des collections en Java. Il s'apparente davantage à un itérateur unique en ce qu'il est dérivé d'une collection et est utilisé pour la parcourir, en effectuant des opérations sur chaque élément qui est vu.

Dans Kotlin, tous les types de collection prennent déjà en charge ces opérations sans avoir besoin de les convertir au préalable. Une conversion n'est nécessaire que si la sémantique de la collection est incorrecte - par exemple, un ensemble a des éléments uniques mais n'est pas ordonné.

L'un des avantages de ceci est qu'il n'y a pas besoin d'une conversion initiale d'une collection en un flux, ni d'une conversion finale d'un flux vers une collection - en utilisant les appels collect () .

Par exemple, dans Java 8, nous devrions écrire ce qui suit:

someList .stream() .map() // some operations .collect(Collectors.toList());

L'équivalent à Kotlin est très simplement:

someList .map() // some operations

De plus, les flux Java 8 ne sont pas non plus réutilisables. Une fois que Stream est consommé, il ne peut plus être utilisé.

Par exemple, les éléments suivants ne fonctionneront pas:

Stream someIntegers = integers.stream(); someIntegers.forEach(...); someIntegers.forEach(...); // an exception

À Kotlin, le fait que ce ne soient que des collections normales signifie que ce problème ne se pose jamais. L'état intermédiaire peut être assigné à des variables et partagé rapidement , et fonctionne comme prévu.

3. Séquences paresseuses

L'un des éléments clés de Java 8 Streams est qu'ils sont évalués paresseusement. Cela signifie que pas plus de travail que nécessaire ne sera effectué.

Ceci est particulièrement utile si nous effectuons des opérations potentiellement coûteuses sur les éléments du Stream, ou si cela permet de travailler avec des séquences infinies.

Par exemple, IntStream.generate produira un Stream potentiellement infini d'entiers. Si nous appelons findFirst () dessus, nous obtiendrons le premier élément, et nous ne serons pas dans une boucle infinie.

À Kotlin, les collections sont avides plutôt que paresseuses . L'exception ici est Sequence , qui évalue paresseusement.

Il s'agit d'une distinction importante à noter, comme le montre l'exemple suivant:

val result = listOf(1, 2, 3, 4, 5) .map { n -> n * n } .filter { n -> n < 10 } .first()

La version Kotlin de cela effectuera cinq opérations map () , cinq opérations filter () , puis extraira la première valeur. La version Java 8 n'effectuera qu'une seule map () et un filtre () car du point de vue de la dernière opération, rien de plus n'est nécessaire.

Toutes les collections de Kotlin peuvent être converties en une séquence différée à l'aide de la méthode asSequence () .

L'utilisation d'une séquence au lieu d'une liste dans l'exemple ci-dessus effectue le même nombre d'opérations que dans Java 8.

4. Opérations de flux Java 8

Dans Java 8, les opérations Stream sont réparties en deux catégories:

  • intermédiaire et
  • Terminal

Les opérations intermédiaires convertissent essentiellement un Stream en un autre paresseusement - par exemple, un Stream de tous les entiers en un Stream de tous les entiers pairs.

Les options de terminal sont la dernière étape de la chaîne de méthodes Stream et déclenchent le traitement réel.

A Kotlin, une telle distinction n'existe pas. Au lieu de cela, ce ne sont que des fonctions qui prennent la collection comme entrée et produisent une nouvelle sortie.

Notez que si nous utilisons une collection impatiente dans Kotlin, ces opérations sont évaluées immédiatement, ce qui peut être surprenant par rapport à Java. Si nous avons besoin que ce soit paresseux, n'oubliez pas de convertir d'abord en une séquence .

4.1. Opérations intermédiaires

Presque toutes les opérations intermédiaires de l'API Java 8 Streams ont des équivalents dans Kotlin . Cependant, ce ne sont pas des opérations intermédiaires - sauf dans le cas de la classe Sequence - car elles entraînent des collections entièrement remplies à partir du traitement de la collection d'entrée.

Parmi ces opérations, il y en a plusieurs qui fonctionnent exactement de la même manière - filter () , map () , flatMap () , distinct () et sorted () - et certaines fonctionnent de la même manière uniquement avec des noms différents - limit () est maintenant take , et skip () est maintenant drop () . Par exemple:

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

Cela renverra la valeur unique «9» - 3².

Certaines de ces opérations ont également une version supplémentaire - suffixée du mot «À» - qui sort dans une collection fournie au lieu d'en produire une nouvelle.

Cela peut être utile pour traiter plusieurs collections d'entrée dans la même collection de sortie, par exemple:

val target = mutableList() listOf(1, 2, 3, 4, 5) .filterTo(target) { n -> n % 2 == 0 }

This will insert the values “2” and “4” into the list “target”.

The only operation that does not normally have a direct replacement is peek() – used in Java 8 to iterate over the entries in the Stream in the middle of a processing pipeline without interrupting the flow.

If we are using a lazy Sequence instead of an eager collection, then there is an onEach() function that does directly replace the peek function. This only exists on this one class though, and so we need to be aware of which type we are using for it to work.

There are also some additional variations on the standard intermediate operations that make life easier. For example, the filter operation has additional versions filterNotNull(), filterIsInstance(), filterNot() and filterIndexed().

For example:

listOf(1, 2, 3, 4, 5) .map { n -> n * (n + 1) / 2 } .mapIndexed { (i, n) -> "Triangular number $i: $n" }

This will produce the first five triangular numbers, in the form “Triangular number 3: 6”

Another important difference is in the way the flatMap operation works. In Java 8, this operation is required to return a Stream instance, whereas in Kotlin it can return any collection type. This makes it easier to work with.

For example:

val letters = listOf("This", "Is", "An", "Example") .flatMap { w -> w.toCharArray() } // Produces a List .filter { c -> Character.isUpperCase(c) }

In Java 8, the second line would need to be wrapped in Arrays.toStream() for this to work.

4.2. Terminal Operations

All of the standard Terminal Operations from the Java 8 Streams API have direct replacements in Kotlin, with the sole exception of collect.

A couple of them do have different names:

  • anyMatch() ->any()
  • allMatch() ->all()
  • noneMatch() ->none()

Some of them have additional variations to work with how Kotlin has differences – there is first() and firstOrNull(), where first throws if the collection is empty, but returns a non-nullable type otherwise.

The interesting case is collect. Java 8 uses this to be able to collect all Stream elements to some collection using a provided strategy.

This allows for an arbitrary Collector to be provided, which will be provided with every element in the collection and will produce an output of some kind. These are used from the Collectors helper class, but we can write our own if needed.

In Kotlin there are direct replacements for almost all of the standard collectors available directly as members on the collection object itself – there is no need for an additional step with the collector being provided.

The one exception here is the summarizingDouble/summarizingInt/summarizingLong methods – which produce mean, count, min, max and sum all in one go. Each of these can be produced individually – though that obviously has a higher cost.

Alternatively, we can manage it using a for-each loop and handle it by hand if needed – it is unlikely we will need all 5 of these values at the same time, so we only need to implement the ones that are important.

5. Additional Operations in Kotlin

Kotlin adds some additional operations to collections that are not possible in Java 8 without implementing them ourselves.

Some of these are simply extensions to the standard operations, as described above. For example, it is possible to do all of the operations such that the result is added to an existing collection rather than returning a new collection.

It is also possible in many cases to have the lambda provided with not only the element in question but also the index of the element – for collections that are ordered, and so indexes make sense.

There are also some operations that take explicit advantage of the null safety of Kotlin – for example; we can perform a filterNotNull() on a List to return a List, where all nulls are removed.

Actual additional operations that can be done in Kotlin but not in Java 8 Streams include:

  • zip() and unzip() – are used to combine two collections into one sequence of pairs, and conversely to convert a collection of pairs into two collections
  • associate – is used for converting a collection into a map by providing a lambda to convert each entry in the collection into a key/value pair in the resulting map

For example:

val numbers = listOf(1, 2, 3) val words = listOf("one", "two", "three") numbers.zip(words)

This produces a List , with values 1 to “one”, 2 to “two” and 3 to “three”.

val squares = listOf(1, 2, 3, 4,5) .associate { n -> n to n * n }

This produces a Map, where the keys are the numbers 1 to 5, and the values are the squares of those values.

6. Summary

La plupart des opérations de flux auxquelles nous sommes habitués à partir de Java 8 sont directement utilisables dans Kotlin sur les classes de collection standard, sans avoir besoin de convertir d' abord en flux .

De plus, Kotlin ajoute plus de flexibilité à la façon dont cela fonctionne, en ajoutant plus d'opérations qui peuvent être utilisées et plus de variations sur les opérations existantes.

Cependant, Kotlin est impatient par défaut, pas paresseux. Cela peut entraîner un travail supplémentaire si nous ne faisons pas attention aux types de collection qui sont utilisés.