Metodologie di Programmazione: Lezione 10

Riccardo Silvestri

Espressioni lambda

Con la versione 8 Java ha introdotto alcune nuove caratteristiche sia relative alla sintassi del linguaggio che alla libreria che permettono entro una certa misura una programmazione di stile funzionale. Tutto ciò mantenendo come sempre la compatibilità con il codice pregresso. In questa lezione vedremo il nucleo fondamentale di queste caratteristiche che al di là dello stile funzionale offrono in molti casi la possibilità di scrivere codice più parametrizzabile e quindi con maggiori possibilità di riuso.

Le lambda e le interfacce funzionali

Le espressioni lambda (lambda expressions1) sono state introdotte in Java 8 per agevolare lo stile di programmazione funzionale. In altri linguaggi come ad esempio Python, che al pari di Java non sono linguaggi funzionali, le funzioni sono comunque riconosciute come valori di un tipo a sé stante, si dice anche che sono first class citizens. In Java non è così, tutti i valori, eccetto quelli primitivi, sono oggetti e non sono funzioni o metodi. I metodi fanno parte delle classi e fanno parte delle istanze delle classi, ma non possono esistere al di fuori di una classe o di un'istanza di una classe. Per queste ragioni le espressioni lambda non permettono di definire un nuovo tipo che corrisponde alle funzioni o metodi, ma è una sintassi più semplice e leggibile per definire-creare un'istanza di una classe anonima che implementa un'interfaccia con un solo metodo astratto. La sintassi generale di una espressione lambda è la seguente:

lambda-parametri -> lambda-corpo

Si noti lo speciale segno ->. Questa è rozzamente equivalente a

new NomeI() {
    metodo(lambda-parametri) {
        lambda-corpo
    }
}

dove NomeI è il nome dell'interfaccia implementata dalla espressione lambda e metodo è l'unico metodo astratto di tale interfaccia. In un certo senso questo tipo di interfacce che rappresentano oggetti con un solo metodo sono, nel contesto di un linguaggio orientato agli oggetti come Java, ciò che più si avvicina a una funzione. Queste interfacce sono appunto dette interfacce funzionali (functional interfaces) e sono generalmente marcate con l'annotazione @FunctionalInterface, anche se non è obbligatorio. Nella libreria di Java ne sono state introdotte molte nel nuovo package java.util.function. Una di queste è Predicate<T> che ha un unico metodo astratto boolean test(T t). Un'espressione lambda che crea un predicato sulle stringhe che ritorna sempre true è

Predicate<String> pred = (s) -> true;

Senza le lambda, avremmo dovuto scrivere,

Predicate<String> pred = new Predicate<String>() {
    public boolean test(String s) { return true; }
};

che è decisamente più verboso. La sintassi delle lambda si concentrano solamente sulla definizione dell'unico metodo astratto dell'interfaccia. I tipi dei parametri sono opzionali, perché sono inferiti dal compilatore. Ad esempio per l'interfaccia BiPredicate<T,U> con metodo boolean test(T t, U u),

BiPredicate<Integer,String> pred2 = (i,s) -> s.length() == i;  // OK
pred2 = (Integer i, String s) -> s.length() == i;              // OK
pred2 = (Integer i, s) -> s.length() == i;         // ERRORE: o tutti o nessuno

Come si vede dall'ultima espressione, o nessuno dei tipi dei parametri è dichiarato oppure lo devono essere tutti. Se c'è solamente un parametro, le parentesi tonde possono essere omesse,

pred = s -> true;     // OK se c'è un solo parametro

Se è sufficiente un'espressione il cui valore è quello che è ritornato dal metodo dell'interfaccia, allora il corpo della lambda può essere formato proprio da tale espressione, come mostrano gli esempi dati. Però se non si può definire il comportamento con un'espressione ma occorrono altri costrutti decisionali, di iterazione o variabili è necessario racchiudere il corpo tra parentesi graffe come un qualsiasi blocco di codice o corpo di metodo. Ad esempio, il metodo getCheckLen che abbiamo definito nella lezione precedente usando una classe anonima può essere definito più semplicemente con una lambda:

static Checker getCheckLen(int min, int max) {
    return s -> {
        if (s == null) return "Non può essere null";
        int len = s.length();
        if (len < min || len > max)
            return "La lunghezza deve essere tra "+min+" e "+max;
        return null;
    };
}

Quindi le espressioni lambda sono una sintassi semplificata per classi anonime che implementano interfacce funzionali. Però a differenza di una classe anonima, le parole chiavi this e super presenti nel corpo di una lambda si riferiscono alla classe che ne contiene la definizione e non all'oggetto creato dalla lambda stessa.

Riferimenti a metodi e a costruttori

Un'altra semplificazione introdotta insieme alle espressione lambda è una sintassi molto snella per definire una lambda che consiste solamente in (un'invocazione di) un metodo o un costruttore già esistente. Ad esempio, la lambda

BiPredicate<String,String> eqstr = (s1,s2) -> s1.equals(s2);

può essere definita può semplicemente,

eqstr = String::equals;

Questo è un esempio di un riferimento a metodo (reference method). Si noti che il compilatore inferisce che il primo argomento è usato come oggetto sul quale è invocato il metodo equals passandogli il secondo argomento. In generale la sintassi di un riferimento a metodo può essere una delle seguenti:

NomeClasse::metodoIstanza  // Il primo parametro è l'oggetto su cui è invocato
                           // il metodo e gli eventuali altri parametri sono
                           // passati al metodo
NomeClasse::metodoStatico  // Tutti i parametri sono passati al metodo
oggetto::metodoIstanza     // Tutti i parametri sono passati al metodo

L'esempio di equals rientra nel secondo caso, cioè, metodo dell'istanza referenziato dal nome della classe. Vediamo qualche altro esempio. L'interfaccia Iterable<E> ha il metodo di default (ereditato quindi da Collection<E> e da tutti i suoi sotto-tipi, List<E>, Set<E>, ecc.)

void forEach(Consumer<? super T> action)
Esegue l'azione action per ogni elemento di questo iterabile. Dove Consumer<T> è una delle interfacce funzionali in java.util.function e rappresenta un'operazione che prende in input un argomento e non ritorna alcun risultato.
Se vogliamo stampare una lista di Dipendente, possiamo scrivere

List<Dipendente> dd = . . .
dd.forEach(System.out::println);

L'interfaccia Collection<E> ha il metodo

boolean removeIf(Predicate<? super E> filter)
Rimuove tutti gli elementi di questa collezione che soddisfano il predicato filter.
Se vogliamo eliminare da una lista di stringhe tutti i null, possiamo scrivere

List<String> slist = . . .
slist.removeIf(Objects::isNull);

dove static boolean isNull(Object obj) è un metodo statico della classe Objects.

Il terzo caso della sintassi per i riferimenti a metodi, cioè metodo dell'istanza referenziato da un oggetto, si può usare con this e super. Ad esempio, this::equals è equivalente a x -> this.equals(x).

Oltre ai riferimenti a metodi è possibile usare anche i riferimenti a costruttori (constructor references). La sintassi è come quella dei riferimenti a metodi con il nome del metodo che è new. Se la classe ha più di un costruttore, dipende dal contesto quale sarà scelto. Ad esempio possiamo definire il seguente metodo

/** Ritorna una lista con ogni elemento creato tramite gen in corrispondenza
 * ad ognuno dei valori dell'array specificato.
 * @param gen  per creare un elemento
 * @param array  i valori usati per creare gli elementi
 * @param <E>  tipo degli elementi
 * @param <T>  tipo dei valori
 * @return una lista di elementi creati dai valori dell'array */
private static <E,T> List<E> createList(Function<T,E> gen, T...array) {
    List<E> list = new ArrayList<>();
    for (T v : array)
        list.add(gen.apply(v));
    return list;
}

Abbiamo usato Function<T,R> un'altra delle interfacce funzionali di java.util.function e che ha il metodo astratto R apply(T t) il quale semplicemente applica l'argomento t alla funzione e ritorna il risultato. Possiamo usare createList per creare una lista di dipendenti, a partire da un array di nomi e cognomi, e poi stamparla,

List<Dipendente> dlist = createList(Dipendente::new, "Mario Rossi",
                  "Ugo Gialli", "Carla Bo", "Lia Dodi", "Ciro Espo");
dlist.forEach(System.out::println);

producendo

mp.Dipendente[codice=1,nomeCognome=Mario Rossi]
mp.Dipendente[codice=2,nomeCognome=Ugo Gialli]
mp.Dipendente[codice=3,nomeCognome=Carla Bo]
mp.Dipendente[codice=4,nomeCognome=Lia Dodi]
mp.Dipendente[codice=5,nomeCognome=Ciro Espo]

Si noti l'uso del riferimento al costruttore Dipendente::new. Il costruttore che è stato inferito dal compilatore è l'unico applicabile in questo contesto, cioè quello che ha come unico parametro una stringa.

I riferimenti a costruttori possono essere usati anche per i tipi array: ad es. int[]::new è equivalente a n -> new int[n]. Come esempio, consideriamo il metodo

/** Ritorna un array che contiene tutti gli elementi della collezione.
 * @param coll  una collezione
 * @param gen  per creare un array del tipo giusto
 * @param <E>  il tipo degli elementi dell'array
 * @return un array che contiene tutti gli elementi della collezione */
private static <E> E[] toArray(Collection<? extends E> coll, 
                                                IntFunction<E[]> gen) {
    E[] array = gen.apply(coll.size());
    int i = 0;
    for (E e : coll)
        array[i++] = e;
    return array;
}

Abbiamo usato IntFunction<R>, sempre del package java.util.function, che rappresenta una funzione che prende in input un valore di tipo R e ritorna un int. Possiamo usare il metodo per ottenere un array di dipendenti da una lista di dipendenti,

Dipendente[] dd = toArray(dlist, Dipendente[]::new);

usando il riferimento a un costruttore di array di dipendenti Dipendente[]::new.

Continuiamo ora con altri esempi d'uso delle espressioni lambda.

Lambda per ordinare

Abbiamo già incontrato l'interfaccia Comparable<T> che è usata per definire il cosiddetto ordinamento naturale ed è infatti implementata da molte classi della libreria di Java, String, Integer, Double, ecc. Però tale interfaccia, anche se ha un solo metodo astratto, non è marcata con l'annotazione @FunctionalInterface perché deve essere necessariamente implementata dalla classe che vuole definire un ordinamento per le sue istanze. Quindi non avrebbe molto senso implementare Comparable con un'espressione lambda. Per definire un ordinamento su un certo tipo di oggetti sganciato dalla classe che definisce gli oggetti si può usare l'interfaccia funzionale Comparator<T> il cui unico metodo astratto è

int compare(T o1, T o2)
Ritorna un intero negativo, zero o positivo a seconda che o1 sia minore, uguale o maggiore di o2.
L'interfaccia è stata introdotta nella lontana versione 2 di Java, ma nella versione 8 è stata rivista marcandola come interfaccia funzionale e aggiungendo vari metodi statici e di default.

Ad esempio se abbiamo un array di stringhe da ordinare potremmo usare il metodo static void sort(Object[] a) di Arrays che ordina rispetto all'ordinamento naturale. Se però volessimo ordinare le stringhe secondo un ordine diverso, possiamo usare il metodo <T> void sort(T[] a, Comparator<? super T> c), sempre di Arrays, passandogli un opportuno Comparator<T>. Ad esempio se vogliamo ordinare senza tener conto delle maiuscole/minuscole, possiamo sfruttare il metodo int compareToIgnoreCase(String str) delle stringhe,

String[] sarr = {"Pera","Mela","fragola","arancia","Uva","pesca"};
Arrays.sort(sarr);                               // Ordinamento naturale
out.println(Arrays.toString(sarr));
Arrays.sort(sarr, String::compareToIgnoreCase);  // Ignora maiuscole/minuscole
out.println(Arrays.toString(sarr));

questo produce in output

[Mela, Pera, Uva, arancia, fragola, pesca]
[arancia, fragola, Mela, Pera, pesca, Uva]

Se vogliamo ordinare rispetto alla lunghezza possiamo sfruttare uno dei metodi di utilità di Comparator: static <T> Comparator<T> comparingInt(ToIntFunction<? super T> keyExtractor) che ritorna un Comparator che confronta gli elementi rispetto alla key (di tipo int) ritornata dalla funzione keyExtractor applicata agli elementi.

sarr = new String[] {"Pera","Mela","fragola","arancia","Uva","pesca"};
Arrays.sort(sarr, Comparator.comparingInt(String::length));
out.println(Arrays.toString(sarr));

Questo produce

[Uva, Pera, Mela, pesca, fragola, arancia]

Le stringhe con la stessa lunghezza (cioè quelle per cui il confronto con il Comparator ritorna zero) rimangono nelle stesse posizioni relative, cioè la procedura di ordinamento usata da Array.sort è stabile. Se vogliamo ordinare anche quelle con la stessa lunghezza, ad esempio rispetto all'ordinamento che ignora le maiuscole/minuscole, possiamo usare un altro dei metodi di utilità di Comparator: default Comparator<T> thenComparing(Comparator<? super T> other) che ritorna un Comparator che prima applica il Comparator su cui il metodo è invocato e poi sugli elementi che hanno confronto pari a zero applica other. Ad esempio,

Arrays.sort(sarr, Comparator.comparingInt(String::length).thenComparing(
            String::compareToIgnoreCase));
out.println(Arrays.toString(sarr));

produce

[Uva, Mela, Pera, pesca, arancia, fragola]

Quindi usando thenComparing è facile definire Comparator che ordinano rispetto a una caratteristica e a parità di questa ordinano rispetto ad un'altra caratteristica.

Anche le liste hanno metodi sort simili a quelli di Arrays che prendono in input un Comparator e ritornano liste ordinate.

Lambda per liste e mappe

Supponiamo di dover modificare una lista di stringhe di modo che tutte le stringhe siano in maiuscolo. Possiamo usare il metodo void replaceAll(UnaryOperator<E> operator) di List<E> che sostituisce ogni elemento x delle lista con quello che è ritornato da operator applicato ad x. Basterà fornire quindi al metodo replaceAll un operatore che ritorna la stringa di input in maiuscolo. Questo è facile

List<String> fruits = Arrays.asList(sarr);
fruits.replaceAll(String::toUpperCase);
out.println(fruits);

L'esecuzione produce

[UVA, MELA, PERA, PESCA, ARANCIA, FRAGOLA]

Qualche lezione fa abbiamo definito un metodo wordMap che dato un file di testo ritorna una mappa che conta le occorrenze delle parole. Però le chiavi della mappa ritornata distinguono le maiuscole/minuscole. Ora vorremmo ricavare da questa mappa una che invece non fa distinzioni di questo tipo e anzi ha solamente chiavi in minuscolo, ma con i conteggi preservati. Vale a dire che se nella mappa originale ci sono ad esempio le associazioni ("La", 20) e ("la", 40), allora nella nuova mappa ci deve essere ("la", 60). Possiamo scrivere un metodo che fa questa elaborazione sfruttando alcuni metodi di Map<K,V> introdotti per usare al meglio le espressioni lambda. Prima di tutto il metodo forEach(BiConsumer<? super K,? super V> action) che permette di iterare su tutte le associazioni di una mappa applicando su ognuna action che è un BiConsumer, cioè una funzione che riceve in input due valori. Poi possiamo usare il metodo merge(K key, V value, BiFunction<? super V,? super V,? extends V> remappingFunction) che è utile per aggiornare la nuova mappa, infatti se non è presente ancora nessuna associazione per la chiave key la aggiunge con il valore value, altrimenti il nuovo valore è dato da quello ritornato applicando la funzione remappingFunction al vecchio valore e a value. Questo è proprio quello che ci serve per aggiornare i conteggi.

/** Ritorna una mappa che ha come chiavi le stringhe della mappa wm ridotte
 * in minuscolo e i conteggi sono aggiornati sommando quelli che sono
 * relativi a chiavi di wm che sono uguali se ridotte in minuscole.
 * @param wm  una mappa da stringhe a interi
 * @return una mappa che con solo chiavi minuscole ma conteggi preservati */
public static Map<String,Integer> wordMapLowerCase(Map<String,Integer> wm) {
    Map<String,Integer> wmap = new HashMap<>();
    wm.forEach((s,i) -> wmap.merge(s.toLowerCase(), i, Integer::sum));
    return wmap;
}

Si noti l'uso del riferimento al metodo Integer::sum. Prima di provarlo conviene definire anche un metodo che ci permette di calcolare la somma di tutte le occorrenze:

/** Ritorna la somma degli interi della collezione.
 * @param coll  una collezione di interi
 * @return la somma degli interi della collezione */
public static int sum(Collection<Integer> coll) {
    Integer[] counter = {0};
    coll.forEach(i -> counter[0] += i);
    return counter[0];
}

Si osservi il trucchetto che abbiamo usato per aggirare la restrizione che una lambda non può usare una variabile locale che non è effettivamente final. Adesso possiamo mettere alla prova il metodo

try {
    Map<String,Integer> wmap = Utils.wordMap(Paths.get("files",
            "alice_it_utf8.txt"), "utf8");
    out.println("Numero chiavi: "+wmap.size());
    out.println("Numero occorrenze: "+ sum(wmap.values()));
    Map<String,Integer> wmaplc = wordMapLowerCase(wmap);
    out.println("Numero chiavi: "+wmaplc.size());
    out.println("Numero occorrenze: "+ sum(wmaplc.values()));
} catch (IOException e) {
    e.printStackTrace();
}

Se eseguito stampa

Numero chiavi: 5717
Numero occorrenze: 27794
Numero chiavi: 5180
Numero occorrenze: 27794

Esercizi

[Checker]    Riscrivere i metodi Checker.getCheckChars e Checker.getCheckAll tramite espressioni lambda.

[CheckerThen]    Aggiungere all'interfaccia Checker un metodo di default default Checker then(Checker c) che ritorna un Checker che prima esegue il controllo relativo all'oggetto e se questo è soddisfatto esegue il controllo di c. Usare un'espressione lambda.

[TipoFilter]    Perché il parametro filter del metodo removeIf(Predicate<? super E> filter) deve avere tipo Predicate<? super E>? Se invece fosse Predicate<E>? E se fosse Predicate<? extends E>? Quali differenze ci sarebbero per le possibili invocazioni del metodo?

[FilterPrefix]    Scrivere un metodo che prende in input una lista di stringhe e una stringa pre ed elimina dalla lista tutte le stringhe che iniziano con la stringa pre. Sfruttare il metodo removeIf.

[SortDipendenti]    Scrivere un metodo statico che prende in input una lista di Dipendente e la ordina rispetto allo stipendio e a parità di stipendio rispetto al nome e cognome. Scriverlo sfruttando il più possibile le lambda e i riferimenti a metodi.

[MaxWidth]    Scrivere un metodo int maxWidth(Collection<?> coll) che ritorna la massima lunghezza delle stringhe ritornate da toString relativo agli elementi della collezione coll. Cercare di implementarlo usando il metodo <T> T max(Collection<? extends T> coll, Comparator<? super T> comp) di Collections.

[ArrayReplace]    Scrivere un metodo statico analogo a replaceAll ma per gli array. Il metodo deve essere generico permettendo un qualsiasi tipo riferimento per le componenti dell'array.

[PrettyPrinting]    Scrivere un metodo statico <E> String prettyPrint(List<E> list, Function<? super E, String> toStr) che ritorna una stringa che contiene, una per linea, le stringhe ritornate applicando la funzione toStr agli elementi della lista list. Usare il metodo per stampare una lista di oggetti a piacere in vari modi: quello che ritorna toString ma in maiuscolo, lo stesso ma in minuscolo, quello che ritorna toString allineato a destra in un campo di lunghezza maggiore alla massima lunghezza delle toString.

[GeneralFilter]    Scrivere un metodo statico <E, C extends Collection<E>> C filter(C coll, Supplier<? extends C> gen, Predicate<E> p) che ritorna una collezione, creata usando gen, che contiene gli elementi di coll eccetto quelli che soddisfano il predicato p.

[FindFiles]    Scrivere un metodo Path[] find(Path dir, int minuzie) che ritorna un array con i path di tutti i file contenuti a qualsiasi livello nella directory dir che hanno size >= minsize. Preferibilmente usare il metodo Stream<Path> find(Path start, int maxDepth, BiPredicate<Path,BasicFileAttributes> matcher, FileVisitOption... options) di Files. Per ottenere l'array di Path da Stream<Path> si può usare il metodo A[] toArray(IntFunction<A[]> generator) di Stream.

26 Mar 2015


  1. Il nome lambda deriva dall'uso della lettera greca λ, introdotta dal logico Alonzo Church, per denotare le funzioni nell'ambito della formalizzazione del concetto di effettiva calcolabilità delle funzioni matematiche.