Lambda e funzioni first-class

Progettazione e Sviluppo del Software

C.D.L. Tecnologie dei Sistemi Informatici

Gianluca Aguzzi — gianluca.aguzzi@unibo.it

Angelo Filaseta — angelo.filaseta@unibo.it

versione stampabile

Riconoscimenti

  • Questo materiale è ampiamente basato su quello realizzato dai Prof. Mirko Viroli e Roberto Casadei, che ringrazio.

  • Ogni errore riscontratovi è esclusiva responsabilità degli autori di questo documento.

Outline

Goal della lezione

  • Illustrare il concetto di lambda
  • Dettagliare il supporto alle lambda in Java

Argomenti

  • Espressioni lambda
  • Interfacce funzionali
  • Altri usi nell’API
  • Stream

Introduzione alle lambda

Le novità di Java 8

Una release molto attesa, che “rincorre” C# e Scala

  • Molte funzionalità rimandate da Java 7
  • Java 8 disponibile dall’estate 2014
  • Principale novità: lambda (ossia uno degli elementi fondamentali dello stile di programmazione funzionale)
    • Le lambda portano ad uno stile più elegante e astratto di programmazione
    • In Java, portano a codice più compatto e chiaro in certe situazioni
    • Impatta alcuni aspetti di linguaggio
    • Impatta varie librerie

Risorse

Da classi/metodi alle lambda: il caso dei comparatori

Comparatori definiti attraverso classi

public class FirstComparableBasic {
	public static void main(String[] args) {
		final List<Person> list = new ArrayList<Person>();
		list.add(new Person("Mario", 1960, true));
		list.add(new Person("Gino", 1970, false));
		list.add(new Person("Rino", 1951, true));
		System.out.println(list);

		Collections.sort(list, new AgeComparator());
		System.out.println(list);

		Collections.sort(list, new AgeComparator().reversed());
		System.out.println(list);

		Collections.sort(list, new NameComparator());
		System.out.println(list);
	}
}

class AgeComparator implements Comparator<Person> {
	@Override
	public int compare(Person o1, Person o2) {
		return Integer.compare(o1.getYearOfBirth(), o2.getYearOfBirth());
	}
}

class NameComparator implements Comparator<Person> {
	@Override
	public int compare(Person o1, Person o2) {
		return o1.getName().compareTo(o2.getName());
	}
}

Comparatori definiti attraverso classi innestate

  • Una classe innestata statica B (static nested) è definita all’interno di un’altra classe A
    • Dunque ci si può riferire ad essa via A.B (secondo regole di visibilità), o direttamente via B da dentro la classe Aapprofondimento
public class FirstComparableNested {
	public static void main(String[] args) {
		final List<Person> list = new ArrayList<Person>();
		list.add(new Person("Mario", 1960, true));
		list.add(new Person("Gino", 1970, false));
		list.add(new Person("Rino", 1951, true));
		System.out.println(list);

		Collections.sort(list, new AgeComparator());
		System.out.println(list);

		Collections.sort(list, new AgeComparator().reversed());
		System.out.println(list);

		Collections.sort(list, new NameComparator());
		System.out.println(list);
	}

	private static class AgeComparator implements Comparator<Person> {
		@Override
		public int compare(Person o1, Person o2) {
			return Integer.compare(o1.getYearOfBirth(), o2.getYearOfBirth());
		}
	}
	
	private static class NameComparator implements Comparator<Person> {
		@Override
		public int compare(Person o1, Person o2) {
			return o1.getName().compareTo(o2.getName());
		}
	}
}

Comparatori definiti attraverso classi anonime

  • Una classe anonima è una classe definita “al volo” (senza fornirne dunque un nome) e immediatamente istanziata per evitare la proliferazione di classi – approfondimento
    • Tipicamente: per implementare “al volo” una interfaccia
public class FirstComparableWithAnonymousClasses {
	public static void main(String[] args) {
		final List<Person> list = new ArrayList<Person>();
		list.add(new Person("Mario", 1960, true));
		list.add(new Person("Gino", 1970, false));
		list.add(new Person("Rino", 1951, true));
		System.out.println(list);

		Comparator<Person> ageComparator = new Comparator<Person>() {
			@Override
			public int compare(Person o1, Person o2) {
				return Integer.compare(o1.getYearOfBirth(), o2.getYearOfBirth());
			}
		};
		Collections.sort(list, ageComparator);
		System.out.println(list);

		Collections.sort(list, ageComparator.reversed());
		System.out.println(list);

		Collections.sort(list, new Comparator<Person>() {
			@Override
			public int compare(Person o1, Person o2) {
				return o1.getName().compareTo(o2.getName());
			}
		});
		System.out.println(list);
	}
}

Comparatori attraverso lambda

  • Una lambda è una funzione anonima, creata “al volo”
    • internamente implementata come istanza di un’interfaccia funzionale (un’interfaccia che definisce un solo metodo astratto)
public class FirstComparableWithLambdas {
	public static void main(String[] args) {
		final List<Person> list = new ArrayList<Person>();
		list.add(new Person("Mario", 1960, true));
		list.add(new Person("Gino", 1970, false));
		list.add(new Person("Rino", 1951, true));
		System.out.println(list);

		Collections.sort(list, 
			(Person o1, Person o2) -> Integer.compare(o1.getYearOfBirth(), o2.getYearOfBirth()));
		System.out.println(list);

		Collections.sort(list, 
			(o1, o2) -> { return Integer.compare(o2.getYearOfBirth(), o1.getYearOfBirth()); } );
		System.out.println(list);

		Collections.sort(list, (o1, o2) -> o1.getName().compareTo(o2.getName()));
		System.out.println(list);
	}
}

Lambda expressions

Elementi delle lambda expression

Che cos’è una lambda

  • è una funzione (anonima) con accesso ad uno scope locale (cf. closure)
  • è applicabile a certi input, e dà un risultato (oppure void)
    • per calcolare il risultato potrebbe usare qualche variabile nello scope in cui è definita
  • la lambda è usabile come “valore” (quindi, come dato), ossia è passabile a metodi, altre funzioni, o memorizzata in variabili/campi
    • ossia, consente di “passare” del “codice”

Caratteristica specifica di Java

  • come vedremo, una lambda è un oggetto, e il suo tipo è sempre quello di una interface detta “funzionale”
  • metodi statici o istanza possono essere usati a mo’ di lambda (chiamati “method reference”, perché sono interpretabili come funzioni (come i delegate di C#)

Come si esprime una lambda

Sintassi possibili

  • (T1 x1, ..., Tn xn) -> { <body> }
  • (x1, ..., xn) -> { <body> }
  • x -> { <body> }
  • (T1 x1,..,Tn xn) -> <exp>
  • (x1,..,xn) -> <exp>
  • x -> <exp>
  • .. oppure un “method reference” (si veda in seguito)

Ossia:

  • Per gli argomenti si può esprimere un tipo o può essere inferito (type inference)
  • Con un argomento, le parentesi tonde sono omettibili
  • Il body può essere direttamente una singola espressione/istruzione

Esempi di Lambda

public class FilteringWithLambdas {
	public static void main(String[] args) {
		final List<String> list = Arrays.asList("foo", "bar", "foobar!", "AAAAAAA", "!!!");

		var l1 = new ArrayList<String>(list);
		l1.removeIf((String s) -> { return s.length() > 3; });
		System.out.println(l1); // [foo, bar, !!!]

		var l2 = new ArrayList<String>(list);
		l2.removeIf(s -> { return s.startsWith("f"); });
		System.out.println(l2); // [bar, AAAAAAA, !!!]

		var l3 = new ArrayList<String>(list);
		l3.removeIf((String s) -> s.contains("!"));
		System.out.println(l3);

		var l4 = new ArrayList<String>(list);
		java.util.function.Predicate<String> p = s -> s.matches("(foo)|(bar)");
		l4.removeIf(p.negate());
		System.out.println(l4);
	}
}

Come si esprime una lambda: i method reference

Sintassi possibili

  • <class>::<static-method>
    • sta per (x1, ..., xn) -> <class>.<static-method>(x1, ..., xn)
  • <class>::<instance-method>
    • sta per (x1, ..., xn) -> x1.<instance-method>(x2, ..., xn)
  • <obj>::<method>
    • sta per (x1, ..., xn) -> <obj>.<method>(x1, ..., xn)
  • <class>::new
    • sta per (x1, ..., xn) -> new <class>(x1, ..., xn)

Ossia:

  • Descrivibile come metodo (statico o non), o costruttore..
  • Usabile “naturalmente” (e opzionalmente) quando la lambda non fa altro che chiamare un metodo usando “banalmente” i suoi input e restituendo il suo “output”

Esempi di Method Reference (casi 1,2,3)

public class AllLambdas2 {
	private static int staticMyCompare(final String a, final String b) {
		return a.compareTo(b);
	}

	private int instanceMyCompare(final String a, final String b) {
		return b.compareTo(a);
	}
	
	public static void main(String[] args) {
		final List<String> list = Arrays.asList("a", "bb", "c", "ddd");
		final AllLambdas2 objAL = new AllLambdas2();

		Collections.sort(list, (x,y) -> staticMyCompare(x,y));
		Collections.sort(list, AllLambdas2::staticMyCompare);  // same as above
		System.out.println(list); // [a, bb, c, ddd]

		Collections.sort(list, (x,y) -> objAL.instanceMyCompare(x, y));
		Collections.sort(list, objAL::instanceMyCompare);  // same as above
		System.out.println(list); // [ddd, c, bb, a]
		
		Collections.sort(list, (x,y) -> x.compareTo(y));
		Collections.sort(list, String::compareTo);  // same as above
		System.out.println(list); // [ddd, c, bb, a]
	}
}

Dove si può usare una lambda?

Definizione di interfaccia “funzionale”

  • E’ una interface con un singolo metodo astratto
    • Ma potrebbero esserci più metodi di default (cf. keyword default)

Quale tipo è compatibile con una lambda?

  • Una lambda può essere passata dove ci si attende un oggetto che implementi una interfaccia funzionale
    • C’è compatibilità se i tipi in input/output della lambda (inferiti o non) sono compatibili con quelli dell’unico metodo dell’interfaccia

Motivazione:

  • Di fatto, il compilatore traduce la lambda nella creazione di un oggetto di una classe anonima che implementa l’interfaccia funzionale
    • Uno specifico opcode a livello di bytecode evita di costruirsi effettivamente un .class per ogni lambda

Generazione automatica della classe anonima

public class FirstComparable2 {
	public static void main(String[] args) {
		final List<Person> list = new ArrayList<Person>();
		list.add(new Person("Mario", 1960, true));
		list.add(new Person("Gino", 1970, false));
		list.add(new Person("Rino", 1951, true));
		System.out.println(list);

		// Sorting with a lambda
		Collections.sort(list, (p1, p2) -> Integer.compare(p2.getYear(), p1.getYear()));
		System.out.println(list);

		// Nota che sort richiede un Comparator<Persona>, che ha il solo metodo:
		// int compare(Persona p1, Persona p2)
		// Quindi il codice equivalente generato da javac è:
		Collections.sort(list, new Comparator<Person>() {
			public int compare(Person p1, Person p2) {
				return Integer.compare(p2.getYear(), p1.getYear());
			}
		});
		System.out.println(list);
	}
}

Esempio: funzione riusabile di filtraggio

// Similar to java.util.functions.Predicate<T>
public interface Filter<X> {
	boolean applyFilter(X x); // Does element x pass the filter?
}
public class FilterUtility {
	public static <X> Set<X> filterAll(Collection<X> set, Filter<X> filter) {
		final Set<X> newSet = new HashSet<>();
		for (final X x : set) {
			if (filter.applyFilter(x)) { newSet.add(x); }
		}
		return newSet;
	}

	public static void main(String[] args) {
		final List<Integer> ls = Arrays.asList(10, 20, 30, 40, 50, 60);

		// Nota che il nome del metodo in Filter non è mai menzionato qui
		System.out.println(filterAll(ls, x -> x > 20)); // [30,40,50,60]
		System.out.println(filterAll(ls, x -> x > 20 && x < 60)); // [30,40,50]
		System.out.println(filterAll(ls, x -> x % 20 == 0)); // [20,40,60]
	}
}

Metodi default nelle interfacce

Da Java 8 è possibile fornire implementazioni ai metodi delle interface

  • sintassi: interface I { ... default int m(){ ... } }
  • significato: non è necessario implementarli nelle sottoclassi

Utilità

  • consente di aggiungere metodi a interfacce senza rompere la compatibilità con classi esistenti che le implementano
  • fornire “behaviour” ereditabile in modalità multipla
  • costruire più facilmente interfacce funzionali: queste devono in effetti avere un unico metodo senza default
  • consente di realizzare il pattern template method solo con interfacce

Esempi di interfacce con metodi di default

  • Iterable, Iterator, Collection, Comparator

Annotazione @FunctionalInterface

  • da usare opzionalmente per interfacce funzionali, affinché il compilatore controlli che l’interfaccia sia funzionale (ossia che vi sia un solo metodo “astratto”)
  • nella Java API viene usata spesso

Lambda expressions nell’API di Java

Interfacce funzionali di libreria – package java.util.function

Perché scriversi una nuova interfaccia funzionale all’occorrenza?

  • Lo si fa solo per rappresentare concetti specifici del dominio
  • Lo si fa se ha metodi default aggiuntivi

In java.util.function vengono fornite varie interfacce “general purpose”

  • Sono tutte funzionali
  • Hanno metodi aggiuntivi default di cui non ci occupiamo
  • Hanno un metodo “astratto” chiamato, a seconda: apply, accept, test o get

Package java.util.function

Interfacce base

  • Consumer<T>: accept:(T)->void
  • Function<T,R>: apply:(T)->R
  • Predicate<T>: test:(T)->boolean
  • Supplier<T>: get:()->T
  • UnaryOperator<T>: apply:(T)->T
    • BiConsumer<T,U>: accept:(T,U)->void
  • BiFunction<T,U,R>: apply:(T,U)->R
  • BinaryOperator<T>: apply:(T,T)->T
  • BiPredicate<T,U>: test:(T,U)->boolean
  • java.lang.Runnable: run:()->void

Altre interfacce (usano i tipi primitivi senza boxing)

  • BooleanSupplier: get:()->boolean
  • IntConsumer: accept:(int)->void

Esempio: funzione riusabile di filtraggio via Predicate

import java.util.*;
import java.util.function.Predicate;

public class FilterUtility2 {
	public static <X> Set<X> filterAll(Collection<X> set, Predicate<X> filter) {
		final Set<X> newSet = new HashSet<>();
		for (final X x : set) {
			if (filter.test(x)) {
				newSet.add(x);
			}
		}
		return newSet;
	}

	public static void main(String[] args) {
		final List<Integer> ls = Arrays.asList(10, 20, 30, 40, 50, 60);

		// Note that the name of the method in Filter is never mentioned here
		System.out.println(filterAll(ls, x -> x > 20)); // [30,40,50,60]
		System.out.println(filterAll(ls, x -> x > 20 && x < 60)); // [30,40,50]
		System.out.println(filterAll(ls, x -> x % 20 == 0)); // [20,40,60]
	}
}

Esempio: comandi “programmati” via Runnable

public class RunnableUtility {
	private static void iterate(final int howMany, final Runnable r) {
		for (int i = 0; i < howMany; i++) {
			r.run();
		}
	}

	private static void batchExecution(final List<Runnable> list) {
		for (final Runnable r : list) {
			r.run();
		}
	}

	public static void main(String[] args) {
		iterate(10, () -> System.out.println("ok"));
		final List<Runnable> list = Arrays.asList(
				() -> System.out.println("a"),
				() -> System.out.println("b"),
				() -> System.out.println("c"),
				() -> System.exit(0)); // Inferenza su asList automatica!
		batchExecution(list);
	}
}

Esempio: funzioni di ordine superiore

  • Le funzioni di ordine superiore sono funzioni che accettano in ingresso funzioni e/o che restituiscono in output funzioni
import java.util.function.*;

Function<Integer, Function<Integer, Integer>> multiplier = x -> (y -> x * y);
Function<Integer, Integer> doubler = multiplier.apply(2);
assertEquals(10, doubler.apply(5));

BiFunction<Predicate<String>, Predicate<String>, Predicate<String>> and = 
  (p1, p2) -> (s -> p1.test(s) && p2.test(s));
Predicate<String> p = and.apply(s -> s.length() < 5, s -> s.toUpperCase().equals(s));
assertFalse(p.test("abc"));
assertFalse(p.test("ABCDEFG"));
assertTrue(p.test("ABC"));

Motivazioni e vantaggi nell’uso delle lambda in Java

Elementi di programmazione funzionale in Java

  • Le lambda consentono di aggiungere certe funzionalità della programmazione funzionale in Java, creando quindi una contaminazione OOP + FP
  • Il principale uso è quello che concerne la creazione di funzionalità (metodi) ad alto riuso – ad esempio filterAll
  • Tali metodi possono prendere in ingresso funzioni, passate con sintassi semplificata rispetto a quella delle classi anonime, rendendo più “naturale” e agevole l’uso di questo meccanismo

Miglioramento alle API di Java

  • Concetto di Stream<T> e sue manipolazioni, per lavorare su dati sequenziali (collezioni, file, …)
  • Supporto più diretto ad alcuni pattern: Command, Strategy, Observer
  • Alcune migliorie “varie” nelle API

Interfaccia java.util.Map – metodi aggiuntivi

public interface Map<K,V> {
    ...
    
    default V getOrDefault(Object key, V defaultValue) {..}

    default void forEach(BiConsumer<? super K, ? super V> action) {..}
    
    default void replaceAll(BiFunction<? super K, ? super V, ? extends V> function) {..}
    
    default V putIfAbsent(K key, V value) {..}
    
    default boolean remove(Object key, Object value) {..}
    
    default boolean replace(K key, V oldValue, V newValue) {..}
    
    default V replace(K key, V value) {..}
    
    default V computeIfAbsent(K key, Function<? super K, ? extends V> mappingFunction) {..}
    
    default V computeIfPresent(K key, BiFunction<? super K, ? super V, ? extends V> remappingFunction) {..}
    
    default V compute(K key, BiFunction<? super K, ? super V, ? extends V> remappingFunction) {..}
    
    default V merge(K key, V value, BiFunction<? super V, ? super V, ? extends V> remappingFunction) {..}
}

Esempi su Map

public class UseMap {
	public static void main(String[] args) {
		final Map<Integer, String> map = new HashMap<>();
		map.put(10, "a");
		map.put(20, "bb");
		map.put(30, "ccc");

		map.forEach((k, v) -> System.out.println(k + " " + v));

		map.replaceAll((k, v) -> v + k); // nuovi valori
		System.out.println(map);
		// {20=bb20, 10=a10, 30=ccc30}

		map.merge(5, ".", String::concat);
		map.merge(10, ".", String::concat);
		System.out.println(map);
		// {20=bb20, 5=., 10=a10., 30=ccc30}

		System.out.println(map.getOrDefault(5, "no")); // "."
		System.out.println(map.getOrDefault(6, "no")); // "no"
	}
}

Il concetto di Stream

Idee

  • Uno Stream rappresenta un flusso sequenziale (anche infinito) di dati omogenei, usabile una volta sola, e dal quale si vuole ottenere una informazione complessiva e/o aggregata
  • Assomiglia al concetto di Iteratore, ma lo Stream è più dichiarativo, perché non indica passo-passo come l’informazione viene processata, e quindi è concettualmente più astratto
  • Ove possibile, uno Stream manipola i suoi elementi in modo “lazy” (ritardato): i dati vengono processati mano a mano che servono, non sono memorizzati tutti assieme come nelle Collection
  • E’ possibile creare “catene” di trasformazioni di Stream (implementate con decorazioni successive) in modo funzionale, per ottenere flessibilmente computazioni non banali dei loro elementi, con codice più compatto e leggibile
  • Questa modalità di lavoro rende le computazioni (automaticamente) parallelizzabili, ossia computabili da un set arbitrario di Thread

Computazioni con gli Stream

Struttura a pipeline

  • Una sorgente o sink:
    • Una Collection/array, un dispositivo di I/O, una funzione generatrice
  • Una sequenza di trasformazioni:
    • mappe e filtri, ma non solo..
  • Un terminatore, che aggrega i dati dello Stream:
    • una riduzione ad un valore, una Collection/array, un Iteratore

Esempio: con persone con nome, città e reddito

  • Data una List<Persona> con proprietà reddito e città, ottenere la somma dei redditi di tutte le persone di Cesena
  • Come lo realizziamo tramite una pipeline?
    • Sorgente: la lista
    • Trasformazione 1: filtro sulle persone di Cesena
    • Trasformazione 2: si mappa ogni persona sul suo reddito
    • Terminazione: sommo
  • Aspetto cruciale: le fasi intermedie (dopo le trasformazioni), non generano collezioni temporanee

Classe Personequals, hashCode e toString omessi

public class Person {
	private final String name;
	private final Optional<String> city;
	private final double income;
	private final Set<String> jobs;
	
	public Person(String name, String city, double income, String... jobs) {
		this.name = Objects.requireNonNull(name);
		this.city = Optional.ofNullable(city); // null in ingresso indica città assente
		this.income = income;
		this.jobs = new HashSet<>(Arrays.asList(jobs)); // conversione a set
	}

	public String getName() {
		return this.name;
	}

	public Optional<String> getCity() {
		return this.city;
	}

	public double getIncome() {
		return this.income;
	}
	
	public Set<String> getJobs(){
		return Collections.unmodifiableSet(this.jobs);	// copia difensiva
	}
	
	//.. seguono hashCode, equals e toString

Realizzazione dell’esempio in Java 8

public class UseStreamsOnPerson {
	public static void main(String[] args) {
		final List<Person> list = new ArrayList<>();
		list.add(new Person("Mario","Cesena",20000,"Teacher"));
		list.add(new Person("Rino","Forlì",50000,"Professor"));
		list.add(new Person("Lino","Cesena",110000,"Professor","Dean"));
		list.add(new Person("Ugo","Cesena",20000,"Secretary"));
		list.add(new Person("Marco",null,4000,"Contractor"));
		
		final double result = list.stream()
								  .filter(p->p.getCity().isPresent())
								  .filter(p->p.getCity().get().equals("Cesena"))
								  .mapToDouble(Person::getIncome)
								  .sum();
	
		System.out.println(result);
		
		// alternativa con iteratore: qual è la più leggibile?
		double res2 = 0.0;
		for (final Person p: list){
			if (p.getCity().isPresent() && p.getCity().get().equals("Cesena")){
				System.out.println(p);
				res2 = res2 + p.getIncome();
			}
		}
		System.out.println(res2);
	}
}   

Questa è solo una introduzione alle lambda in Java. Nel blocco di approfondimento si trovano ulteriori informazioni su come:

  • creare stream
  • manipolazioni di stream (avanzate)
  • esempi di uso di stream