Metodologie di Programmazione: Lezione 11

Riccardo Silvestri

Espressioni lambda

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

Le lambda e le interfacce funzionali

Le espressioni lambda (lambda expressions1) sono state introdotte in Java 8 per agevolare uno stile di programmazione funzionale. In altri linguaggi come Python, che al pari di Java non sono linguaggi funzionali, le funzioni sono comunque considerate valori di un tipo a sé stante (il tipo function in Python), e quindi si dice che le funzioni 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 sono 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 il segno speciale ->. 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 le 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. E sono appunto dette interfacce funzionali (functional interfaces). 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). Gli esempi relativi alle espressioni lambda li scriveremo nella classe mp.Lambda

/** Una classe per fare esempi sulle espressioni lambda */
public class Lambda {

}

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 concentra 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 ritornato dal metodo dell'interfaccia, allora il corpo della lambda può essere formato solamente da tale espressione, come mostrano gli esempi dati. Però se non si può definire il comportamento con un'espressione ma occorrono costrutti decisionali, di iterazione o l'introduzione di variabili è necessario racchiudere il corpo tra parentesi graffe come un qualsiasi blocco di codice o corpo di metodo. Ad esempio, il metodo checkLen che abbiamo definito nella lezione precedente usando una classe anonima può essere definito più semplicemente con una lambda:

static Checker checkLen(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 nell'invocazione di un metodo o di un costruttore. 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 (method reference). 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 primo 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.)

default void forEach(Consumer<? super T> action)
Esegue l'azione action su 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);    // Caso oggetto::metodoIstanza

L'interfaccia Collection<E> ha il metodo

default 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);    // Caso NomeClasse::metodoStatico

dove static boolean isNull(Object obj) è un metodo della classe Objects che ritorna true se obj è null.

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 la lista dei risultati prodotti dall'applicazione della funzione gen 
 * ad ognuno dei valori dell'array specificato.
 * @param gen  una funzione da applicare ai valori dell'array
 * @param array  un array di valori
 * @param <E>  tipo dei risultati
 * @param <T>  tipo dei valori
 * @return la lista dei risultati della funzione applicata ai 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, che rappresenta una funzione che prende in input valori di tipo T e ritorna valori di tipo R. Il suo metodo astratto R apply(T t) applica la funzione all'argomento t 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 un solo parametro e di tipo 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  funzione che crea un array di tipo E[]
 * @param <E>  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 int e ritorna un valore di tipo R. 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.

Si osservi che questo permette di compensare molte delle limitazioni dovute al fatto che non si possono creare direttamente array di tipo parametrico.

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.

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 in 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> remapFunc) 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 è quello ritornato dalla funzione remapFunc applicata 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 numeri
 * @return una mappa che ha solo chiavi minuscole ma conteggi preservati */
public static <N extends Number> Map<String,Double> wordMapLowerCase(Map<String,N> wm) {
    Map<String,Double> wmap = new HashMap<>();
    wm.forEach((s,i) -> wmap.merge(s.toLowerCase(), i.doubleValue(), Double::sum));
    return wmap;
}    

Si osservi come sia stato definito nel modo più generale possibile. Si noti anche l'uso del riferimento a metodo Double::sum relativo al metodo static double sum(double a, double b) della classe Double. Prima di provarlo conviene definire anche un metodo che ci permette di calcolare la somma di tutte le occorrenze:

/** Ritorna la somma dei numeri della collezione.
 * @param coll  una collezione di numeri
 * @return la somma dei numeri della collezione */
public static double sum(Collection<? extends Number> coll) {
    Double[] counter = {0.0};
    coll.forEach(i -> counter[0] += i.doubleValue());
    return counter[0];
}

Si osservi come sia stato definito nel modo più generale possibile e si noti anche il trucchetto per "aggirare" la restrizione che una lambda non può accedere a una variabile locale che non è effettivamente final. Adesso possiamo mettere alla prova il metodo

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

Se eseguito stampa

Numero chiavi: 5717
Numero occorrenze: 27794.0
Numero chiavi: 5180
Numero occorrenze: 27794.0

Esercizi

[Checker]    Riscrivere i metodi Checker.checkChars e Checker.checkAll 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) ha il 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 degli 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 uguale 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 tutti gli elementi di coll eccetto quelli che soddisfano il predicato p.

[FindFiles]    Scrivere un metodo Path[] find(Path dir, int minsize) che ritorna un array con i path di tutti i file contenuti a qualsiasi livello nella directory dir che hanno size >= minsize. Usare preferibilmente 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.

5 Apr 2016


  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.