Monadi in dettaglio – Versione Haskell
Giuseppe Maggiore, Ph.D. Student
Chapter 1 Exception Handling
Vogliamo gestire i possibili errori che le varie porzioni del nostro programma potrebbero generare. Consideriamo l’approccio ormai standard delle eccezioni, con un programma in pseudo-codice á-la-Java/C#:







In un linguaggio imperativo sappiamo che le funzioni usate possono tutte lanciare un’eccezione, anche se a guardarne il tipo non si direbbe:


Nessuna di queste funzioni ritorna esplicitamente un’eccezione, e infatti a meno di non sapere cosa accade “di nascosto” pare proprio che tutte queste funzioni restituiscano un valore corretto in qualsiasi condizione vengano chiamate. Ovviamente ció non é vero: che intero potrebbe venire restituito da
? Cosa ritorna
quando il file é terminato? Come deve comportarsi
se la stringa non rappresenta un file esistente?
Tutti i comportamenti sbagliati fanno si che le nostre funzioni lancino una eccezione ogni volta che non é possibile ritornare un risultato sensato. La firma completa delle funzioni che stiamo considerando é quindi:


Siccome peró tipicamente tali funzioni vengono chiamate con parametri corretti, e gli errori rappresentano un comportamento inusuale (appunto, “eccezionale”), facciamo si che la gestione degli errori avvenga da un’altra parte rispetto al codice che potrebbe generarla. In Haskell saremmo costretti a ritornare valori da un tipo unione, in cui un costruttore rappresenta l’esecuzione corretta (contenente il risultato desiderato) e un altro costruttore rappresenta l’esecuzione con errori e la descrizione dell’errore avvenuto:
Le firme delle nostre funzioni sono ora modificate in:


Il vantaggio immediato é che ora risulta evidente da dove possono uscire errori, e una semplice ispezione del tipo della funzione ci dice subito che il risultato potrebbe essere un descrittore di errore invece del risultato desiderato. Inoltre il tipo rappresenta piú accuratamente il comportamento della funzione, e la semantica del linguaggio é piú chiara: non c’é alcun misterioso meccanismo con cui le eccezioni vengono propagate “sotto le coperte”. Nonostante i vantaggi di questo metodo, c’é una evidente ragione per cui non si usa questo sistema in linguaggi come Java: ne risulta codice illeggibile. Ogni assegnamento nel codice originale diventerebbe un pattern-matching (o un if). Il codice Haskell che realizza il programma iniziale sarebbe infatti:









In questo listato la logica originale é completamente sepolta sotto tonnellate di gestione degli errori, ed effettivamente non si capisce quasi nulla di quello che accade. Fortunatamente possiamo osservare un aspetto ripetitivo di questa modalitá di scrittura del codice, nella speranza di poterlo astrarre via:
Lo stesso é accaduto per trasformare GetLine e int.Parse in un sistema con eccezioni esplicite. Questo suggerisce che potremmo definire un operatore che prende un risultato di tipo Result a, controlla che il risultato sia corretto (non un’eccezione) e se lo é passa il valore contenuto in esso al resto del programma, e altrimenti propaga l’errore:


Grazie a questo operatore (detto operatore di binding) valutiamo un’espressione che potrebbe dare errori, e in caso di errore ne propaghiamo la descrizione, altrimenti proseguiamo con la valutazione del resto del programma passandogli il risultato della valutazione dell’espressione. L’operatore di binding rende il codice Haskell notevolmente piú leggibile, e la gestione delle eccezioni é adesso generalizzata:






Le differenze tra questo listato e quello iniziale sono:
1. L’assegnamento é verso destra, ossia file=Open(…) diventa
2. Il resto del codice é diventato una funzione che prende come parametro il valore assegnato; questo sembra strano di primo acchito, ma in effetti ogni volta che si assegna una variabile, il codice successivo che la usa puó essere visto come una funzione che aspetta il valore di quella variabile; in effetti sappiamo che
3. Il try…catch… é diventato
4. Ritornare il risultato finale richiede di incapsularlo nel costruttore Value
I punti 2 e 3 sono accettabili, mentre i punti 1 e 4 lo sono assai di meno: assegnare verso destra é decisamente spiacevole, specialmente considerate le nostre abitudini; inoltre costruire un valore non dovrebbe mai richiedere di incapsularlo in un tipo apposito. Fortunatamente da un po’ di tempo alcuni linguaggi avanzati hanno cominciato a supportare una sintassi apposita per questa classe di costrutti (in particolare Haskell e F#). Siccome in realtá le comprensioni di liste, come vedremo piú avanti, sono un caso particolare di tali costrutti, si puó scoprire che moltissimi linguaggi sono parzialmente equipaggiati con una sintassi per realizzare queste funzionalitá.
La soluzione all’”inestetismo” introdotto dal punto 1 é presto detta: la do-notation di Haskell (per F# guardare i Workflows). Il compilatore ci tradurrá

In
La “bruttura” del punto 4 invece si risolve con un altro operatore, comunemente definito assieme all’operatore di binding: l’operatore unitario:

In questo modo ogni volta che arriviamo al valore “finale” da restituire, possiamo farlo con la (ormai consolidata nell’immaginario comune) keyword return.
Adesso possiamo definire un’utile funzione:


Arrivati a questo punto possiamo confrontare il codice iniziale con una traduzione che sfrutti tutti i costrutti introdotti finora:
Ora vediamo che le differenze sono decisamente marginali: la forma del codice é notevolmente simile, a parte qualche simbolo diverso (= invece di ←) e il gestore delle eccezioni é esplicitamente definito come una funzione che prende come argomento l’eccezione stessa, invece che un blocco di codice che ha la variabile di tipo eccezione in scope.
Chapter 2 Monadi
Finora abbiamo definito in maniera piuttosto informale una serie di tipi di dato e di operatori per la gestione degli errori. In questo modo (pur senza accorgercene, ma qualche lettore accorto lo sospettava giá da tempo) abbiamo realizzato la nostra prima monade. Una monade é definita come una terna:
|
|
Monade
|
Exceptions Monad
|
|
Tipo monadico
|

|

|
|
Operatore di binding
|

|


|
|
Operatore unitario
|

|

|
M a é il costruttore di tipo della monade, ossia il tipo di dato che verrá gestito “dietro le quinte” dagli operatori di binding e unitario. Il tipo di dato M a contiene una sequenza (anche vuota!) di valori di tipo a.
L’operatore di binding opera in quattro stadi:
1. Apre la monade di input, estraendone tutti i valori di tipo a
2. Applica il secondo argomento a ciascuno dei valori generati al punto 1
3. Apre tutte le monadi ottenute al punto 2, ottenendo una serie di valori di tipo b
4. Assembla tutti i valori di tipo b ottenuti al punto 3
L’operatore unitario fa da costruttore della piú “semplice” monade che incapsuli il valore di tipo a che viene passato in input. Naturalmente la semplicitá va intesa in modo appropriato rispetto alla definizione della monade.
Assiomi
Cosa rende una monade una monade vera e propria? Effettivamente per assicurare che una monade abbia un comportamento sensato sono stati definiti degli assiomi che definiscono come si comportano gli operatori monadici in alcuni casi “al limite”:


Gli stessi assiomi possono essere scritti con la notazione do di Haskell:



Oltre agli assiomi una monade puó (opzionalmente) definire uno “zero”, ossia un particolare valore della monade che non contiene al suo interno alcun valore di tipo a. Se la monade definisce uno zero, allora deve essere vero che:

Una monade puó ulteriormente definire un’operazione di somma, ossia un’operazione che dati due valori della monade li componga in uno solo:
La somma monadica é associativa e lo zero ne é identitá destra e sinistra.
Chapter 3 Le monadi come DSL
La monade di gestione degli errori vista finora é una monade semplice e tutto sommato intuitiva. Esistono peró moltissime monadi, in grado di coprire sostanzialmente tutte le computazioni immaginabili.
Le monadi possono essere viste come il modo per implementare esplicitamente la semantica di un qualche linguaggio, che tramite la monade diventa un “sotto-linguaggio” del linguaggio ospite. Tramite le monadi é possibile costruire quelli che vengono comunemente chiamati Linguaggi Specifici di Dominio (DSL, o Domain Specific Language) che permettono di implementare la soluzione ad un qualche problema costruendo un linguaggio di programmazione “ideale” per affrontare tale problema. I DSL dipendono ovviamente dal dominio del problema. Il problema puó essere generale: scrivere programmi concorrenti, paralleli o in stile imperativo. Il problema puó anche essere piú specifico: manipolare liste in modo immediato, nascondere ottimizzazioni algoritmiche, scrivere la logica di una UI in modo dichiarativo. In ogni caso si definiscono le istruzioni del proprio mini-linguaggio, la semantica di tali istruzioni (ossia che effetto ha l’esecuzione di ciascuna istruzione) e le si implementa in una monade.
Nel seguito discuteremo come costruire due monadi estremamente rilevanti: la monade lista e la monade di stato. La monade lista ci permette di rappresentare una lista e le operazioni su di essa in modo estremamente elegante, e di dare una solida base di naturale generale che supporti la sintassi delle list-comprehensions in Haskell. La monade di stato, invece, ci permette di costruire un linguaggio imperativo (“stile C”, per intenderci) come monade dentro Haskell. La monade di stato consente infatti di nascondere le transizioni di stato (un tipo di dato che rappresenta la memoria), poiché in un programma imperativo lo stato viene modificato da una istruzione all’altra, ma sarebbe scocciante e soggetto ad errori portarcelo dietro esplicitamente da un’istruzione alla successiva.
Monade lista
Consideriamo ora un iteratore in un linguaggio imperativo:



Possiamo immaginare il corpo del ciclo for come una funzione che dipende dal valore corrente x. Per ogni elemento della lista l, viene invocata la funzione
. In questa ottica, possiamo dare un tipo all’iteratore (in maniera del tutto informale):
Naturalmente all’iteratore in un linguaggio imperativo non serve ritornare altro che Unit poiché puó accedere alla memoria del processo e modificarla liberamente. Poiché in un linguaggio funzionale come Haskell non ci sono alternative per una funzione se non di ritornare i valori risultato della computazione, un eventuale costrutto di iterazione avrebbe firma:
Nell’iteratore Haskell ogni elemento a della lista genera una lista List b e alla fine tutte le liste risultanti vengono concatenate tra loro. Se diamo una descrizione testuale del comportamento dell’operatore foreach per come lo abbiamo definito in Haskell, tale descrizione sarebbe:
1. Apre la lista di input, estraendone tutti i valori di tipo a
2. Applica il secondo argomento a ciascuno dei valori generati al punto 1
3. Apre tutte le liste ottenute al punto 2, ottenendo una serie di valori di tipo b
4. Concatena tutti i valori di tipo b ottenuti al punto 3
Questa descrizione rispecchia perfettamente la descrizione generale dell’operatore di binding che abbiamo visto nel capitolo Monadi. L’operatore foreach é dunque il candidato ideale per farci da operatore di binding. Questo implica che il tipo della monade M a sará in realtá il tipo List a, che in effetti sappiamo contenere una serie di valori di tipo a. L’operatore unitario piú ragionevole sará poi quello che genera una lista con un solo elemento. Il resto della monade lista é facile da ricostruire:
Per capire meglio come funziona l’operatore di binding, vediamo un semplice esempio:
Dalla definizione dell’operatore di binding sappiamo che dobbiamo applicare una map alla lista originale e alla funzione di trasformazione, e quindi concatenare la lista di liste risultante:


Il vantaggio portato dal fatto di considerare le liste come monadi é che moltissime computazioni sulle liste possono adesso venire definite con grandissima naturalezza. La map di una lista rispetto ad una funzione é:

Il filtraggio di una lista é:

Il prodotto cartesiano filtrato di due liste é:


In generale poi le comprensioni di liste altro non sono che zucchero sintattico costruito sopra l’interpretazione monadica delle liste. In effetti l’operatore ← che adoperiamo nelle comprensioni di liste é esattamente lo stesso operatore di binding visto finora (non si tratta di una banale sovrapposizione sintattica):
|
Comprensione
|
Monade
|
Notazione do
|
|

|

|

|
|

|



|

|
|

|



|



|
Il vantaggio di usare le comprensioni di liste é evidente nella leggibilitá che tale notazione ha, soprattutto grazie alla somiglianza con la notazione insiemistica a cui siamo tutti piú o meno abituati:
diventa:
Gli ulteriori punti di forza del fatto che le monadi stanno sotto alle comprensioni di liste sono:
1. Il compilatore é piú semplice in quanto implementa una caratteristica di meno (solo le monadi piuttosto che monadi + comprensioni di liste ad hoc)
2. Possiamo costruire monadi per qualsiasi collezione in modo analogo alle comprensioni su liste, anche se senza lo zucchero sintattico.
Monade di stato
Come ultima monade che vediamo in dettaglio, scegliamo quella che mostra (a mio avviso) senza ombra di dubbio l’estrema potenza di linguaggi come Haskell o F# dotati di monadi di prima classe: realizziamo un intero mini-linguaggio imperativo sotto forma di monade, all’interno di Haskell e senza alcuna problematica risultante (non é una soluzione “sporca”).
Per semplicitá il nostro linguaggio imperativo definisce solamente due locazioni di memoria, che chiamiamo v1 e v2. Essendo un linguaggio non puro, vi saranno delle istruzioni di lettura e scrittura che leggono, ma soprattutto “modificano in-place” le nostre due variabili. Un possibile programma valido (impossibile da scrivere in Haskell puro) sarebbe:




Se all’inizio dell’esecuzione avessimo
e
, il risultato ritornato sarebbe
.
Come si comporta questo programma? Chiaramente per descrivere questa esecuzione, possiamo immaginare che ogni istruzione prenda in input lo stato (la coppia del valore corrente di v1 e v2) e ritorni come output un valore (la valutazione dell’espressione) e il nuovo stato (che potrebbe essere stato modificato durante la valutazione dell’istruzione):
|
Stato prima della valutazione
|
Istruzione
|
Valore ritornato
|
Stato dopo la computazione
|
|

|

|

|

|
|

|

|

|

|
|

|

|

|

|
|

|

|

|

|
|

|

|

|

|
Questo significa che per valutare ogni nostra istruzione, in un sistema funzionale che ne rappresenta uno imperativo, una istruzione prende in input lo stato corrente (la “memoria” che contiene le variabili) e ritorna lo stato modificato piú il risultato della valutazione delle espressioni. Il sistema di esecuzione dovrebbe anche essere fatto in modo da garantire che un programmatore “sbadato” non usi uno stato vecchio, altrimenti si genererebbero inconsistenze.
Il tipo di una istruzione (che é il nostro tipo monadico) risulta essere:
Le funzioni che interagiscono con le variabili di stato sono rispettivamente i metodi di lettura e scrittura delle due variabili v1 e v2:
Adesso vogliamo definire un operatore di binding che esegua una istruzione in modo da rispettare la logica del passaggio di stato. Per fare ció l’operatore di binding dovrá passare all’istruzione da eseguire lo stato corrente, ottenere un valore e un nuovo stato, e passare al resto del programma sia il valore calcolato che il nuovo stato.
Ricordiamo che il tipo dell’operatore di binding é:
Ossia nel nostro caso



Il nostro esempio di codice imperativo viene ora riscritto come:
Il codice originale e il codice in notazione do sono quasi identici, ed é notevole come siamo riusciti a spremere da un linguaggio funzionale e “puro” come Haskell codice completamente impuro e imperativo. Non a caso molti Haskellers sostengono Haskell essere il piú elegante e completo linguaggio imperativo esistente .
Chapter 4 Codice Haskell della monade di gestione degli errori
