Java 8 in practice parte 1: filtro e trasformazione dei dati in uno stream

Java 8 è uscito nella versione definitiva a Marzo, ne abbiamo già parlato ampiamente su CoseNonJaviste in alcuni post in cui abbiamo visto soprattutto della teoria. E’ arrivato il momento di mettere in pratica i concetti che abbiamo visto con un esempio un po’ più complesso. In questo post descriveremo l’esempio e vedremo come filtrare e trasformare i dati in uno stream. Nel prossimo post parleremo dei method references e vedremo come sfruttarli per ordinare i dati di uno stream.

Iniziamo esponendo il problema che vogliamo risolvere utilizzando le nuove feature introdotte in Java 8. Abbiamo un file di testo con tutti i risultati della seria A di calcio formattato in questo modo:

Giornata Nº 1
Data	Incontro	Risultato
24/08/2013	Verona	Milan	2	1
24/08/2013	Sampdoria	Juventus	0	1
25/08/2013	Inter	Genoa	2	0
25/08/2013	Torino	Sassuolo	2	0
....

Per ogni giornata c’è un titolo e una intestazione, fra una giornata e la successiva è presente una riga vuota. Le informazioni di ogni partita sono su una riga con i dati separati da tab. Partendo da questo file vogliamo calcolare la classifica del campionato, analizzando ogni partita e assegnando i punti per le vittorie e i pareggi.

Iniziamo subito vedendo il risultato finale in puro stile Java 8:

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

Poche righe di codice ma molto dense di significato! Qualcuno probabilmente si starà chiedendo: in che linguaggio di programmazione è scritto? Tranquilli, è sempre codice Java, il System.out scritto nell’ultima riga risulta familiare!

Filtro dei dati di uno stream in Java 8

Iniziamo a analizzare il codice appena visto, abbiamo una lista di stringhe (le righe del file da analizzare) e vogliamo togliere le righe vuote e quelle che non contengono i risultati delle partite. Per far questo possiamo creare lo stream a partire dalla nostra lista e applicare tre volte il metodo filter che accetta come parametro una lambda expression (il Predicate che sarà usato per capire se un elemento deve essere incluso nei risultati):

strings.stream()
    .filter(s -> !s.isEmpty())
    .filter(s -> !s.startsWith("Giornata"))
    .filter(s -> !s.startsWith("Data"));

In questo modo filtriamo i dati togliendo le righe di testo che non ci interessano. Pur essendo già abbastanza breve questo blocco di codice può essere semplificato ancora, per questo definiamo un metodo statico di utilità che esegue la negazione di un oggetto Predicate:

public static <T> Predicate<T> not(Predicate<T> t) {
    return t.negate();
}

L’interfaccia Predicate è una interfaccia funzionale, contiene solo un metodo da implementare. Grazie ai metodi di default aggiunti in Java 8 questa interfaccia contiene altri metodi già implementati, come ad esempio il metodo negate che utilizziamo in questo esempio.

Riscriviamo il codice che abbiamo visto usando questo metodo:

strings.stream()
    .filter(not(s -> s.isEmpty()))
    .filter(not(s -> s.startsWith("Giornata")))
    .filter(not(s -> s.startsWith("Data")));

Ok, non abbiamo fatto un gran che e probabilmente abbiamo complicato il codice! Ma adesso possiamo togliere la lambda expression che richiama il metodo isEmpty e passare direttamente il riferimento al metodo:

strings.stream()
    .filter(not(String::isEmpty))
    .filter(not(s -> s.startsWith("Giornata")))
    .filter(not(s -> s.startsWith("Data")));

C’è un’altra cosa che possiamo migliorare in questo codice: è presente una piccola duplicazione nella seconda e terza chiamata al metodo filter. Per evitare questa duplicazione possiamo creare un metodo che data una stringa ritorna un Predicate che controlla se una stringa inizia con quella passata:

public static Predicate<String> startsWith(String start) {
    return s -> s.startsWith(start);
}

In pratica è una funzione che invece di eseguire un blocco di codice ritorna una funzione. Questi tipi di metodo vengono chiamati higher-order function, sono da sempre abbastanza comuni in Javascript e, grazie soprattutto all’introduzione delle lambda, diverranno molto utilizzati anche in Java. All’inizio può sembrare un po’ complesso, non è semplice capire che start è un parametro del metodo statico e s è il parametro della funzione ritornata dal metodo statico.

Usando questo metodo possiamo semplificare il nostro esempio

strings.stream()
    .filter(not(String::isEmpty))
    .filter(not(startsWith("Giornata")))
    .filter(not(startsWith("Data")));

A questo punto possiamo usare un altro metodo di default dell’interfaccia funzionale Predicate per invocare una sola volta il metodo filter sullo stream:

Stream<String> stringsStream = strings.stream()
    .filter(
        not(String::isEmpty)
        .and(not(startsWith("Giornata")))
        .and(not(startsWith("Data")))
    )

Con poche righe di codice (e neanche un ciclo for) siamo riusciti a filtrare i dati per escludere le righe che non ci interessano. Siamo pronti per trasformare i dati in modo da poter calcolare la classifica.

Trasformazione dei dati: map, reduce e collect

A questo punto abbiamo una lista (o meglio, uno stream) di stringhe che contiene tutti i risultati della serie A. Vogliamo trasformarla in una lista di oggetti TeamItem, questi oggetti contengono il riferimento al numero di punti che una squadra ha ottenuto in una partita e serviranno per calcolare la classifica. La classe TeamItem non è niente di particolare, è un classico bean Java con i getter e l’implementazione del metodo toString:

public class TeamItem {
    private String team;

    private int points;

    public TeamItem(String team, int points) {
        this.team = team;
        this.points = points;
    }

    public String getTeam() {
        return team;
    }

    public int getPoints() {
        return points;
    }

    @Override
    public String toString() {
        return points + "\t" + team;
    }
}

Come abbiamo visto nel post introduttivo sugli stream per cambiare il tipo degli oggetti possiamo utilizzare il metodo map. Questo metodo permette di trasformare ad uno ad uno gli oggetti contenuti dello stream invocando la lambda expression passata come parametro. In questo caso però non vogliamo fare un mapping uno ad uno, infatti nel caso di pareggio dobbiamo assegnare un punto ad ogni squadra e quindi creare due oggetti di tipo TeamItem partendo da una sola stringa. Per questo possiamo utilizzare il metodo flatMap che accetta una lambda expression che invece di ritornare un singolo oggetto ritorna a sua volta uno stream di oggetti. In pratica nel metodo flatMap la lambda expression passata viene invocata per ogni oggetto dello stream di partenza, il risultato di queste invocazioni, ovvero uno stream di stream, viene appiattito per avere uno stream di oggetti.

Scriviamo anche un metodo statico che esegue il parsing di una stringa e, in base ai goal segnati dalle due squadre, ritorna uno stream di oggetti TeamItem. In pratica in caso di vittoria vengono assegnati tre punti alla squadra vincente altrimenti c’è un pareggio e quindi ognuna delle due squadre ottiene un punto:

public static Stream<TeamItem> createItems(String s) {
    String[] split = s.split("\t");
    int goal1 = Integer.parseInt(split[3]);
    int goal2 = Integer.parseInt(split[4]);

    if (goal1 > goal2) {
        return Stream.of(new TeamItem(split[1], 3));
    } else if (goal1 == goal2) {
        return Stream.of(
                new TeamItem(split[1], 1),
                new TeamItem(split[2], 1)
        );
    } else {
        return Stream.of(new TeamItem(split[2], 3));
    }
}

A questo punto possiamo invocare il metodo flatMap passando il riferimento al metodo statico appena scritto:

Stream<TeamItem> teamItemStream = stringsStream.flatMap(TeamItem::createItems);

Siamo arrivati ad avere una lista di punteggi ottenuti dalle squadre nelle varie partite, a questo punto dobbiamo fare la somma per ottenere la classifica. Per prima cosa dobbiamo raggruppare i dati per squadra, possiamo farlo in modo semplice con il metodo collect passando un oggetto di tipo Collector:

Map<String, List<TeamItem>> map = teamItemStream.collect(groupingBy(TeamItem::getTeam));

In questo caso per avere il parametro da passare richiamiamo il metodo statico groupingBy della classe Collectors. Questa classe contiene vari metodi statici per creare gli oggetti Collector più comunemente usati. Con la riga di codice appena vista creiamo una mappa di oggetti in cui raggruppiamo i dati per squadra.

A questo punto abbiamo i dati raggruppati, dobbiamo sommare i punti e creare una lista di oggetti TeamItem. Per questo possiamo creare uno stream delle entry presenti nella mappa e trasformare le varie entry in un oggetto TeamItem. Come già detto quando vogliamo trasformare oggetti in uno stream possiamo usare il metodo map passando una lambda expression:

Stream<TeamItem> itemsStream = map.entrySet().stream().map(
    e -> {
        TeamItem identity = new TeamItem(e.getKey(), 0);
        Stream<TeamItem> stream = e.getValue().stream();
        return stream.reduce(identity, (t1, t2) ->
            new TeamItem(t1.getTeam(), t1.getPoints() + t2.getPoints())
        );
    }
);

Nella lambda expression vogliamo creare un oggetto TeamItem partendo da una lista di oggetti TeamItem. Questa operazione viene comunemente chiamata reduce, per questo gli stream di Java 8 contengono il metodo reduce. In pratica in questo metodo vengono processati i dati a due a due, da ogni coppia viene creato un nuovo oggetto contenente un risultato parziale dell’operazione (nel nostro caso la somma dei punti). In questo caso passiamo anche un oggetto identity che viene utilizzato per partire nella computazione e per essere sicuri di avere un risultato anche con uno stream vuoto. Esiste anche una versione del metodo reduce che non ha fra i parametri l’oggetto identity, in questo caso viene ritornato un optional.

I metodi map e reduce sono molto spesso utilizzati insieme per eseguire un calcolo, per questo si parla spesso di MapReduce. Il concetto di MapReduce è alla base di varie tecnologie, da hadoop a mongodb a molti altri sistemi distribuiti. Si adatta bene ad essere utilizzato in sistemi distribuiti in quanto le varie operazioni di map e reduce possono essere facilmente parallelizzate anche su più server in contemporanea.

Per questo esempio possiamo utilizzare anche un altro approccio, possiamo trasformare il nostro stream di oggetti in un IntStream usando il metodo mapToInt. Questo metodo trasforma oggetti in interi (il tipo primitivo int) applicando la lambda expression passata di tipo ToIntFunction:

Stream<TeamItem> itemsStream = map.entrySet().stream().map(
    e -> new TeamItem(
        e.getKey(),
        e.getValue().stream().mapToInt(TeamItem::getPoints).sum()
    )
);

La classe IntStream contiene il metodo sum che esegue una operazione di reduce per ottenere la somma dei valori dello stream. In questo modo non dobbiamo preoccuparci di passare una lambda expression, pensa a tutto lo stream di interi.

Un approccio ancora diverso consiste nell’eseguire il raggruppamento e la somma contemporaneamente. Per questo possiamo usare sempre il metodo groupingBy della classe Collectors ma nella versione con due parametri. Il secondo parametro è un oggetto Collector che sarà utilizzato per calcolare il valore da inserire nella mappa. Nell’esempio precedente non avendo passato niente viene utilizzato il metodo toList che accumula i vari elementi in una lista di oggetti. Volendo eseguire direttamente la somma usiamo il metodo summingInt sempre della classe Collectors:

Map<String, Integer> map = teamItemStream.collect(
    groupingBy(
        TeamItem::getTeam,
        summingInt(TeamItem::getPoints)
    )
);

A questo punto il grosso del lavoro è già fatto, dobbiamo solo creare una lista di oggetti a partire dalla mappa. Anche in questo caso usiamo il metodo map passando una lambda expression:

Stream<TeamItem> itemsStream = map.entrySet().stream().map(e -> new TeamItem(e.getKey(), e.getValue()));

Conclusioni

Siamo arrivati a un buon punto, abbiamo la classifica con tutte le squadre e i relativi punti ma non sono ordinati. L’ordinamento di uno stream sarà proprio l’argomento del prossimo post, ovviamente utilizzeremo un oggetto Comparator ma vedremo che, anche in questo caso, ci sono diverse novità di Java 8 che possono darci una mano. Alla prossima!

Fabio Collini

Software Architect con esperienza su piattaforma J2EE e attualmente focalizzato principalmente in progetti di sviluppo di applicazioni Android. Attualmente sono in Nana Bianca dove mi occupo dello sviluppo di alcune app Android. Coautore della seconda edizione di Android Programmazione Avanzata e docente di corsi di sviluppo su piattaforma Android. Follow me on Twitter - LinkedIn profile

  • Luca Meravigliao

    Tutorial fatto bene. Con i sorgenti ho degli errori e non riesco a risolverli. Potresti mettere i sorgenti completi ?
    Grazie mille

    • Ciao, purtroppo è passato un po’ di tempo da quando ho scritto questo post e non ho i sorgenti completi. Che errore hai? Probabilmente è dovuto a qualche import mancante, gli import statici non sempre sono inclusi automaticamente dall’IDE.