Programmazione funzionale in Java puro: Functor and Monad Examples

Questo articolo era inizialmente un’appendice nella nostra Programmazione reattiva con RxJava libro. Tuttavia, un’introduzione alle monadi, anche se molto legata alla programmazione reattiva, non si adattava molto bene. Così ho deciso di tirarlo fuori e pubblicare questo separatamente come post sul blog. Sono consapevole che ” la mia stessa spiegazione, metà corretta e metà completa delle monadi “è il nuovo” Ciao, mondo ” sui blog di programmazione. Tuttavia, l’articolo esamina funtori e monadi da un angolo specifico delle strutture e delle librerie di dati Java. Così ho pensato che valesse la pena di condividere.

RxJava è stato progettato e costruito sulla base di concetti fondamentali come funtori, monoidi e monadi. Anche se Rx è stato modellato inizialmente per il linguaggio C # imperativo e stiamo imparando a conoscere RxJava, lavorando su un linguaggio altrettanto imperativo, la libreria ha le sue radici nella programmazione funzionale. Non dovresti essere sorpreso dopo aver capito quanto sia compatta l’API RxJava. Ci sono praticamente solo una manciata di classi principali, in genere immutabili, e tutto è composto usando principalmente funzioni pure.

Con una recente ascesa della programmazione funzionale (o stile funzionale), più comunemente espressa in linguaggi moderni come Scala o Clojure, le monadi divennero un argomento ampiamente discusso. C’è molto folklore intorno a loro:

Una monade è un monoide nella categoria degli endofuntori, qual è il problema?

James Iry

La maledizione della monade è che una volta ottenuta l’epifania, una volta capito – “oh questo è quello che è” – si perde la capacità di spiegarlo a chiunque.

Douglas Crockford

La stragrande maggioranza dei programmatori, specialmente quelli senza un background di programmazione funzionale, tendono a credere che le monadi siano un arcano concetto di informatica, così teorico che non può aiutare nella loro carriera di programmazione. Questa prospettiva negativa può essere attribuita a dozzine di articoli e post di blog troppo astratti o troppo stretti. Ma si scopre che le monadi sono tutto intorno a noi, anche in una libreria Java standard, soprattutto perché Java Development Kit (JDK) 8 (ne parleremo più avanti). Ciò che è assolutamente geniale è che una volta comprese le monadi per la prima volta, improvvisamente diverse classi e astrazioni non correlate, che servono scopi completamente diversi, diventano familiari.

Le monadi generalizzano vari concetti apparentemente indipendenti in modo che l’apprendimento di un’altra incarnazione della monade richieda pochissimo tempo. Ad esempio, non devi imparare come completabilefuture funziona in Java 8 – una volta che ti rendi conto che è una monade, sai esattamente come funziona e cosa puoi aspettarti dalla sua semantica. E poi senti parlare di RxJava che suona molto diverso, ma perché Observable è una monade, non c’è molto da aggiungere. Ci sono numerosi altri esempi di monadi che hai già incontrato senza saperlo. Pertanto, questa sezione sarà un aggiornamento utile anche se non si riesce a utilizzare effettivamente RxJava.

Funtori

Prima di spiegare cos’è una monade, esploriamo un costrutto più semplice chiamato funtore . Un functor è una struttura di dati tipizzata che incapsula alcuni valori. Da una prospettiva sintattica un functor è un contenitore con la seguente API:

import java.util.function.Function;interface Functor<T> { <R> Functor<R> map(Function<T, R> f);}

Ma la semplice sintassi non è sufficiente per capire cos’è un functor. L’unica operazione fornita dal functor è map () che accetta una funzione f. Questa funzione riceve tutto ciò che è all’interno di una scatola, la trasforma e avvolge il risultato così com’è in un secondo functor. Si prega di leggere attentamente. Functor < T > è sempre un contenitore immutabile, quindi map non muta mai l’oggetto originale su cui è stato eseguito. Invece, restituisce il risultato(o risultati – sii paziente) avvolto in un funtore nuovo di zecca, possibilmente di tipo diverso R. Inoltre i funtori non dovrebbero eseguire alcuna azione quando viene applicata la funzione identity, ovvero map (x -> x). Tale modello dovrebbe sempre restituire lo stesso functor o un’istanza uguale.

Spesso Functor < T > viene confrontato con un’istanza di T in cui l’unico modo di interagire con questo valore è trasformarlo. Tuttavia, non esiste un modo idiomatico di scartare o fuggire dal funtore. I valori rimangono sempre nel contesto di un funtore. Perché i funtori sono utili? Generalizzano più idiomi comuni come collezioni, promesse, optionals, ecc. con una singola API uniforme che funziona su tutti. Permettetemi di presentarvi un paio di funtori per rendervi più fluenti con questa API:

interface Functor<T,F extends Functor<?,?>> { <R> F map(Function<T,R> f);}class Identity<T> implements Functor<T,Identity<?>> { private final T value; Identity(T value) { this.value = value; } public <R> Identity<R> map(Function<T,R> f) { final R result = f.apply(value); return new Identity<>(result); }}

È stato necessario un parametro di tipo F aggiuntivo per la compilazione dell’identità. Quello che hai visto nell’esempio precedente era il funtore più semplice che teneva solo un valore. Tutto ciò che puoi fare con quel valore è trasformarlo all’interno del metodo map, ma non c’è modo di estrarlo. Questo è considerato oltre lo scopo di un funtore puro. L’unico modo per interagire con functor è applicando sequenze di trasformazioni sicure per il tipo:

Identity<String> idString = new Identity<>("abc");Identity<Integer> idInt = idString.map(String::length);

O fluentemente, proprio come componi le funzioni:

Identity<byte> idBytes = new Identity<>(customer) .map(Customer::getAddress) .map(Address::street) .map((String s) -> s.substring(0, 3)) .map(String::toLowerCase) .map(String::getBytes);

Da questa prospettiva la mappatura su un functor non è molto diversa dal semplice richiamo di funzioni concatenate:

byte bytes = customer .getAddress() .street() .substring(0, 3) .toLowerCase() .getBytes();

Perché dovresti nemmeno preoccuparti di un wrapping così dettagliato che non solo non fornisce alcun valore aggiunto, ma non è nemmeno in grado di estrarre il contenuto? Bene, si scopre che puoi modellare diversi altri concetti usando questa astrazione del functor grezzo. Ad esempio a partire da Java 8 Opzionale è un functor con il metodo map (). Cerchiamo di implementare da zero:

class FOptional<T> implements Functor<T,FOptional<?>> { private final T valueOrNull; private FOptional(T valueOrNull) { this.valueOrNull = valueOrNull; } public <R> FOptional<R> map(Function<T,R> f) { if (valueOrNull == null) return empty(); else return of(f.apply(valueOrNull)); } public static <T> FOptional<T> of(T a) { return new FOptional<T>(a); } public static <T> FOptional<T> empty() { return new FOptional<T>(null); }}

Ora diventa interessante. Un functor FOptional<T> può contenere un valore, ma altrettanto bene potrebbe essere vuoto. È un modo sicuro per codificare null. Esistono due modi per costruire FOptional: fornendo un valore o creando un’istanza empty(). In entrambi i casi, proprio come con Identity,FOptional è immutabile e possiamo interagire solo con il valore dall’interno. Ciò che differisceFOptional è che la funzione di trasformazione f non può essere applicata a nessun valore se è vuota. Ciò significa che functor non può necessariamente incapsulare esattamente un valore di tipo T. Può anche avvolgere un numero arbitrario di valori, proprio come List… funtore:

import com.google.common.collect.ImmutableList;class FList<T> implements Functor<T, FList<?>> { private final ImmutableList<T> list; FList(Iterable<T> value) { this.list = ImmutableList.copyOf(value); } @Override public <R> FList<?> map(Function<T, R> f) { ArrayList<R> result = new ArrayList<R>(list.size()); for (T t : list) { result.add(f.apply(t)); } return new FList<>(result); }}

L’API rimane la stessa: prendi un functor in una trasformazione, ma il comportamento è molto diverso. Ora applichiamo una trasformazione su ogni elemento del FList, trasformando dichiarativamente l’intero elenco. Quindi, se hai un elenco di clienti e vuoi un elenco delle loro strade, è semplice come:

import static java.util.Arrays.asList;FList<Customer> customers = new FList<>(asList(cust1, cust2));FList<String> streets = customers .map(Customer::getAddress) .map(Address::street);

Non è più semplice come dire customers.getAddress().street(), non è possibile richiamaregetAddress() su una raccolta di clienti, è necessario richiamare getAddress() su ogni singolo cliente e quindi riporlo in una raccolta. A proposito, Groovy ha trovato questo modello così comune che in realtà ha uno zucchero di sintassi per questo: customer*.getAddress()*.street(). Questo operatore, noto come spread-dot, è in realtà un map sotto mentite spoglie. Forse ti stai chiedendo perché eseguo l’iterazione su list manualmente all’interno di map piuttosto che usare Streams da Java 8:list.stream().map(f).collect(toList())? Ti dice niente? Cosa succede se ti dicessi che java.util.stream.Stream<T> in Java è anche un functor? E a proposito, anche una monade?
Ora dovresti vedere i primi vantaggi dei funtori: astraggono la rappresentazione interna e forniscono API coerenti e facili da usare su varie strutture di dati. Come ultimo esempio permettetemi di introdurre il functor promise, simile a Future. Promise “promette” che un valore diventerà disponibile un giorno. Non è ancora lì, forse perché è stato generato un calcolo in background o stiamo aspettando un evento esterno. Ma apparirà qualche volta in futuro. La meccanica del completamento di unPromise<T> non è interessante, ma la natura del functor è:

Promise<Customer> customer = //...Promise<byte> bytes = customer .map(Customer::getAddress) .map(Address::street) .map((String s) -> s.substring(0, 3)) .map(String::toLowerCase) .map(String::getBytes);

Ti sembra familiare? Questo è il punto! L’implementazione del functor va oltre lo scopo di questo articolo e non è nemmeno importante. Abbastanza per dire che siamo molto vicini all’implementazione di CompletableFuture da Java 8 e abbiamo quasi scoperto Observable da RxJava. Ma torniamo ai funtori. Promessa < Cliente > non detiene un valore del cliente appena ancora. Promette di avere tale valore in futuro. Ma possiamo ancora mappare su tale functor, proprio come abbiamo fatto con FOptional e FList – la sintassi e la semantica sono esattamente le stesse. Il comportamento segue ciò che rappresenta il functor. Invocare cliente.map (Customer::getAddress) produce Promise<Address>, il che significa che map non è bloccante. cliente.mappa () sarà cliente promessa di completare. Invece, restituisce un’altra promessa, di un tipo diverso. Al termine della promessa upstream, la promessa downstream applica una funzione passata a map () e passa il risultato a valle. Improvvisamente il nostro functor ci consente di pipeline di calcoli asincroni in modo non bloccante. Ma non devi capirlo o impararlo – perché Promise è un functor, deve seguire la sintassi e le leggi.

Ci sono molti altri grandi esempi di funtori, ad esempio che rappresentano valore o errore in modo compositivo. Ma è giunto il momento di guardare alle monadi.

Dai Funtori alle Monadi

Presumo che tu capisca come funzionano i funtori e perché sono un’astrazione utile. Ma i funtori non sono così universali come ci si potrebbe aspettare. Cosa succede se la tua funzione di trasformazione (quella passata come argomento a map()) restituisce l’istanza del functor piuttosto che il semplice valore? Bene, un functor è solo un valore, quindi non succede nulla di male. Tutto ciò che è stato restituito viene riportato in un functor in modo che tutto si comporti in modo coerente. Tuttavia immagina di avere questo pratico metodo per analizzare le stringhe:

FOptional<Integer> tryParse(String s) { try { final int i = Integer.parseInt(s); return FOptional.of(i); } catch (NumberFormatException e) { return FOptional.empty(); }}

Le eccezioni sono effetti collaterali che minano il sistema di tipo e la purezza funzionale. Nei linguaggi funzionali puri, non c’è posto per le eccezioni. Dopo tutto, non abbiamo mai sentito parlare di lanciare eccezioni durante le lezioni di matematica, giusto? Gli errori e le condizioni illegali sono rappresentati esplicitamente utilizzando valori e wrapper. Ad esempio tryParse() prende una stringa ma non restituisce semplicemente un int o lancia silenziosamente un’eccezione in fase di runtime. Diciamo esplicitamente, attraverso il sistema di tipi, che tryParse () può fallire, non c’è nulla di eccezionale o errato nell’avere una stringa malformata. Questo semi-errore è rappresentato da un risultato opzionale. È interessante notare che Java ha controllato le eccezioni, quelle che devono essere dichiarate e gestite, quindi in un certo senso, Java è più puro a questo proposito, non nasconde gli effetti collaterali. Ma nel bene e nel male le eccezioni controllate sono spesso scoraggiate in Java, quindi torniamo a tryParse(). Sembra utile comporre tryParse con Stringa già avvolta in FOptional:

FOptional<String> str = FOptional.of("42");FOptional<FOptional<Integer>> num = str.map(this::tryParse);

Questo non dovrebbe essere una sorpresa. Se tryParse() restituirebbe un int si otterrebbeFOptional<Integer> num, ma poiché la funzione map() restituisce FOptional<Integer>stessa, viene avvolta due volte in imbarazzante FOptional<FOptional<Integer>>. Si prega di guardare attentamente i tipi, è necessario capire perché abbiamo ottenuto questo doppio involucro qui. Oltre a sembrare orribile, avere un funtore in funtore rovina la composizione e il concatenamento fluente:

FOptional<Integer> num1 = //...FOptional<FOptional<Integer>> num2 = //...FOptional<Date> date1 = num1.map(t -> new Date(t));//doesn't compile!FOptional<Date> date2 = num2.map(t -> new Date(t));

Qui cerchiamo di mappare il contenuto di FOptional trasformando int in +Date+. Avendo una funzione di int -> Date possiamo facilmente trasformare da Functor<Integer> a Functor<Date>, sappiamo come funziona. Ma nel caso di num2 la situazione diventa complicata. Ciò che num2.map()riceve come input non è più un int ma un FOoption<Integer> e ovviamentejava.util.Date non ha un tale costruttore. Abbiamo rotto il nostro functor avvolgendolo doppio. Tuttavia, avere una funzione che restituisce un functor piuttosto che un semplice valore è così comune (cometryParse()) che non possiamo semplicemente ignorare tale requisito. Un approccio consiste nell’introdurre uno speciale metodo parameterless join() che “appiattisce” i funtori nidificati:

FOptional<Integer> num3 = num2.join()

Funziona ma poiché questo modello è così comune, è stato introdotto un metodo speciale denominato flatMap(). flatMap() è molto simile a map ma si aspetta che la funzione ricevuta come argomento restituisca un functor – o monad per essere precisi:

interface Monad<T,M extends Monad<?,?>> extends Functor<T,M> { M flatMap(Function<T,M> f);}

Abbiamo semplicemente concluso che flatMap è solo uno zucchero sintattico per consentire una migliore composizione. Ma il metodoflatMap (spesso chiamato bind o >>= da Haskell) fa la differenza poiché consente di comporre trasformazioni complesse in uno stile puro e funzionale. Se FOptional era un’istanza di monad, l’analisi funziona improvvisamente come previsto:

FOptional<String> num = FOptional.of("42");FOptional<Integer> answer = num.flatMap(this::tryParse);

Le monadi non hanno bisogno di implementare map, possono essere implementate facilmente su flatMap(). Infatti flatMap è l’operatore essenziale che consente un intero nuovo universo di trasformazioni. Ovviamente, proprio come con i funtori, la conformità sintattica non è sufficiente per chiamare una monade di classe a, l’operatore flatMap() deve seguire le leggi della monade, ma sono abbastanza intuitive come l’associatività di flatMap() e l’identità. Quest’ultimo richiede che m(x).flatMap(f) sia lo stesso dif(x) per qualsiasi monade con un valore x e qualsiasi funzione f. Non ci immergeremo troppo in profondità nella teoria della monade, concentriamoci invece sulle implicazioni pratiche. Le monadi brillano quando la loro struttura interna non è banale, ad esempio Promise monade che manterrà un valore in futuro. Riesci a indovinare dal sistema di tipi come Promise si comporterà nel seguente programma? Innanzitutto, tutti i metodi che possono potenzialmente richiedere del tempo per completare restituiscono a Promise:

import java.time.DayOfWeek;Promise<Customer> loadCustomer(int id) { //...}Promise<Basket> readBasket(Customer customer) { //...}Promise<BigDecimal> calculateDiscount(Basket basket, DayOfWeek dow) { //...}

Ora possiamo comporre queste funzioni come se fossero tutte bloccanti usando operatori monadici:

Promise<BigDecimal> discount = loadCustomer(42) .flatMap(this::readBasket) .flatMap(b -> calculateDiscount(b, DayOfWeek.FRIDAY));

Questo diventa interessante. flatMap() deve preservare il tipo monadico quindi tutti gli oggetti intermedi sono Promise s. Non si tratta solo di mantenere i tipi in ordine – il programma precedente è improvvisamente completamente asincrono! loadCustomer() restituisce un Promise in modo che non si blocchi. readBasket() prende tutto ciò che Promise ha (avrà) e applica una funzione restituendo un’altra Promise e così via e così via. Fondamentalmente, abbiamo costruito una pipeline di calcolo asincrona in cui il completamento di un passaggio in background attiva automaticamente il passaggio successivo.

Exploring flatMap ()

È molto comune avere due monadi e combinare il valore che racchiudono insieme. Tuttavia, sia i funtori che le monadi non consentono l’accesso diretto ai loro interni, il che sarebbe impuro. Invece, dobbiamo applicare attentamente la trasformazione senza sfuggire alla monade. Immagina di avere due monadi e vuoi combinarle:

import java.time.LocalDate;import java.time.Month;Monad<Month> month = //...Monad<Integer> dayOfMonth = //...Monad<LocalDate> date = month.flatMap((Month m) -> dayOfMonth .map((int d) -> LocalDate.of(2016, m, d)));

Si prega di prendere il vostro tempo per studiare il precedente pseudo-codice. Non uso alcuna implementazione monad reale come Promise o List per enfatizzare il concetto di base. Abbiamo due monadi indipendenti, una di tipo Monthe l’altra di tipo Integer. Per costruire LocalDate da loro, dobbiamo costruire una trasformazione nidificata che abbia accesso agli interni di entrambe le monadi. Lavora attraverso i tipi, specialmente assicurandoti di capire perché usiamo flatMap in un posto emap() nell’altro. Pensa a come struttureresti questo codice se avessi anche un terzo Monad<Year>. Questo modello di applicazione di una funzione di due argomenti (m e d nel nostro caso) è così comune che in Haskell esiste una funzione di supporto speciale chiamata liftM2 che fa esattamente questa trasformazione, implementata su map e flatMap. Nella pseudo-sintassi Java sarebbe simile a questo:

Monad<R> liftM2(Monad<T1> t1, Monad<T2> t2, BiFunction<T1, T2, R> fun) { return t1.flatMap((T1 tv1) -> t2.map((T2 tv2) -> fun.apply(tv1, tv2)) );}

Non è necessario implementare questo metodo per ogni monade, flatMap() è sufficiente, inoltre, funziona in modo coerente per tutte le monadi. liftM2 è estremamente utile se si considera come può essere utilizzato con varie monadi. Ad esempio, listM2(list1, list2, function) applicherà function su ogni possibile coppia di elementi da list1 e list2 (prodotto cartesiano). D’altra parte, per gli optionals applicherà una funzione solo quando entrambi gli optionals non sono vuoti. Ancora meglio, per una monade Promise una funzione verrà eseguita in modo asincrono quando entrambi i Promise sono completati. Ciò significa che abbiamo appena inventato un semplice meccanismo di sincronizzazione (join() negli algoritmi fork-join) di due passaggi asincroni.

Un altro operatore utile che possiamo facilmente costruire su flatMap()è filter(Predicate<T>) che prende tutto ciò che è all’interno di una monade e lo scarta completamente se non soddisfa determinati predicati. In un certo senso, è simile a map ma piuttosto che la mappatura 1-a-1 abbiamo 1-a-0-o-1. Di nuovo filter() ha la stessa semantica per ogni monade ma una funzionalità piuttosto sorprendente a seconda di quale monade usiamo effettivamente. Ovviamente, consente di filtrare determinati elementi da un elenco:

FList<Customer> vips = customers.filter(c -> c.totalOrders > 1_000);

Ma funziona altrettanto bene, ad esempio per gli optionals. In tal caso, possiamo trasformare l’facoltativo non vuoto in uno vuoto se il contenuto dell’facoltativo non soddisfa alcuni criteri. Gli optionals vuoti sono lasciati intatti.

Dalla lista delle Monadi alla Monade della lista

Un altro operatore utile che ha origine da flatMap() è sequence(). Puoi facilmente indovinare cosa fa semplicemente guardando la firma del tipo:

Monad<Iterable<T>> sequence(Iterable<Monad<T>> monads)

Spesso abbiamo un gruppo di monadi dello stesso tipo e vogliamo avere una singola monade di una lista di quel tipo. Questo potrebbe sembrare astratto a voi, ma è incredibilmente utile. Immagina di voler caricare alcuni clienti dal database contemporaneamente per ID, quindi hai usato il metodo loadCustomer(id)più volte per ID diversi, ogni chiamata restituendo Promise<Customer>. Ora hai un elenco di Promise s ma quello che vuoi veramente è un elenco di clienti, ad esempio da visualizzare nel browser Web. L’operatore sequence() (in RxJava sequence() è chiamato concat() o merge(), a seconda del caso d’uso) è costruito solo per questo:

FList<Promise<Customer>> custPromises = FList .of(1, 2, 3) .map(database::loadCustomer);Promise<FList<Customer>> customers = custPromises.sequence();customers.map((FList<Customer> c) -> ...);

Avere un FList<Integer> che rappresenta gli ID cliente map su di esso (vedi come aiuta che FList è un functor?) chiamando database.loadCustomer(id) per ogni ID. Ciò porta a una lista piuttosto scomoda di Promise s. sequence() salva la giornata, ma ancora una volta questo non è solo uno zucchero sintattico. Il codice precedente è completamente non bloccante. Per diversi tipi di monadi sequence() ha ancora senso, ma in un contesto computazionale diverso. Ad esempio, può cambiare FList<FOptional<T>> in FOptional<FList<T>>. E a proposito, puoi implementaresequence() (proprio come map()) in cima a flatMap().

Questa è solo la punta dell’iceberg quando si tratta dell’utilità di flatMap() e delle monadi in generale. Nonostante provenga da una teoria di categoria piuttosto oscura, le monadi si sono dimostrate un’astrazione estremamente utile anche nei linguaggi di programmazione orientati agli oggetti come Java. Essere in grado di comporre funzioni che restituiscono monadi è così universalmente utile che dozzine di classi non correlate seguono il comportamento monadico.

Inoltre, una volta incapsulati i dati all’interno di una monade, è spesso difficile estrarli esplicitamente. Tale operazione non fa parte del comportamento della monade e spesso porta a codice non idiomatico. Ad esempio, Promise.get() su Promise<T> può tecnicamente restituire T, ma solo bloccando, mentre tutti gli operatori basati su flatMap() non sono bloccanti. Un altro esempio è FOptional.get(), ma può fallire perché FOptional potrebbe essere vuoto. Anche FList.get(idx) che fa capolino un particolare elemento da una lista sembra imbarazzante perché puoi sostituire for loop con map() abbastanza spesso.

Spero che tu ora capisca perché le monadi sono così popolari in questi giorni. Anche in un linguaggio orientato agli oggetti (- ish) come Java, sono un’astrazione abbastanza utile.

Lascia un commento

Il tuo indirizzo email non sarà pubblicato.