Metodologie di Programmazione: Lezione 12

Riccardo Silvestri

Streams

In Java 8 oltre alle lambda, che favoriscono uno stile di programmazione funzionale, sono stati introdotti gli Streams che, operando in perfetta simbiosi con le lambda, migliorano l'efficienza delle più comuni operazioni di elaborazione dati con uno stile di programmazione dichiarativo.

I flussi o Stream sono stati introdotti principalmente per sfruttare al meglio le potenzialità offerte dalle moderne piattaforme hardware/sofware. I miglioramenti in efficienza riguardano sopratutto l'esecuzione parallela o concorrente e saranno discussi più avanti quando tratteremo la programmazione concorrente. Per adesso ci concentriamo sugli usi più comuni dei flussi e del particolare modo di programmare che propongono che è quello tipico di una pipeline.

Pipeline

Le pipeline sono tipiche dei sistemi Unix e affini dove le operazioni possono essere concatenate in modo tale che l'output di una operazione diventi l'input dell'operazione successiva. In Unix il carattere | è usato per concatenare, cioè mettere in pipeline, due operazioni. Ad esempio,

ls | wc -l

concatena l'operazione ls, che stampa sullo standard output il contenuto della directory corrente, con l'operazione wc -l, che conta il numero di linee dello standard input e riporta il conteggio sullo standard output. Il risultato è equivalente ad un singola operazione che produce sullo standard output il numero di file/dir contenuti nella directory corrente.

Gli Stream di Java permettono di concatenare operazioni su un flusso di dati in modo molto simile alle pipeline di Unix. Supponiamo di avere una lista di Dipendente e di voler conoscere il numero di dipendenti con lo stipendio superiore ad una certa soglia. Potremmo iterare sulla lista e per ogni dipendente ottenere il suo stipendio, controllare se è maggiore della soglia e in caso affermativo incrementare un contatore:

List<Dipendente> dips = . . .
long gte1200 = 0;
for (Dipendente d : dips) 
    if (d.getStipendio() >= 1200)
        gte1200++;
out.println("# dipendenti con stipendio >= 1200: "+gte1200);

Vedendo la lista dei dipendenti come un flusso e avendo a disposizione un'operazione che filtra gli elementi del flusso che hanno una certa proprietà (nel nostro caso avere uno stipendio < 1200) e un'operazione che conta gli elementi rimasti a valle del filtro, potremmo ottenere lo stesso risultato così

gte1200 = dips.stream().filter(d -> d.getStipendio() >= 1200).count();

Fra poco vedremo nel dettaglio i metodi coinvolti, per adesso la spiegazione intuitiva è la seguente: dips.stream() ritorna uno Stream degli elementi della lista dips, filter(d -> d.getStipendio() >= 1200) filtra, cioè elimina, tutti gli elementi che non soddisfano la condizione, cioè quelli con stipendio < 1200, infine count() ritorna il numero di elementi dello Stream, ovvero quelli rimasti dopo il filtro.

In generale, una volta ottenuto uno Stream (da una lista, un insieme, un array) possiamo applicare ad esso una dopo l'altra delle operazioni che elaborano gli elementi dello Stream, come ad es. filter, ognuna delle quali ritorna ancora uno Stream, possibilmente differente, ed infine applichiamo un'operazione terminale che produce il risultato voluto, ad es. count.

Tutte le definizioni inerenti gli Stream si trovano nel package java.util.stream. La definizione principale è l'interfaccia Stream che definisce appunto uno Stream con tante possibili operazioni, fra cui i metodi dell'esempio visto sopra.

Stream<E> stream()
Metodo dell'interfaccia Collection<E> e quindi ereditato da liste e insiemi. Ritorna un flusso con gli elementi della collezione.
Stream<T> filter(Predicate<? super T> predicate)
Metodo di Stream<T>, ritorna il flusso a cui è applicato ma senza gli elementi che non soddisfano il predicato.
long count()
Metodo di Stream<T>, ritorna il numero di elementi del flusso.

Per avere dei dati su cui fare alcuni esempi ci serve una lista di dipendenti che possiamo creare con il seguente metodo di convenienza che definiamo in una classe mp.TestStreams:

/** Ritorna una lista di dipendenti creati con i dati specificati nelle stringhe
 * nomCogStip ognuna delle quali contiene il nomeCognome e lo stipendio di un
 * dipendente. Esempio,
 * <pre>
 *     create("Mario Rossi 1000", "Ugo Gio 1200", "Lia Dodi 1100");
 * </pre>
 * @param nomCogStip  array coi nomeCognome e stipendio dei dipendenti
 * @return lista di dipendenti creati con i dati specificati */
public static List<Dipendente> create(String...nomCogStip) {
    List<Dipendente> dd = new ArrayList<>();
    for (String s : nomCogStip) {
        String[] tt = s.split(" ");
        dd.add(new Dipendente(String.join(" ", Arrays.copyOf(tt, tt.length-1)),
                Double.parseDouble(tt[tt.length-1])));
    }
    return dd;
}

Allora, la nostra lista dipendenti può essere così creata

List<Dipendente> dips = create("Max Ro 1350", "Ugo Gio 1200", "Lia Dea 1100",
             "Lea Gru 1500", "Ciro Espo 1350", "Ugo Bo 1000", "Lola La 1200");

Supponiamo di voler stampare i dipendenti della nostra lista ordinati per stipendio crescente

import static java.util.Comparator.*;

. . .

dips.stream().sorted(comparingDouble(Dipendente::getStipendio))
                                   .forEachOrdered(out::println);

Questo produce

mp.Dipendente[codice=6,nomeCognome=Ugo Bo,stipendio=1000.0]
mp.Dipendente[codice=3,nomeCognome=Lia Dea,stipendio=1100.0]
mp.Dipendente[codice=2,nomeCognome=Ugo Gio,stipendio=1200.0]
mp.Dipendente[codice=7,nomeCognome=Lola La,stipendio=1200.0]
mp.Dipendente[codice=1,nomeCognome=Max Ro,stipendio=1350.0]
mp.Dipendente[codice=5,nomeCognome=Ciro Espo,stipendio=1350.0]
mp.Dipendente[codice=4,nomeCognome=Lea Gru,stipendio=1500.0]

Il metodo toString di Dipendente è stato modificato aggiungendo il valore dello stipendio. Abbiamo usato i seguenti metodi di Stream:

Stream<T> sorted(Comparator<? super T> comparator)
Metodo di Stream<T>, ritorna un flusso con gli elementi del flusso a cui è applicato ordinati tramite il comparator specificato.
void forEachOrdered(Consumer<? super T> action)
Metodo di Stream<T>, esegue action su ogni elemento del flusso a cui è applicato, se il flusso è ordinato, l'ordine è rispettato, altrimenti è arbitrario.

Prima di procedere con altri esempi dobbiamo chiarire alcuni concetti fondamentali sui flussi.

Operazioni sui flussi

Le operazioni sui flussi si dividono in due categorie: operazioni intermedie (intermediate operations) e operazioni terminali (terminal operations). Tutte le operazioni sono metodi definiti nell'interfaccia Stream. Le operazioni intermedie a partire da un flusso, magari ottenuto da una lista o una collezione, possono essere applicate una dopo l'altra quante se ne vuole. Negli esempi abbiamo visto filter e sorted. Tutte le operazioni intermedie ritornano sempre un flusso perché così ad esso è possibile applicare un'altra operazione intermedia o una terminale. Le operazioni terminali invece producono un risultato che non è un flusso. Negli esempi abbiamo visto count e forEachOrdered. Una volta che a un flusso è stata applicata un'operazione terminale il flusso è consumato e non può più essere usato. Ad esempio,

 Stream<Dipendente> ss = dips.stream();
 ss.sorted(comparing(Dipendente::getNomeCognome)).forEachOrdered(out::println);
 long c = ss.count();           // ERRORE lancia IllegalStateException

Quindi si può dire che la vita di un flusso dura una pipeline. Inizia quando viene creato, ad es. tramite il metodo stream() di una collezione, continua finché sono concatenate operazioni intermedie nella pipeline e termina non appena si applica un'operazione terminale. Dopo che l'operazione terminale è stata applicata il flusso non può più essere usato perché è chiuso. Questa è una prima importante differenza con le collezioni. Una collezione può essere usata e riusata un flusso no. Un flusso è creato e usato per un singolo scopo e poi non può più essere riusato. Perché questo strano comportamento? La ragione principale è l'efficienza. I flussi, cioè gli Stream, sono stati introdotti con lo scopo principale di sfruttare al meglio il parallelismo e la concorrenza offerte dalle moderne piattaforme hardware e software. Quando si crea un flusso e si applica ad esso una sequenza di operazioni intermedie non viene effettuata esplicitamente alcuna elaborazione né viene creata una sequenza di elementi che copia la collezione da cui il flusso è stato creato. Solamente quando è applicata un'operazione terminale il calcolo è eseguito. Così l'implementazione dei flussi può tener conto della pipeline complessiva, cioè di tutte le operazioni intermedie concatenate e di quella terminale, per organizzare in modo efficiente il calcolo sfruttando al meglio la disponibilità di più processori.

Ad esempio, nel conteggio dei dipendenti con uno stipendio superiore ad una certa soglia che abbiamo visto, l'implementazione esplicita tramite il for non lascia spazio ad alcuna ottimizzazione parallela. Questo perché il nostro codice esplicitamente prescrive come il calcolo va eseguito. Mentre la definizione tramite i flussi dichiara solamente quali operazioni svolgere e il risultato voluto, ma non prescrive come debba essere eseguito il calcolo. Ad esempio, l'implementazione dei flussi potrebbe suddividere il calcolo tra 8 processori, ogni processore avrebbe circa 1/8 del flusso dei dipendenti da conteggiare, e poi gli basterebbe sommare gli 8 conteggi. Sicuramente questo sarebbe sostanzialmente più veloce (almeno per flussi con molti elementi) dell'implementazione sequenziale prescritta da un for.

Proprio per dare la massima libertà di implementazione e per non porre inutili vincoli su di essa, nella libreria standard di Java non ci sono classi che implementano i flussi, cioè l'interfaccia Stream. I flussi possono essere solamente creati tramite metodi che ritornano un flusso. D'altronde l'implementazione dei flussi è estremamente delicata e se non riesce a sfruttare al meglio le potenzialità di parallelismo e concorrenza della piattaforma hardware/software sarebbe quasi del tutto inutile. Per questo è bene lasciarla alla piattaforma Java e alla JVM.

Un sottoprodotto di queste caratteristiche dei flussi è che la programmazione tramite di essi è di tipo dichiarativo, cioè si dichiara il risultato voluto senza esplicitare come eseguirne il calcolo. Inoltre, si noti che un flusso non modifica la collezione o l'array dal quale è creato, a meno di non farlo esplicitamente. Ad esempio, il filtro sui dipendenti con stipendio superiore alla soglia è relativo solamente al flusso e non alla lista di dipendenti che è lasciata inalterata dalla pipeline del flusso.

Ordinamento    I flussi possono avere un ordinamento oppure no. Se un flusso è creato da una lista o un array è ordinato se invece è creato da un insieme non lo è. Alcune operazione intermedie possono ordinare un flusso inizialmente non ordinato, come ad esempio, sorted. Alcune operazioni seguono l'ordinamento, come forEachOrdered mentre altre no.

Ci sono molte sottigliezze relative all'uso dei flussi per ottenere le migliori prestazioni sfruttando la disponibilità di più processori. Avremo modo di discuterne con cognizione di causa dopo aver trattato la programmazione concorrente.

Usare i flussi

Vediamo altri esempi d'uso che ci permetteranno di introdurre altre operazioni intermedie e terminali dei flussi. Consideriamo di voler stampare solamente i valori degli stipendi, senza alcun ordine prestabilito. Dalla lista dei dipendenti dobbiamo passare ad un flusso dei dipendenti e poi applicare un'operazione terminale che per ogni dipendente ne stampi lo stipendio:

dips.stream().forEach(d -> out.println(d.getStipendio()));

Abbiamo usato l'operazione terminale void forEach(Consumer<? super T> action) che applica l'azione action ad ogni elemento del flusso ma con un ordine arbitrario anche se il flusso è ordinato. A differenza di forEachOrdered che rispetta l'ordine. Perché mai ci sono queste due versioni? Non ne bastava una sola, preferibilmente quella che rispetta l'ordine? La ragione è che l'operazione forEach non dovendo rispettare alcun ordine è molto più facile che possa essere eseguita efficientemente in parallelo.

Se vogliamo stampare i valori degli stipendi senza ripetizioni? Potremmo usare la seguente operazione:

Stream<T> distinct()
Operazione intermedia, ritorna un flusso con gli elementi distinti (secondo il metodo equals) del flusso a cui è applicata.

Ma se l'applichiamo al flusso dei dipendenti avremo ancora lo stesso flusso dato che i dipendenti sono tutti distinti e non saranno considerati uguali perché hanno lo stesso stipendio. Dovremmo avere a disposizione un flusso con i valori degli stipendi. Per questo ci viene in aiuto la seguente operazione che mappa un flusso in un altro:

Stream<R> map(Function<? super T, ? extends R> mapper)
Operazione intermedia, ritorna un flusso con gli elementi che si ottengono applicando la funzione mapper agli elementi del flusso originale. In altri termini, se il flusso originale è

a b c d e . . .

ed f è la funzione mapper, il flusso ritornato è

f(a) f(b) f(c) f(d) f(e) . . .

Se il flusso è ordinato, l'operazione map rispetta l'ordine.

E quindi possiamo usare il metodo getStipendio come funzione di mappatura

dips.stream().map(Dipendente::getStipendio).distinct().forEach(out::println);

L'esecuzione stampa

1350.0
1200.0
1100.0
1500.0
1000.0

Se vogliamo stamparli anche ordinati, basta aggiungere alla pipeline l'operazione Stream<T> sorted() (la versione senza parametri, ordina rispetto all'ordinamento naturale)

dips.stream().map(Dipendente::getStipendio).distinct().sorted()
                                      .forEachOrdered(out::println);

Ovviamente dobbiamo anche usare forEachOrdered al posto di forEach.

Se invece siamo interessato solamente al dipendente con il massimo stipendio (o meglio a uno di quelli con il massimo stipendio), potremmo usare l'operazione

Optional<T> max(Comparator<? super T> comparator)
Operazione terminale, ritorna il massimo elemento del flusso rispetto all'ordine determinato da comparator.

Però il metodo max ritorna Optional<T> e non T, come mai? La ragione è che se il flusso è vuoto, cosa dovrebbe ritornare, null? Ma se il flusso contiene proprio il valore null? Le due situazioni sarebbero indistinguibili. Proprio per risolvere casi di questo tipo e più in generale per dare una risposta soddisfacente al trattamento di valori ritornati non definiti, è stato introdotto il tipo Optional.

Valore o non valore: Optional

La classe Optional<T>, nel package java.util, serve a contenere un valore non null o a indicare che un valore non c'è. Il parametro di tipo T è il tipo del valore contenuto. Se usato correttamente, è una conveniente alternativa all'uso del null. I due principali metodi sono i seguenti.

boolean isPresent()
Ritorna true se contiene un valore, altrimenti false.
T get()
Se contiene un valore, lo ritorna, altrimenti lancia NoSuchElementException.

Oltre ai casi come quelli del metodo max in cui se ritornasse null non si potrebbe distinguere il flusso vuoto dal flusso che contiene null, un Optional risulta utile in quasi tutti i casi in cui un metodo può ritornare null. Se un metodo ritorna un valore di tipo T e può ritornare null, il codice che invoca il metodo potrebbe facilmente cercare di invocare un metodo sul valore ritornato senza aver prima controllato che non sia null. Rischiando così di provocare il lancio di NullPointerException. Se invece il metodo ritorna un Optional<T> il programmatore è quasi forzato a tener in conto che il valore ritornato potrebbe non esserci in quanto non può usare direttamente l'oggetto ritornato.

Ritorniamo ora all'operazione che volevamo scrivere

Optional<Dipendente> dipMaxStip = dips.stream()
            .max(comparingDouble(Dipendente::getStipendio));
out.println("Dipendente con il max stipendio: "+(dipMaxStip.isPresent() ?
            dipMaxStip.get() : "non ci sono dipendenti"));

Questa stampa

Dipendente con il max stipendio: mp.Dipendente[codice=4,nomeCognome=Lea Gru,stipendio=1500.0]

Passiamo ora ad altri esempi relativi a file di testo. Vogliamo contare il numero di linee di un file di testo che contengono una data stringa (o parola). Prima di tutto leggiamo le linee del file in una lista, possiamo usare il metodo Files.readAllLines:

Path p = Paths.get("files", "alice_it_utf8.txt");
List<String> lines = Files.readAllLines(p);

Creiamo un flusso dalla lista delle linee, applichiamo ad esso l'operazione filter per filtrare via le linee che non contengono la stringa che ci interessa e poi count:

String w = "Regina";
long wCount = lines.stream().filter(s -> s.contains(w)).count();
out.println("# linee che contengono "+w+" : "+wCount);

Adesso vorremmo stampare le linee che contengono la stringa, ma al massimo 10. L'operazione intermedia Stream<T> limit(long maxSize) tronca il flusso ad al più maxSize elementi (se il flusso è ordinato sono i primi maxSize elementi).

try (Stream<String> slines = Files.lines(p)) {
    slines.filter(s -> s.contains(w)).limit(10).forEach(out::println);
}

In questo caso abbiamo usato il metodo Files.lines che ritorna direttamente un flusso delle linee del file. I flussi che sono basati su risorse come i file dovrebbero essere sempre chiusi. Per questo abbiamo usato un try-with-resources che ci garantisce che il flusso sarà sempre chiuso anche in caso di errori di I/O.

Adesso vogliamo giocare un po' con caratteri e parole di un file. Per prima cosa leggiamo tutti i caratteri di un file in una stringa:

String txt = new String(Files.readAllBytes(p));

Il metodo byte[] readAllBytes(Path path) legge tutti i bytes del file e li ritorna in un array. Il costruttore di String crea una stringa decodificando la sequenza di bytes tramite la codifica di default (UTF-8). Vogliamo contare i caratteri distinti

Stream<String> cs = Stream.of(txt.split(""));     // Stream di tutti i caratteri
out.println("# caratteri distinti: "+cs.distinct().count());

Passiamo ora al conteggio di tutte le parole. Così stampiamo il numero di tutte le occorrenze di parole

String[] ww = txt.split("[^\\p{IsLetter}]+");    // Array di tutte le parole
out.println("# parole: "+ww.length);

Per stampare il numero di parole distinte

Stream<String> ws = Arrays.stream(ww);           // Stream delle parole
out.println("# parole distinte: "+ws.distinct().count());

Il metodo Arrays.stream(T[] array) crea un flusso da un array, si può anche usare il metodo Stream<T> of(T... values). Se vogliamo ignorare le maiuscole/minuscole,

ws = Stream.of(ww);                 // Di nuovo lo Stream delle parole
out.println("# parole distinte (ignorando M/m): " +
                      ws.map(String::toLowerCase).distinct().count());

Tramite l'operazione map abbiamo ridotto tutte le parole in minuscole. Per trovare la parola più lunga basta usare l'operazione max con un comparatore sulla lunghezza delle parole:

ws = Stream.of(ww);                // Di nuovo lo Stream delle parole
Optional<String> longest = ws.max(comparingInt(String::length));
out.println("Parola più lunga: " + longest.get());

Collezionare flussi

Finora abbiamo visto esempi in cui al termine della pipeline di un flusso il risultato viene o stampato tramite forEach o è ridotto a un valore semplice con count o max. Ma ci sono casi in cui si vuole collezionare il risultato in una qualche struttura dati. Per raccogliere gli elementi di un flusso in un array si può usare il metodo A[] toArray(IntFunction<A[]> gen) che è ovviamente un'operazione terminale. Se si vuole invece collezionare gli elementi di un flusso in una struttura differente si può usare il metodo

<R,A> R collect(Collector<? super T,A,R> collector)
Operazione terminale, colleziona gli elementi del flusso tramite il collettore collector in un contenitore di tipo R.

Un collettore Collector è un oggetto che permette di collezionare gli elementi di un flusso in un contenitore. Almeno per il momento non spiegheremo nei dettagli che cos'è un collettore, ci accontenteremo di usare i collettori già pronti all'uso forniti dalla classe di utilità Collectors. Per collezionare il flusso in una lista si può usare toList(). Ad esempio, se vogliamo la lista delle 10 parole più lunghe,

import static java.util.stream.Collectors.*;

. . .

ws = Stream.of(ww);                // Di nuovo lo Stream delle parole
List<String> longWords = ws.distinct().sorted(comparingInt(String::length)
                                      .reversed()).limit(10).collect(toList());

Si noti che abbiamo dovuto usare il metodo reversed per invertire l'ordinamento di modo che sia per lunghezze decrescenti. Se volevamo un insieme invece che una lista potevamo usare il collettore toSet().

Se gli elementi del flusso sono stringhe e li vogliamo concatenare in una stringa possiamo usare il collettore joining(). Ad esempio, possiamo ottenere una stringa con tutti i caratteri distinti di un file,

String chars = Stream.of(txt.split("")).distinct().collect(joining());

Se si vogliono separare le stringhe del flusso con un delimitatore, si può usare la versione joining(CharSequence delimiter).

La classe Collectors offre anche collettori che calcolano statistiche di base come min, max e media di valori numerici ottenuti dagli elementi di un flusso. Il metodo summarizingDouble(ToDoubleFunction<? super T> mapper) colleziona le statistiche in un oggetto di tipo DoubleSummaryStatistics che ha metodi come getMax, getAverage, ecc. per ottenere le statistiche. Lo possiamo usare per calcolare le statistiche sulle lunghezze delle parole:

ws = Stream.of(ww);                 // Di nuovo lo Stream delle parole
DoubleSummaryStatistics stats = ws.collect(summarizingDouble(String::length));
out.println("Numero parole: "+stats.getCount());
out.println("Lunghezza:  media = "+stats.getAverage()+
        "  min = "+stats.getMin()+"  max = "+stats.getMax());

Questo stampa

Numero parole: 27794
Lunghezza:  media = 4.652910700151112  min = 1.0  max = 17.0

Per collezionare un flusso in una mappa si può usare il collettore

toMap(Function<? super T,? extends K> keyMapper, Function<? super T,? extends U> valueMapper, BinaryOperator<U> merge)
Colleziona il flusso in una mappa di tipo Map<K,U>, le chiavi sono ottenute applicando la funzione keyMapper agli elementi del flusso e i valori applicando la funzione valueMapper. Se ci sono due o più elementi che mappano alla stessa chiave k, il valore associato a k è ottenuto fondendo i valori, tramite la funzione merge, degli elementi che mappano a k.

Spesso i valori delle chiavi della mappa sono gli elementi del flusso, in tali casi per la funzione keyMapper si può usare la funzione identità fornita da <T> Function<T,T> identity(). Come esempio calcoliamo la mappa che ad ogni parola di un file associa il numero delle sue occorrenze,

ws = Stream.of(ww);                 // Di nuovo lo Stream delle parole
Map<String,Integer> counts = ws.collect(toMap(Function.identity(), s -> 1, 
                                                            Integer::sum));
out.println("Alice: "+counts.get("Alice")+"   Regina: "+
                            counts.get("Regina")+"   Re: "+counts.get("Re"));

che stampa

Alice: 403   Regina: 77   Re: 65

Esercizi

[OrdinaNomeCognome]    Scrivere una pipeline per stampare i dipendenti ordinati rispetto al nomeCognome.

[Stipendio]    Scrivere una pipeline che produce una lista coi dipendenti che hanno un dato stipendio. Ad esempio se usata con dips e stipendio 1200, dovrebbe produrre la lista

[Ugo Gio, Lola La]

[ElencoDipendenti]    Scrivere una pipeline che elenca in una stringa i nomeCognome dei dipendenti ordinati e separati da virgole. Se usata con dips dovrebbe produrre la stringa

"Ciro Espo,Lea Gru,Lia Dea,Lola La,Max Ro,Ugo Bo,Ugo Gio"

[CodiceToStipendio]    Scrivere una pipeline che colleziona i dipendenti in un mappa Map<Long,Double> che associa ad ogni codice di dipendente il suo stipendio. Applicata alla lista dips dovrebbe produrre la mappa:

{1=1350.0, 2=1200.0, 3=1100.0, 4=1500.0, 5=1350.0, 6=1000.0, 7=1200.0}

Suggerimento: usare il metodo toMap(Function<? super T,? extends K> keyMapper, Function<? super T,? extends U> valueMapper) di Collectors.

[MappaStipendi]    Scrivere una pipeline che colleziona i dipendenti in una mappa di tipo Map<Double, List<Dipendente>> che ad ogni valore di stipendio associa la lista dei dipendenti con quello stipendio. Ad esempio se usata con dips dovrebbe produrre la mappa:

{
1100.0=[mp.Dipendente[codice=3,nomeCognome=Lia Dea,stipendio=1100.0]],
1200.0=[mp.Dipendente[codice=2,nomeCognome=Ugo Gio,stipendio=1200.0], 
        mp.Dipendente[codice=7,nomeCognome=Lola La,stipendio=1200.0]], 
1350.0=[mp.Dipendente[codice=1,nomeCognome=Max Ro,stipendio=1350.0], 
        mp.Dipendente[codice=5,nomeCognome=Ciro Espo,stipendio=1350.0]], 
1500.0=[mp.Dipendente[codice=4,nomeCognome=Lea Gru,stipendio=1500.0]], 
1000.0=[mp.Dipendente[codice=6,nomeCognome=Ugo Bo,stipendio=1000.0]]
}

[LineeCaratteri]    Scrivere una pipeline che partendo da una lista di linee ritorna il numero di linee che contengono almeno n occorrenze di un carattere c. Ad esempio se applicata a lines degli esempi sopra, con n = 2 e c = 'z', dovrebbe ritornare 525.

[MaxOccorrenze]    Scrivere una pipeline che partendo da una lista di linee ritorna la linea con il massimo numero di occorrenze di un dato carattere. Ad esempio, se applicata a lines degli esempi sopra, relativamente al carattere 'a', dovrebbe ritornare la linea:

"nella sala, capitò davanti a una cortina bassa che non aveva osservata"

[LunghezzeParole]    Scrivere una pipeline che partendo da una lista di parole produce una mappa di tipo Map<Integer,Set<String>> che per chiavi ha le lunghezze delle parole e ad ogni lunghezza associa l'insieme delle parole di quella lunghezza. Se applicata a lines, l'insieme delle parole di lunghezza 15 dovrebbe essere:

[bruttificazione, tranquillissimo, MERCHANTIBILITY, representations,
 perentoriamente, quarantaduesima, meravigliarsene, tranquillamente,
 capitombolarono, schiaffeggiarsi, permetterebbero, silenziosamente,
 stropicciandosi, frettolosamente, Bruttificazione]

[LanguageToCountry]    La classe Locale in java.util è usata per rappresentare diverse regioni che possono differenziarsi in base a geografia, politica o cultura. Ogni oggetto di tipo Locale rappresenta quindi una di queste regioni. Il metodo statico Locale[] getAvailableLocales() ritorna un array di tutti i disponibili Locale. Scrivere una pipeline che partendo dall'array dei Locale produce una mappa di tipo Map<String,List<String>> che ad ogni linguaggio, ritornato dal metodo getLanguage, associa la lista dei nomi dei paesi, ritornati dal metodo getCountry(), dei Locale con quel linguaggio. Quindi dovrebbe produrre una mappa del tipo:

{=[], no=[NO, NO, ], de=[, CH, AT, LU, DE, GR], hi=[IN, ], ru=[RU, ], 
 be=[, BY], fi=[FI, ], pt=[, BR, PT], bg=[, BG], lt=[, LT], lv=[, LV], 
 hr=[HR, ], fr=[BE, CH, , LU, FR, CA], hu=[, HU], uk=[, UA], sk=[, SK], 
 sl=[, SI], ga=[, IE], mk=[, MK], sq=[, AL], ca=[ES, ], 
 sr=[ME, BA, CS, BA, ME, , RS, , RS], sv=[SE, ], ko=[, KR], in=[, ID],
 ms=[MY, ], el=[, CY, GR], mt=[MT, ], 
 en=[US, SG, MT, , PH, NZ, ZA, AU, IE, CA, IN, GB], is=[IS, ], it=[, CH, IT], 
 iw=[IL, ], zh=[TW, HK, SG, CN, ], 
 es=[PA, VE, PR, BO, AR, SV, , ES, CO, PY, EC, US, GT, MX, HN, CL, DO, CU, UY,
 CR, NI, PE], et=[, EE], cs=[, CZ], 
 ar=[AE, JO, SY, BH, SA, YE, EG, SD, TN, IQ, MA, QA, OM, , KW, LY, DZ, LB], 
 vi=[VN, ], th=[TH, , TH], ja=[JP, , JP], pl=[PL, ], ro=[RO, ], da=[DK, ], 
 nl=[, NL, BE], tr=[, TR]}

Si noti che per alcuni Locale, i metodi getLanguage e getCountry possono ritornare la stringa vuota.

[CharsetMap]    Nel package java.nio.charset c'è la classe Charset le cui istanze rappresentano le diverse codifiche per i caratteri. Il metodo statico SortedMap<String,Charset> availableCharsets() ritorna una mappa che ad ogni nome canonico di codifica (conosciuto dalla JVM) associa il corrispondente oggetto Charset. Un Charset chs1 contiene un Charset chs2 se tutti i caratteri codificabili tramite chs2 sono codificabili anche tramite chs1. Il metodo boolean contains(Charset cs) ritorna true se il Charset su cui è invocato contiene il Charset cs. Scrivere una pipeline che partendo dall'insieme dei Charset che sono i valori della mappa ritornata da availableCharsets produce una mappa di tipo Map<Boolean,List<String>> che alla chiave true associa la lista dei nomi (prodotti tramite il metodo String name()) dei Charset che sono contenuti nel Charset di default (ottenibile tramite il metodo statico Charset defaultCharset()) e alla chiave false associa la lista dei nomi di quelli non contenuti.

6 Apr 2016