mer 18 ottobre 2017 - Lo Sviluppatore  anno III

Java 8 – la Streaming API

Condividi

 

In questo articolo cerchiamo di dare uno sguardo alla nuova Streaming API di java 8, una delle nuove ed interessanti  feature presenti in quasta major release di JSE. Prima del jdk 8 per iterare attraverso una collezione l’unico modo di procedere era quello di definirsi un iteratore e ciclarlo mediante un ciclo for o while. Per esempio:

List<Fruit> fruits= /* ... */;
 
int sumOfAppleWeights = 0;
for (Fruit fruit: fruits) {
  if (fruit.getType() == Fruit.APPLE) {
    sumOfAppleWeights += fruit.getWeight();
  }
}

Questa forma esplicita di looping (nota come iterazione esterna) si basa sul tradizionale paradigma di programmazione imperativa, basato sull’idea che chi programma “dice” al computer in maniera esplicita i passi da eseguire di un determinato algoritmo.

Nell’esempio precedente, data una collection di oggetti Fruit che rappresentano vari tipi frutta, si vuole restituire il totale del peso delle mele. In questa soluzione è il programmatore che dice esplicitamente COME ottenere il risultato anche se questo in realtà a noi interessa poco, visto che l’obbiettivo è quello di avere il totale del peso delle mele. Inoltre, con questo approccio, si vede come sia necessario scrivere troppo codice anche per task semplici, senza contare che essendo un processo sequenziale non può essere eseguto in ambienti multicore senza aggiungere ancora codice.

Altri linguaggi di programmazione forniscono in genere delle API di tipo pipe-filter specifiche per le Collection. Questo tipo di API usano una forma di IOC (Inversion Of Control) in cui è il framework a controllare l’iterazione e non il programmatore, come nell’esempio precedente. Si parla, in questi casi, di iterazione interna che sposta la resposnsabilità di COME avviene l’iterazione dal programmatore al framework. Ciò rende possibile iterare con strategie potenzialmente intercambiabili : sequenziale, parallelo su 2 o più unità di elaborazione, o a anche addirittura eseguire su un servizio di gestione di terzi (ad esempio, Amazon AWS).

Java 8 è stato esteso con una API di questo tipo per le sue collezioni. Il ciclo for dell’esempio precedente può essere espresso con la Stream API del JDK 8 in un modo molto più leggibile, facendo ampio uso di espressioni lambda:

List<Fruit> fruits= /* ... */;
int sumOfAppleWeights = fruits.stream()
                      .filter(b -> b.getType() == Fruit.APPLE)
                      .mapToInt(b -> b.getWeight())
                      .sum();

Vediamo come funziona questo secondo esempio : Qui la lista di oggetti Fruit (List <Fruit>) viene prima trasformato in un  oggetto Stream  tramite il metodo stream(),   definito nell’ interfaccia Collection. Il risultato è uno Stream<Fruit>  che mette a disposizione i nuovi operatori di manipolazione delle collezioni.

Dopo questa trasformazione, il contenuto corrente viene filtrato con il metodo filter, che prende un oggetto Predicate come argomento. Il predicato può essere espresso come un’espressione lambda, in quanto è una interfaccia funzionale (vedi Java 8 : Introduzione alle Lambda expressions per approfondimenti sulle lambda expression e le interfacce funzionali). Il metodo filter restituisce una nuova istanza di Stream<Fruit> : ogni elemento del flusso originale che rispetta la condizione del predicato viene inoltrata nel flusso per le successive elaborazioni. Nel nostro caso, dalla Collection di oggetti Fruit originaria, vengono inoltrati nel flusso solo gli oggetti di tipo mela.

Il passo successivo è la mappatura di ogni oggetto Fruit al proprio peso, visto che siamo interessati a sommare i pesi. Viene usata la funzione mapToInt  per trasformare ogni elemento del flusso in un nuovo elemento. Ancora una volta si usa un’espressione lambda, questa volta di tipo Function. Tale funzione prende in input un oggetto di tipo Fruit e restituisce un numero intero e il tutto viene fatto per ogni elemento presente nel flusso.  In questo caso, poichè il tipo di destinazione è di tipo int, viene utilizzato un parametro di tipo ToIntFunction , e viene restituito un IntStream  (ci sono versioni primitive di Stream per int, long e double ai fini di avere migliori prestazioni).

Infine, IntStream ha un operatore di sum(), che calcola la somma di tutti gli elementi nel flusso. Il risultato di questa operazione è un singolo int. Si noti che dopo il consumo’, lo stream non è più utilizzabile; se occorrono altre elaborazioni occorre ceare un nuovo flusso ripartendo dalla Collection originaria.

Per fare un paragone tra le Collection e gli Stream possiamo dire che le Collection  sono strutture di dati in memoria che contengono elementi in qualche maniera predeterminati, mentre uno Stream può essere visto come una struttura dati che viene costruita su richiesta. Uno Stream non contiene dei dati, questo opera sulla struttura dati originaria (Collection) creando una sorta di flusso su cui è possible fare delle operazioni, come abbiamo già visto nell’esempio precedente.  

Le operazioni sugli Stream

L’interfaccia Stream è definita nel package java.util.stream. A partire da Java 8, tutte le classi che implementano l’interfaccia Collection hanno metodi che restiuiscono oggetti StreamCiò è stato possibile grazie ad un’altra nuova feature introdotta con java 8 : i default methods.  Ndr Con i default methods è possibile estendere un interfaccia con nuovi metodi e darne una implementazione di default ereditata da tutte le classi che la implementano, le quali non dovranno necessariamente dare una propria definizione dei metodi aggiunti. 

C’è una varietà di operazioni definite dall’interfaccia Stream. Consideriamo il seguente esempio che mostra alcune di queste.

List<String> names = students.stream()
.map(Student::getName)
.filter(name->name.startsWith("A"))
.limit(10)
.collect(Collectors.toList());

 

Qui possiamo vedere l’uso delle operazioni come map, filter, limit e collect.  Le operazioni sugli stream possono essere divisi in due categorie:  le operazioni intermedie e le operazioni terminaliLe operazioni intermedie restituiscono Stream e quindi possono essere collegati insieme per formare una pipeline di operazioni. Nell’esempio sopra map, filter e limit sono esempi di tali operazioni.

Le operazioni terminali, come suggerisce il nome stesso, vengono usate al termine della pipeline e il loro compito è quello di chiudere il flusso in qualche modo significativo. Le operazioni terminali raccolgono i risultati delle varie operazioni fatte con gli stream sotto forma di qualcosa di simile a liste, numeri interi o semplicemente nulla.  Le operazioni terminali di uso comune sono : forEach, toArray, min, max, findFirst, anyMatch, allMatch. Le operazioni terminali sono facilmente riconoscibili in quanto non restituiscono mai uno Stream.

Una cosa interessante da sapere è che  le operazioni intermedie sono in qualche modo “furbe”Le operazioni intermedie non vengono invocate finché non viene richiamato l’operatore terminale. Questo è molto importante quando stiamo elaborando flussi di dati molto grandi: La modalità di elaborazione “su richiesta” migliora drasticamente le prestazioni.

Un pò di esempi

Dopo avere fatto una carrelata delle principali caratteristiche degli Stream passiamo a vedere un pò di esempi di uso che sono sempre il miglior modo per capire bene le cose.

 Creazione di Stream

L’interfaccia Stream mette a disposizione il metodo statico of() che permette di creare stream da Collection e array:

 

// Creazione di uno Stream da un elenco di interi
Stream<Integer> stream = Stream.of(new Integer[]{1,2,3,4}); 

// Crea uno Stream da un elenco di stringhe e stampa l'elenco sullo schermo
Stream.of("This", "is", "Java8", "Stream").forEach(System.out::println);

// Creazione di uno Stream da un array di oggetti Integer
Stream<Integer> stream2 = Stream.of(new Integer[]{1,2,3,4});

// Crea uno stream da un array di stringhe e stampa tutti gli elementi sullo schermo
String[] stringArray = new String[]{"Streams", "can", "be", "created", "from", "arrays"};
Arrays.stream(stringArray).forEach(System.out::println);

//Crea un BufferedReader per un  file
BufferedReader reader = Files.newBufferedReader(Paths.get("File.txt"), StandardCharsets.UTF_8);
// Il metodo lines() di BufferedReader's restituisce uno stream di tutte le linee lette dal file
reader.lines().forEach(System.out::println);

 

L’interfaccia Collelction mette a disposizione metodi per la creazione di stream sequenziali e paralleli:

 

List<Integer> myList = new ArrayList<>();
for(int i=0; i<100; i++) myList.add(i);
         
//stream sequenziale
Stream<Integer> sequentialStream = myList.stream();
         
//stream parallelo
Stream<Integer> parallelStream = myList.parallelStream();

Altri modi per creare uno stream:

Stream<String> stream1 = Stream.generate(() -> {return "abc";});
Stream<String> stream2 = Stream.iterate("abc", (i) -> i);

LongStream is = Arrays.stream(new long[]{1,2,3,4});
IntStream is2 = "abc".chars();

 

Convertire Stream in Collection e Array

Abbiamo visto come sia possibile passare da una Collection o un array ad  uno Stream; E’ possibile fare anche  il process inverso:

 

// Conversione di uno Stream in un oggetto List
Stream<Integer> intStream = Stream.of(1,2,3,4);
List<Integer> intList = intStream.collect(Collectors.toList());

// lo stream è chiuso, per cui è necesario ricrearlo
intStream = Stream.of(1,2,3,4); 

// Conversione di uno stream in un oggetto Map
Map<Integer,Integer> intMap = intStream.collect(Collectors.toMap(i -> i, i -> i+10));

// Conversione di uno stream in un array
intStream = Stream.of(1,2,3,4);
Integer[] intArray = intStream.toArray(Integer[]::new);

Operazioni Intermedie

Abbiamo già introdotto le operazioni intermedie, adesso vediamo  alcuni esempi d’uso di queste operazioni su Stream che ci permettono di fare delle operazione di uso comune in maniera semplice ed elegante.

 

Filtraggio

La Stream API di java 8 ci mette a disposizione vari metodi che ci permettono di interrogare gli Stream come si fa con una SELECT in un DB.

  • filter() : questo metodo l’abbiamo già visto in altri esempi sopra e serve a filtrare gli elementi di uno Stream basandosi sul predicato passato in input. Questo  metodo  restituisce uno stream contenente gli elementi che soddisfano il predicato.
List<Student> students = ........
//Filtra gli studenti con un punteggio superiore a 60
students.stream().filter(student -> student.getScore() >= 60)
.collect(Collectors.toList());
  • distinct() : Questa funzione restituisce uno stream contenente solo gli elementi unici. Questo è un modo molto semplice per rimuovere i duplicati da una collezione. Il metodo distinct() utilizza il metodo equals() per verificare l’uguaglianza e nel acso di oggetti personalizzati richiede quaindi un’implementazione di tale metodo.
//Data una lista di oggetti Student otteniamo una lista distinta dei nomi presenti nella lista in input
List<Student> students = ........
students.stream()
        .map(Student::getName)
        .distinct()
        .collect(Collectors.toList());
  • limit() : Questa funzione serve a limitare il numero di elementi in uno stream. Questa funzione prende in input il numero di elementi da usare come limite.
// Restituisce una lista di 3 studenti di età superiore ai 20 anni
students.stream().filter(s -> s.getAge() > 20)
                 .map(Student::getName)
                 .limit(3)
                 .collect(Collectors.toList());

Similmente esiste una funzione skip(num) che serve a omettere un certo numero di elementi dallo stream.

 

Mapping

Il mapping è l’operazione per cambiare la forma degli elementi in uno stream. Abbiamo la funzione map() che è un’operazione che prende un’altra funzione come argomento, tale funzione prende ogni elemento del flusso come parametro e restituisce una qualche “trasformazione” di tale oggetto come risposta. La funzione data viene quindi applicato a ciascun elemento del flusso

// qui partendo una lista di oggetti Student mediate il metodo map si estrae il nome dello studente per poi essere stampato sullo schermo al passo successivo mediate forEach
  students.stream()
 .map(Student::getName)
 .forEach(System.out::println);

Da notare che lo stream iniziale estratto dalla Collection è uno stream di oggetti Student. Mediate la funzione map, la quale invoca il metodo getName() di ogni oggetto Student, viene restituito un nuovo stream di oggetti di tipo String.

 

Ordinamento

Un’altra delle operazioni di uso comune su una Collection è quella di ordinamento. La stream API mette a disposizione dei metodi che rendono semplice anche questo tipo di operazioni.

// ordiniamo una lista di oggetti Student per nome in base all'ordine naturale
students.stream()
 .sorted(Comparator.comparing(Student::getName))
 .map(Student::getName)
 .collect(Collectors.toList());

// stesso tipo di ordinamento usando un Comparator. E' possibile quindi implementare una qualsiasi logica di ordinamento
students.stream()
 .sorted(Comparator.comparing(Student::getName))
 .map(Student::getName)
 .collect(Collectors.toList());

// ordinamento inverso.
students.stream()
 .map(Student::getName)
 .sorted(Comparator.reverseOrder())
 .collect(Collectors.toList());

//Ordinamento per nome e cognome
students.stream()
 .sorted(Comparator.comparing(Student::getFirstName).
 thenComparing(Student::getLastName))
 .map(Student::getName)
 .collect(Collectors.toList());

Le operazioni intermedie sono sempre “lazy” nel senso che non fanno nulla fino a quando viene eseguita un’operazione terminale, inoltre un’operazione intermedia non termina mai prima che il suo output sia disponibile per l’operazione successiva nella pipeline.

 

Operazioni Terminali

streamj8

Nella figura sopra è possibile vedere un tipico uso di stream in cui abbiamo n operazioni intermedie ciascuna delle quali restituisce sempre uno stream (permettendo quindi la concatenazione) e infine un operazione terminale; ogni flusso che fa uso di stream deve sempre terminare con un operazione di questo tipo. Vediamo che tipo di operazioni terminali abbiamo in java 8.

 

Match condizionali e ricerche

Una tipica operazione quando si lavora con gli stream è quella di cercare degli elementi che matchano un determinato requisito. Abbiamo già visto qualcosa di simile con le operazioni intermedie, ma non dimentichiamoci che questa ultime restituiscono sempre degli stream mentre le operazioni terminali restituiscono altri tipi di oggetti. Ma vediamo qualche esempio:

// Viene restituito true se esiste almeno un oggetto studente con puteggio superiore a 80
Boolean hasStudentWithDistinction = students.stream()
 .anyMatch(student -> student.getScore() > 80);

// Viene restituito true se tutti gli oggetti studente hanno un punteggio superiore a 80
Boolean hasAllStudentsWithDistinction = students.stream()
 .allMatch(student -> student.getScore() > 80);

//Viene resituito true se nessuno degli elementi student ha un punteggio superiore a 80
Boolean hasAllStudentsBelowDistinction = students.stream()
 .noneMatch(student -> student.getScore() > 80);

La stream API offre due metodi creati apposta per le ricerche: findAny() e findFirst() che restituiscono rispettivamente tutti gli elementi e il primo elemento dello stream che soddisfano la condizione data.

//Resituisce tutti gli studenti di età superiore ai 20 anni
students.stream().filter(student -> student.getAge() > 20)
                 .findAny();

//Restituisce il primo elemento student con età superiore ai 20 anni
students.stream().filter(student -> student.getAge() > 20)
                 .findFirst();

 

Operazioni di riduzione (reduce)

Le operazioni di riduzione hanno lo scopo di elaborare ripetutamente gli elementi di uno stream al fine di restituire in output un singolo elemento.  Questo tipo di operazioni sono molto utili ad  esempio  per calcolare la somma di tutti gli elementi  o per trovare l’elemento massimo o minimo in un flusso.  Le funzioni di riduzione prendono in input una entity o un valore iniziale  per combinarlo in qualche modo con il primo elemento del flusso, il risultato viene poi combinato con il secondo elemento del flusso e così via, per ogni elemento presente nello stream.  La logica di come gli elementi sono combinati insieme, è dato da quello che si chiama un accumulatore

//Somma di tutti gli elementi di uno stream
Integer sum = numbers.stream()
 .reduce(0, (x, y) -> x + y); //reduce(identity, accumulatore)


//Prodotto di tutti gli elementi in uno stream
Integer product = numbers.stream()
 .reduce(1, (x, y) -> x * y);


//Somma di tutti gli elementi in uno stream (senza identity)
Optional<integer> sum = numbers.stream()
 .reduce((x, y) -> x + y);
 
//Prodotto di tutti gli elementi in uno stream (senza identity)
Optional<integer> product = numbers.stream()
 .reduce((x, y) -> x * y);

// Cerchiamo il minimo intero in uno stream di Integer
Optional<integer> min = numbers.stream()
 .reduce(0, Integer::min);

//Cerchiamo il massimo intero in uno stream di Integer
Optional<integer> max = numbers.stream()
 .reduce(0, Integer::max);

Negli ultimi due esempi notiamo che il risultato è di tipo Optional<Integer>. Optional è una sorta di wrapper che incapsula il risultato che può essere anche null, nel caso in cui la reduce produca un risultato nullo. Optional serve proprio per evitare possibile errori legati alla restituzione di valori null. In questa classe è presente un metodo isPresent che ci permette appunto di testare in maniera safe se il risultato è null o meno anziche demandare al programmatore il check di valori nulli. Vediamo un esempio su come si usa:

students.stream()
 .filter(student -> student.getScore() > 80) // filter
 .findAny() //Ogni studente che matcha col filtro
 .map(Student::getName) // mapping del nome dello studente
 .ifPresent(System.out::println); // stampa il nome dello studente se questo non è null

 

Le operazioni di reduce sono particolarmente utili quando il flusso viene elaborato in modo parallelo. In un elaborazione parallela è difficile mantenere e condividere lo stato della variabile che contiene la somma incrementale dei valori, attraverso le varie unità di elaborazione.

Con java 8 non è necessario preoccuparsi degli  aspetti che riguardano la programmazione concorrente; il tutto avviene in maniera trasparente all’utente mediante l’uso degli stream paralleli.  Ogni unità di elaborazione  dispone di un proprio risultato variabile, e quindi non vi è alcuna preoccupazione di stato condiviso. Tutti questi risultati sono poi combinati insieme per ottenere il risultato finale.

Conclusioni

Con questo articolo si è voluto introdurre la Stream API di java 8, uno strumento che come abbiamo visto semplifica notevolmente molte operazioni che prima di java 8 dovevano essere implementate dal programmatore con righe e righe di codice.

Riferimenti

Understanding Java 8 Streams API

Processing Data with Java SE 8 Streams, Part 1

Java™ Platform, Standard Edition 8 API Specification

Lascia un commento

Top