Java 8 in practice parte 2: method references e ordinamento di uno stream

Nel post precedente abbiamo introdotto un esempio concreto da risolvere utilizzando le nuove feature di Java 8, abbiamo già visto come filtrare e trasformare i dati di uno stream. Per completare l’esempio in questo post parleremo di come ordinare i dati, prima di questo approfondiremo l’argomento di come si può trasformare un metodo di una classe in una lambda expression.

Method references

Alla fine del post precedente siamo arrivati ad avere uno stream di oggetti di tipo TeamItem (un bean con i campi team e points). Adesso passiamo all’ordinamento dei dati per creare la classifica: vogliamo ordinare le squadre per i punti fatti e, nel caso di parità di punti, in ordine alfabetico. Possiamo passare al metodo sorted una lambda expression corrispondente a un oggetto Comparator:

itemsStream
    .sorted((t1, t2) -> {
        int r = t2.getPoints() - t1.getPoints();
        if (r == 0) {
            r = t1.getTeam().compareTo(t2.getTeam());
        }
        return r;
    });

Per avere un riuso più semplice del codice possiamo spostare il contenuto della lambda expression in un metodo statico:

public static int compare(TeamItem t1, TeamItem t2){
    int r = t2.points - t1.points;
    if (r == 0) {
        r = t1.team.compareTo(t2.team);
    }
    return r;
}

A questo punto, come abbiamo già visto più volte, possiamo passare la referenza del metodo:

itemsStream.sorted(TeamItem::compare);

Questo caso di method reference è abbastanza semplice, abbiamo un metodo statico e passiamo la referenza al metodo. Quando sarà utilizzato il metodo sarà invocato passando i parametri. Ci sono anche altri casi di method reference, vediamo un esempio:

Stream.of("a", "b", "c")
    .map(String::toUpperCase)
    .forEach(System.out::println);

In questo caso abbiamo due method reference, il parametro del metodo map è un metodo non statico senza parametri preso direttamente dalla classe String. Runtime questo metodo sarà invocato su ogni oggetto dello stream. Il parametro del foreach è invece un metodo non statico con un parametro preso da un oggetto (out è un campo statico della classe System). In questo caso runtime il metodo println verrà invocato sull’oggetto System.out passando come parametro l’i-esimo elemento dello stream. Riscrivendo l’esempio senza method reference possiamo vedere la differenza in modo esplicito, nel primo caso il metodo viene invocato sul parametro della lambda expression mentre nel secondo sull’oggetto System.out passando come parametro il parametro della lambda expression:

Stream.of("a", "b", "c")
    .map(s -> s.toUpperCase())
    .forEach(s -> System.out.println(s));

Ma torniamo al nostro ordinamento, in questo caso invece di avere una lambda expression con un solo parametro ne abbiamo una con due parametri. Per questo abbiamo un altro caso di method reference, scriviamo un metodo non statico dentro la classe TeamItem che confronta l’oggetto stesso con un altro oggetto:

public int compareTo(TeamItem other){
    int r = other.points - points;
    if (r == 0) {
        r = team.compareTo(other.team);
    }
    return r;
}

Anche in questo caso possiamo passare un method reference:

itemsStream.sorted(TeamItem::compareTo);

Abbiamo detto che il metodo della lambda expression ha due parametri, in questo caso il metodo compareTo, passato come method reference, sarà invocato sul primo parametro della lambda expression passando il secondo parametro della lambda expression come parametro del metodo:

itemsStream.sorted((t1, t2) -> t1.compareTo(t2));

Non è proprio banale capire tutti i casi di method reference ma per fortuna è più semplice utilizzarli! La sensazione è che comunque tutti i casi siano coperti e che si possa passare qualsiasi metodo a patto che rispetti la signature, Java rimane un linguaggio strong typed nonostante tutte le novità!

Ordinamento dei dati con un Comparator in stile Java 8

Scrivere un Comparator può sembrare semplice ma comunque il codice non è molto leggibile e possono essere introdotti degli errori. Quanto ci avete messo a capire che l’ordinamento per punti è decrescente perché la sottrazione viene eseguita scambiando l’ordine dei parametri? Secondo voi il metodo di confronto che abbiamo scritto è corretto con ogni dato di input? Vediamo un esempio:

Stream.of(
        new TeamItem("A", -1000000000), 
        new TeamItem("B", 2000000000)
    )
    .sorted(TeamItem::compareTo)
    .forEach(System.out::println);

Ok lo so, è difficile che una squadra faccia 2 milioni di punti in un campionato e che una venga penalizzata di un milione di punti! Ma in questo caso c’è un overflow e l’ordinamento viene al contrario. Questa cosa può essere corretta facilmente usando il metodo compare della classe Integer o un semplice if invece di fare una sottrazione.

Per evitare questi problemi e rendere leggibile il codice possiamo usare alcuni metodi di default introdotti nella classe Comparator. Il codice finale è questo:

itemsStream
    .sorted(
        comparingInt(TeamItem::getPoints).reversed()
            .thenComparing(comparing(TeamItem::getTeam))
    );

I metodi statici comparing e comparingInt creano un Comparator che utilizza la lambda expression passata per avere i valori da confrontare. Il metodo reversed inverte l’ordinamento e il metodo thenComparing concatena due Comparator. Scritto così è compatto, leggibile ed è molto più difficile introdurre errori. In pratica è la trasposizione in codice Java dei requisiti dell’ordinamento: ordina prima per i punti della squadra in ordine inverso e poi per il nome della squadra.

Multithread facile con gli stream

Siamo arrivati in fondo all’esempio, tutto semplice no? So che qualcuno starà pensando che con due o tre buon vecchi cicli for e qualche riga di codice in più avrebbe risolto tutto in un decimo del tempo! Ok, parliamoci chiaro, il passaggio a Java 8 non sarà sicuramente indolore e ci sarà da cambiare mentalità per sfruttarlo al meglio. Ma migliorerà il modo di scrivere codice? Vediamo se in questo paragrafo riesco a dare un motivo in più per usare Java 8.

Pensate a una implementazione classica usando più cicli for, adesso pensate a come trasformare quel codice per eseguirlo in contemporanea su più thread. Fatto? Quanto ci vuole a eseguire questo passaggio? Di sicuro non è una cosa che si fa in due minuti, va ripensato l’algoritmo per poterlo parallelizzare e una volta finito c’è da testare in modo accurato tutti i possibili problemi di concorrenza.

Facciamo lo stesso esercizio prendendo il codice visto in questo post, modifichiamolo in modo da eseguirlo su più thread:

List<String> strings = Files.readAllLines(Paths.get(path));
strings.parallelStream()
    .filter(
        not(String::isEmpty)
        .and(not(startsWith("Giornata")))
        .and(not(startsWith("Data")))
    )
    .flatMap(TeamItem::createItems)
    .collect(
        groupingBy(
            TeamItem::getTeam,
            summingInt(TeamItem::getPoints)
        )
    )
    .entrySet().parallelStream()
    .map(e -> new TeamItem(e.getKey(), e.getValue()))
    .sorted(
        comparingInt(TeamItem::getPoints).reversed()
            .thenComparing(comparing(TeamItem::getTeam))
    )
    .forEachOrdered(System.out::println);

Abbiamo modificato due chiamate rispetto all’esempio precedente. La modifica principale è stata cambiare le due chiamate al metodo stream in chiamate al metodo parallelStream. L’altra modifica è stata l’uso del metodo forEachOrdered al posto di un semplice forEach per fare in modo di analizzare gli elementi in modo ordinato pur arrivando da thread diversi. Eseguendo questo codice e misurando il tempo di esecuzione non vedrete un guadagno in quanto è tutto eseguito in memoria e l’overhead della gestione dei thread compensa il guadagno di eseguire il programma su più processori. Se provate a aggiungere qualche sleep per simulare una esecuzione più lunga vedrete che il guadagno è ingente (proporzionato al numero di core della macchina).

Il passaggio a una esecuzione parallela è stato possibile in quanto abbiamo usato uno stile di programmazione funzionale, ogni metodo non modifica variabili globali e, se deve effettuare modifiche, crea nuovi oggetti e li ritorna in output.

Conclusioni

Come già detto il passaggio a Java 8 non sarà semplice e indolore perché più che una modifica al linguaggio è una modifica al paradigma di programmazione: oltre ai classici costrutti object oriented Java sono stati aggiunti costrutti tipici dei linguaggi funzionali. C’è chi dice che è più facile passare da un linguaggio a un’altro (per esempio da Java a C#) che non passare da un paradigma all’altro restando nello stesso linguaggio.

Ovviamente non è una missione impossibile, con un po’ di curiosità e di voglia di imparare è possibile passare a Java 8 in tempi brevi. Il fatto che la nuova versione è backward compatibile aiuta molto in quanto i vari costrutti possono essere introdotti in modo graduale nel codice esistente o in codice nuovo.

Per approfondire l’argomento un libro consigliato che spiega molto bene tutti i costrutti funzionali di Java 8 è Functional Programming in Java: Harnessing the Power Of Java 8 Lambda Expressions di Venkat Subramaniam. Buona lettura!

Fabio Collini

Software Architect con esperienza su piattaforma J2EE e attualmente focalizzato principalmente in progetti di sviluppo di applicazioni Android. Attualmente sono nel team Android di Cynny, ci stiamo occupando dello sviluppo dell'app Morphcast. Coautore della seconda edizione di Android Programmazione Avanzata e docente di corsi di sviluppo su piattaforma Android. Follow me on Twitter - LinkedIn profile - Google+