La comunità Italiana di programmatori Haskell.
La libreria Haskell-MVC scritta da Gabriel Gonzalez, è un ottimo esempio di codice Haskell d’alto livello, scritto da una persona sicuramente intelligente e che padroneggia il linguaggio. La libreria è usata in questo post solo come punto di partenza e pretesto per capire quanto sia facile o difficile studiare il codice Haskell scritto da altri, e non verrà trattata nella sua interezza, anzi quasi per niente.
La libreria definisce una View come
> import Data.Monoid (Monoid(mempty, mappend, mconcat), (<>), First)
> import qualified Data.Monoid as M
>
> newtype View a = AsFold (FoldM IO a ())
FoldM
è un data-type definito in Control.Foldl
.
Andando a recuperare la definizione di FoldM
:
> -- | Like 'Fold', but monadic
> data FoldM m a b = forall x . FoldM (x -> a -> m x) (m x) (x -> m b)
Quindi siccome è “like Fold”, questa è la Fold
:
> {-| Efficient representation of a left fold that preserves the fold's step
> function, initial accumulator, and extraction function
>
> This allows the 'Applicative' instance to assemble derived folds that
> traverse the container only once
> -}
> data Fold a b = forall x . Fold (x -> a -> x) x (x -> b)
Quindi la prima funzione x -> a -> x
è da considerarsi come una funzione di fold, che accetta uno stato di elaborazione intermedio x
, un elemento della “lista” a
e torna il nuovo stato intermedio. Segue lo stato iniziale di elaborazione, come nella fold
. La seconda funzione è invece una funzione finale che viene usata per convertire lo stato finale della fold
nel risultato finale voluto.
E infatti abbiamo questa funzione che esegue una Fold
, come se fosse una fold
:
> -- | Apply a strict left 'Fold' to a 'Foldable' container
> fold :: Foldable f => Fold a b -> f a -> b
> fold (Fold step begin done) as = F.foldr cons done as begin
> where
> cons a k x = k $! step x a
Quindi con Data.Foldl.fold
possiamo passare ad una Fold
un container foldable f a
e avere il risultato finale del fold di tipo b
. Foldable
rappresenta un container che è foldable, quindi di fatto tutte le strutture dati più comuni: liste, vettori, trees, ecc…
Lo scopo di Fold
è quello di scrivere codice che combina due o più funzioni di fold, come
> average :: Num b => Fold b b
> average = (/) <$> sum <*> genericLength
>
> sum :: Num a => Fold a a
> sum = Fold (+) 0 id
>
> genericLength :: Num b => Fold a b
> genericLength = Fold (\n _ -> n + 1) 0 id
La combinazione delle due Fold
sum
e genericLength
, in una implementazione naive richiederebbe una scansione doppia della lista. Qualcosa tipo:
> slowAverage :: Num b => [b] -> b
> slowAverage fs
> = let s = sum fs
> l = length fs
> in s / l
Le regole di combinazione delle operazioni della Fold
permettono invece di scansionare la lista una sola volta, creando un codice equivalente a questo:
> fastAverage :: Num b => [b] -> b
> fastAverage fs
> = let (s,l ) = foldl (\(s1, l1) x -> (s1 + x, l1 + 1)) (0, 0) fs
> in s / l
Questo grazie all’implementazione di Applicative
da parte della Fold
:
> data Pair a b = Pair !a !b
>
> instance Applicative (Fold a) where
> pure b = Fold (\() _ -> ()) () (\() -> b)
>
> (Fold stepL beginL doneL) <*> (Fold stepR beginR doneR) =
> let step (Pair xL xR) a = Pair (stepL xL a) (stepR xR a)
> begin = Pair beginL beginR
> done (Pair xL xR) = (doneL xL) (doneR xR)
> in Fold step begin done
e di
> instance Functor (Fold a) where
> fmap f (Fold step begin done) = Fold step begin (f . done)
Alla luce di questo riguardiamo il codice di esempio
average :: Num b => Fold b b
average = (/) <$> sum <*> genericLength
L’operatore <$>
è un sinonimo della fmap
dei Functor
. La Fold
è un Functor
, quindi è sia in grado di memorizzare un valore di tipo x
, che di applicare al valore una generica funzione x -> y
. La definizione di Functor
è:
class Functor f where
fmap, (<$>) :: (a -> b) -> f a -> f b
La particolarità matematica di un Functor
è quella di non riuscire a variare la struttura del Functor
f a
quando calcola f b
dato che la funzione a -> b
passata come parametro, non ha informazioni sul Functor
sorgente, e non ritorna informazioni sul Functor
destinazione.
Un esempio di fmap
è la map
su una lista. La map
applica una funzione a tutti gli elementi di una lista. Ma non c’è modo in cui la map
possa cambiare il numero di elementi della lista, dato che la funzione f
parametro della map
non ha alcuna informazione riguardo la lista, ma processa solo un elemento alla volta della lista. Inoltre se map
fosse implementata in modo da variare il numero di elementi, non rispetterebbe le propietà dell’elemento neutro e la propietà associativa.
Da notare che Applicative
e poi Monad
hanno una struttura simile, ma permettono alla funzione usata come primo parametro di modificare progressivamente più cose nel risultato finale. Ma lo vedremo in seguito.
Torniamo al nostro esempio. La prima parte dice
average = (/) <$> sum ...
e si noti come nella definizione di Fold
instance Functor (Fold a) where
fmap f (Fold step begin done) = Fold step begin (f . done)
si dica semplicemente che la funzione (/)
debba essere usata come parametro finale della Fold
che se ricordiamo bene è la funzione usata per trasformare lo stato finale della Fold
nel risultato finale tornato. E la cosa torna: la divisione per calcolare la media, non va applicata per ogni elemento, ma solo al termine della scansione della lista.
Da notare che la definizione di fmap
(che sarebbe poi il nome di <$>
) non torni un valore in questo caso, ma torni una Fold
(simil funzione) risulato della combinazione di operatori. Solo quando si applicherà la Fold
ad un container foldable, con un initial state, si avrà il risultato voluto. Ma noi stiamo combinando dei valori Fold
per ora, e non dei valori finali.
Se cerchiamo di fare type checking di (/) <$> sum ...
con (<$>) :: (a -> b) -> f a -> f b
, notiamo che effettivamete l’elemento a sinistra dell’operatore è una funzione e l’elemento a destra la nostra struttura Fold
di tipo Functor
.
Vediamo la seconda parte
( (/) <$> sum) <*> genericLength
In questo caso <*>
è l’operatore usato per combinare fra di loro due valori (funzioni nel nostro caso) di tipo Applicative
. Questa la definizione di Applicative
:
class (Functor f) => Applicative f where
pure :: a -> f a
(<*>) :: f (a -> b) -> f a -> f b
La definizione di <*>
è molto simile a quella di <$>
solo che invece di avere a -> b
, abbiamo f (a -> b)
come primo argomento. Cosa comporta questo in pratica? Comporta che quando si torna il risultato f b
, il risultato oltre a dipendere da f a
, può anche dipendere da f (a -> b)
e quindi anche dalla struttura del functor f
usato come primo argomento in modo esplicito. Quindi se si usa come Functor
un vettore, e si implementa per esempio la moltiplicazione di matrici, allora il numero di colonne e linee del vettore risultato possono dipendere dal numero di linee e colonne dei due vettori sorgenti. Nel caso di un Functor
e della funzione fmap
non era mai possibile cambiare la struttura del Functor
.
Sia la Applicative
che la Functor
combinano Functor
fra di loro e tornano un altro Functor
. Ma la Applicative
può cambiare la struttura dei Functor
combinati fra di loro, mentre la <$>
del Functor
no. Per questo Applicative
è più potente di Functor
. Ma la maggior potenza ha un prezzo. Nel caso di Haskell il prezzo è quello di avere codice meno prevedibile perchè potenzialmente più potente e libero. Banalizzando è un pó come usare una foldl
quando basta e avanza una map
, o usare codice in IO quando basta una funzione pura.
Ma come si comporta la <*>
nel caso della Fold
? Noi stiamo combinando queste due Fold
fra di loro:
sum = Fold (+) 0 id
genericLength = Fold (\n _ -> n + 1) 0 id
((/) <$> sum) <*> genericLength
dove gli elementi della Fold
sono al solito la funzione di step
, il valore iniziale e la funzione che trasforma l’ultimo step nel risultato finale della Fold
. La definizione di Applicative
per Fold
è:
instance Applicative (Fold a) where
pure b = Fold (\() _ -> ()) () (\() -> b)
(Fold stepL beginL doneL) <*> (Fold stepR beginR doneR) =
let step (Pair xL xR) a = Pair (stepL xL a) (stepR xR a)
begin = Pair beginL beginR
done (Pair xL xR) = (doneL xL) (doneR xR)
in Fold step begin done
Intanto importantissimo: la funzione a -> b
viene iniettata in una Fold
tramite pure
e nel nostro caso viene inserita come risultato finale, ignorando ogni altra computazione della Fold
. È quindi come una funzione che torna una costante, senza processare nessun elemento. Nel nostro caso la costante finale è una funzione.
Nel caso della combinazione <*>
, viene tornata una Fold
finale in cui lo stato iniziale è una Pair
con gli stati iniziali delle due Fold
usate come parametro, il passo di step processa un elemento della struttura Foldable
alla volta e lo passa alle due funzioni step dei due Fold
usati come parametro. Quindi usa lo stesso “trucco” che useremo se volessimo unire i due fold in una unica iterazione della lista, mantenendo una pair nello stato.
Lo stato finale della Fold
ha la forma (doneL xL) (doneR xR)
ed è in realtà una applicazione di funzione. Ovvero (doneL xL)
è una funzione. E la cosa ha senso, dato che abbiamo visto che possiamo creare un Fold (a -> b)
solo usando pure
e pure
inserisce la funzione nell’ultimo elemento della Fold
. Tornando all’esempio, doneL
è una funzione, nel nostro caso ((/) <$> sum)
torna nel risultato finale una funzione che accetta un valore Num b
e esegue la divisione con la sum
di tutta la lista. Alla funzione (doneL xL)
passiamo (doneR xR)
che nel nostro caso è genericLength
.
Torniamo alla nostra libreria MVC. All’inizio del tutto stava questa definizione di View
newtype View a = AsFold (FoldM IO a ())
Nel nostro caso una View
è vista come una funzione che accetta dati di tipo a
e può tornare una View
finale risultato della composizione di tutti i dati a
, lavorando nella Monad IO
. In particolare, la View
è anche un Monoid
:
> instance Monoid (View a) where
> mempty = AsFold mempty
> mappend (AsFold fold1) (AsFold fold2) = AsFold (mappend fold1 fold2)
Un Monoid
è una struttura definita come:
class Monoid a where
mempty :: a
mappend, (<>) :: a -> a -> a
e per la quale valgono la propietà associativa ed esiste un elemento neutro. I numeri Interi sono Monoid, e lo sono rispetto la somma o la moltiplicazione, tra le altre cose. Le liste sono Monoid e lo sono rispetto alla concatenazione.
Siccome una View è un Monoid, allora possiamo sempre combinare due View e ottenere una nuova View. Il che rende la View un tipo di dato molto comodo da usare e estremamente componibile. Come tutti i Monoid del resto.
Definire una Fold
come un Applicative
o Functor
o un Monoid
, permettere di ereditare tutte le propietà delle classi e tutte le funzioni di supporto. Si possono riusare i concetti tipici di un Functor
e di un Applicative
, senza doversene inventare di nuovi e ad-hoc. Lo stesso accade in matematica, quando in algebra (per non parlare della category theory) si definiscono le operazioni matematiche in base alla struttura che hanno, e si ereditano in automatico tutte le propietà e teoremi che valgono sulla struttura a cui sono state associate.
Se si studia http://hackage.haskell.org/package/foldl-1.0.7/docs/src/Control-Foldl.html si possono aprezzare le ulteriori definizioni “matematiche” legate alla Fold
, che si ereditano in automatico ogni volta che si definisce qualcosa in termini di Fold
.
Inoltre Functor
, Applicative
e Monoid
rispettano la propietà associativa e hanno un elemento neutro. Questo fa si che abbiano un comportamento facile da prevedere per chi programma e che siano facilmente componibili fra di loro.
Il codice Haskell d’alto livello (scritto da persone intelligenti e che sanno il fatto loro) è da una parte molto compatto, ma è anche purtroppo molto denso e difficile da capire, dato che le definizioni di nuove funzioni e strutture dati si riferiscono a concetti descritti in altri package, e che spesso non sono per niente banali. L’aproccio assomiglia alla costruzione di teorie matematiche in cui ogni definizione si rifà ai concetti e definizioni introdotte precedentenmente, e quindi è facile perdersi dopo un pó, se non si ripassano continuamente le definizioni precedenti.
Probabilmente le IDE dovrebbero aiutare di più permettendo di:
La buona notizia è che il riuso di codice Haskell è sicuramente superiore al riuso di codice object-oriented, dato che librerie Haskell che eseguono compiti complessi, hanno pochissime linee di codice, e riusano concetti definiti in altri packages, con una granularità di riuso molto più fine del tipico codice object-oriented.