Analogie API Java 8 Stream in Kotlin

1. Introduzione

Java 8 ha introdotto il concetto di stream nella gerarchia della raccolta. Questi consentono un'elaborazione molto potente dei dati in un modo molto leggibile, utilizzando alcuni concetti di programmazione funzionale per far funzionare il processo.

Analizzeremo come possiamo ottenere la stessa funzionalità utilizzando gli idiomi di Kotlin. Daremo anche uno sguardo alle funzionalità che non sono disponibili in Java normale.

2. Java contro Kotlin

In Java 8, la nuova fantasia API può essere utilizzata solo quando si interagisce con le istanze java.util.stream.Stream .

La cosa buona è che tutte le raccolte standard, qualsiasi cosa implementi java.util.Collection , hanno un particolare metodo stream () che può produrre un'istanza Stream .

È importante ricordare che lo stream non è una raccolta. Non implementa java.util.Collection e non implementa nessuna delle normali semantiche di Collections in Java. È più simile a un Iteratore occasionale in quanto deriva da una raccolta e viene utilizzato per elaborarlo, eseguendo operazioni su ogni elemento visualizzato.

In Kotlin, tutti i tipi di raccolta supportano già queste operazioni senza bisogno di convertirle prima. Una conversione è necessaria solo se la semantica della raccolta è sbagliata, ad esempio un Set ha elementi unici ma non è ordinato.

Un vantaggio di questo è che non è necessaria una conversione iniziale da una raccolta in uno stream e non è necessaria una conversione finale da uno stream in una raccolta, utilizzando le chiamate collect () .

Ad esempio, in Java 8 dovremmo scrivere quanto segue:

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

L'equivalente in Kotlin è molto semplice:

someList .map() // some operations

Inoltre, anche i flussi Java 8 non sono riutilizzabili. Dopo che Stream è stato consumato, non può essere riutilizzato.

Ad esempio, quanto segue non funzionerà:

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

In Kotlin, il fatto che queste siano tutte raccolte normali significa che questo problema non si pone mai. Lo stato intermedio può essere assegnato a variabili e condiviso rapidamente e funziona come ci aspetteremmo.

3. Sequenze pigre

Uno degli aspetti chiave di Java 8 Stream è che vengono valutati pigramente. Ciò significa che non verrà eseguito più lavoro del necessario.

Ciò è particolarmente utile se stiamo eseguendo operazioni potenzialmente costose sugli elementi nello Stream, o se rende possibile lavorare con sequenze infinite.

Ad esempio, IntStream.generate produrrà un flusso potenzialmente infinito di numeri interi. Se chiamiamo findFirst () su di esso, otterremo il primo elemento e non ci imbatteremo in un ciclo infinito.

A Kotlin, le collezioni sono desiderose, piuttosto che pigre . L'eccezione qui è Sequence , che valuta pigramente.

Questa è una distinzione importante da notare, come mostra il seguente esempio:

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

La versione Kotlin di questo eseguirà cinque operazioni map () , cinque operazioni filter () e quindi estrarrà il primo valore. La versione Java 8 eseguirà solo una map () e un filter () perché dal punto di vista dell'ultima operazione non è necessario altro.

Tutte le raccolte in Kotlin possono essere convertite in una sequenza pigra utilizzando il metodo asSequence () .

L'uso di una sequenza invece di un elenco nell'esempio precedente esegue lo stesso numero di operazioni di Java 8.

4. Java 8 Stream Operations

In Java 8, le operazioni di flusso sono suddivise in due categorie:

  • intermedio e
  • terminale

Le operazioni intermedie convertono essenzialmente un flusso in un altro, ad esempio un flusso di tutti i numeri interi in un flusso di tutti i numeri interi pari.

Le opzioni del terminale sono il passaggio finale della catena di metodi Stream e attivano l'elaborazione effettiva.

In Kotlin non esiste tale distinzione. Invece, queste sono solo funzioni che accettano la raccolta come input e producono un nuovo output.

Nota che se stiamo utilizzando una raccolta entusiasta in Kotlin, queste operazioni vengono valutate immediatamente, il che potrebbe essere sorprendente se confrontato con Java. Se abbiamo bisogno che sia pigro, ricordati di convertirlo prima in una sequenza .

4.1. Operazioni intermedie

Quasi tutte le operazioni intermedie dall'API Java 8 Streams hanno equivalenti in Kotlin . Queste non sono operazioni intermedie, tranne nel caso della classe Sequence , poiché risultano in raccolte completamente popolate dall'elaborazione della raccolta di input.

Di queste operazioni, ce ne sono diverse che funzionano esattamente allo stesso modo - filter () , map () , flatMap () , distinte () e ordinate () - e alcune che funzionano allo stesso modo solo con nomi diversi - limit () è ora prendi , e skip () ora è drop () . Per esempio:

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

Ciò restituirà il valore singolo "9" - 3².

Alcune di queste operazioni hanno anche una versione aggiuntiva - suffissa con la parola "To" - che viene restituita in una raccolta fornita invece di produrne una nuova.

Ciò può essere utile per elaborare più raccolte di input nella stessa raccolta di output, ad esempio:

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 maggior parte delle operazioni di flusso a cui siamo abituati da Java 8 sono direttamente utilizzabili in Kotlin sulle classi Collection standard, senza la necessità di convertirle prima in Stream .

Inoltre, Kotlin aggiunge maggiore flessibilità a come funziona, aggiungendo più operazioni che possono essere utilizzate e più variazioni sulle operazioni esistenti.

Tuttavia, Kotlin è desideroso per impostazione predefinita, non pigro. Ciò può causare l'esecuzione di lavoro aggiuntivo se non si sta attenti ai tipi di raccolta utilizzati.