domenica 24 gennaio 2021

Functional programming

 Programmazione funzionale

La programmazione funzionale è un paradigma diverso da quello procedurale, in contrapposizione a quello procedurale (o imperativa) e a quello orientato ad oggetti.


Premessa

La programmazione procedurale prevede di solito la soluzione di un problema partendo da un set di dati (input), che poi attraverso l'esecuzione di una sequenza di azioni vengono trasformati in output. E' detta anche imperativa poiché consiste di una serie di ordini impartiti alla macchina da svolgere in un preciso ordine. Lo stato del programma cambia a seguito dell'esecuzione di ogni singolo comando (istruzione), per esempio perché si assegnano dei valori a delle variabili o si modificano elementi di strutture composte.

La programmazione ad oggetti ha posto maggiore enfasi sullo stato del programma, consentendo di unire funzioni e dati in classi, in modo tale che ogni oggetto (istanza di una classe) abbia un proprio stato che può essere modificato per mezzo dei metodi pubblici messi a disposizione dalla classe. E' come se avessimo tante piccole cellule che gestiscono i propri dati e comunicano mediante i metodi dichiarati nella propria interfaccia pubblica.

La programmazione ad oggetti ha rappresentato un grande passo avanti perché ha reso possibile implementare l'information hiding a livello di linguaggio, cosa prima impossibile. Un'ulteriore innovazione è stata la possibilità di ereditare comportamenti da una o più classi base, per poter scrivere nuove classi senza dover riscrivere funzioni già presenti. 

Tuttavia la possibilità di ereditare da più classi si è ben presto scoperto essere un'arma a doppio taglio, in cui la costruzione e l'uso delle istanze poli-ereditanti rappresentava un problema di non facile soluzione se non in casi molto semplici, ed è stata presto abbandonata.  

Anche l'ereditarietà singola, sebbene consenta di poter creare nuove classi a partire da classi esistenti, presenta alcuni punti critici, ad esempio se vogliamo derivare una classe A che contiene a sua volta delle istanze di una classe B, per ottenere una classe A1 che contenga istanze di una classe derivata da B ossia B1, e magari nella classe A originale ci sono metodi che creano istanze di tale classe. 
Altro problema è il richiamare metodi virtuali nel costruttore di una classe, poiché l'oggetto derivato non è stato ancora completamente creato e quindi sarebbero eseguiti prima del costruttore della classe derivata.

A questi ed altri problemi si è fatto fronte ad esempio cercando di prediligere la composizione alla derivazione, e la dichiarazione di interfacce ai tipi classe, nei tipi usati nel programma. Molti design patterns sono stati sviluppati in tal senso e saranno oggetto di ulteriori post.

Lo unit testing di classi complesse si è rivelato essere spesso un problema di non facile risoluzione, specie quando queste dipendono da altre classi nella loro costruzione, e che a loro volta richiedono uno stato per poter essere invocate. Tali problemi sono stati risolti facendo uso delle interfacce, limitando e isolando l'istanziazione diretta di oggetti facendo invece affidamento a factory o a meccanismi di dependency injection (che saranno oggetto di altri post), o isolandone l'uso in metodi virtuali da poter derivare in fase di test. Tuttavia rappresentano un ostacolo spesso non banale da risolvere. Anche in questo caso i problemi derivano prevalentemente dallo stato necessario alla classe per la corretta esecuzione dei propri metodi, sia in produzione che in fase di test.


Functional programming

La programmazione funzionale rivolge il proprio focus sulle funzioni separandole nettamente dai dati su cui esse operano, eliminando del tutto il problemi sulla gestione dello stato evidenziati. Infatti non prevede alcuno stato ma semplicemente la trasformazione dei dati in input in (nuovi) dati di output oggetto di eventuali successive elaborazioni. Può risultare abbastanza sconcertante sulle prime rendersi conto che in questo nuovo paradigma, l'istruzione di assegnazione che modifica una varabile, semplicemente, almeno nella forma pura, non esiste.

Nella programmazione ad oggetti gli sviluppatori creano oggetti, via via più complessi, su cui operano con dei metodi, ed è cosi che si attua l'information-hiding. Nella programmazione funzionale ci sono un numero limitato di tipi di base (liste, dizionari, set), che vengono composti in strutture più complesse e su cui si accede con metodi specializzati.

Ogni funzione restituisce un set di nuovi dati e non modifica i dati in input. Questo fa si che non si siano più problemi di stato interno incoerente, non essendoci più uno stato, e lo unit testing diventi un gioco da ragazzi.

A differenza della programmazione imperativa, in cui di solito si usano dei cicli per filtrare/manipolare/eventualmente ricomporre  i dati, nella programmazione funzionale ci sono delle funzioni apposite per realizzare gli stessi obiettivi.

Analizziamo alcune di queste funzioni, presenti (con sintassi diverse) in tutti i linguaggi funzionali.  Una peculiarità dei linguaggi functional è certamente quella di trattare le funzioni come oggetti primitivi, quindi la possibilità di creare/manipolare funzioni alla stregua di tutti gli altri tipi gestiti dal linguaggio. Questa caratteristica mal si sposa con i linguaggi strongly typed, in cui ogni funzione deve avere un tipo e dei parametri ben definiti. A tale limite si è posto un parziale rimedio con le funzioni lambda (es. c#, java), ma il problema di fondo rimane. Pertanto meglio si prestano alla programmazione linguaggi interpretati come Javascript o Phyton, in cui i costrutti functional sono molto più semplici ed eleganti.

Per semplicità parlerò in termini di collezioni, ma più precisamente le funzioni si applicano di solito a oggetti genericamente iterabili. Non farò riferimento ad un particolare linguaggio di programmazione, ma queste funzioni sono presenti in tutti i linguaggi che possiedono caratteristiche funzionali.

Filter(collezione, funzione criterio)

Ad ogni elemento della collezione applica la funzione criterio data, e restituisce via via tutti gli elementi per cui questa restituisce "vero". Il risultato della Filter è una nuova collezione, o più in generale, un oggetto iterabile, dello stesso tipo del primo parametro

Simili a Filter sono Find, FindLast, con medesimi parametri, che però restituiscono solo, rispettivamente, il primo o l'ultimo elemento della collezione che rispetta un certo criterio.



Map(collezione, funzione di trasformazione)

Ad ogni elemento della collezione, applica la funzione di trasformazione e restituisce una nuova collezione con i risultati ottenuti.

Reduce(collezione, funzione binaria)

Itera la collezione prendendo prima i primi due elementi, usando essi come parametri per invocare la funzione binaria, e poi eseguendo la funzione binaria con tutti  successivi elementi della collezione, con il risultato della precedente iterazione.

Ad esempio, dati gli elementi a1, a2, a3, a4, a5 e la funzione f, il risultato sarà

f ( f (f( f( f(a1,a2), a3), a4), a5 )

Essendo f una generica funzione, la reduce può essere usata per effettuare la somma di tutti gli elementi di un array ad esempio, ma anche per funzioni molto più complesse.

E' possibile che il linguaggio fornisca una versione ReduceRight ed una versione ReduceLeft in base all'ordine con cui debbano essere considerati i parametri.


Predicati su collezioni

Every(collezione, predicato)

Restituisce true se il predicato risulta vero per tutti gli elementi della collezione. Di solito si ferma appena trova un elemento false.
rif. c# all

Some(collezione, predicato)

Restituisce true se il predicato risulta vero per almeno un elemento della collezione. Di solito si ferma appena trova un elemento che rende il predicato false.
rif. c# any

Manipolazioni di liste

I linguaggi funzionali presentano di solito una vasta gamma di funzioni per manipolare liste. Ne elenco alcune:

Partition(collezione, predicato)

Restituisce una lista contenente due liste, la prima di tutti gli elementi che rendono il predicato vero, e la seconda che lo rendono falso, e la cui unione è ovviamente la lista data.

Range(inizio, fine passo)

Questo costrutto serve a creare una lista di interi da inizio a fine ad incrementi di "passo", ad esempio

Range(0,10,2)  = [0,2,4,6,8,10]


Concat(collezione1, collezione2,..)

Restituisce una collezione avente, nell'ordine, tutti gli elementi della collezione1, poi della 2 e così via.

Tail(collezione) 

Restituisce una collezione con tutti gli elementi di quella data escluso il primo


Head(collezione) 

Restituisce il primo elemento di una collezione 

Initial(collezione) 

Restituisce una collezione con tutti gli elementi di quella data escluso l'ultimo

Last(collezione) 

Restituisce l'ultimo elemento di una collezione 

Union(collezione1, collezione2,..)

Restituisce una collezione avente tutti gli elementi distinti di collezione1, collezione2,.. nell'ordine dato, presupponendo ci sia un metodo di confronto di default per stabilire l'uguaglianza di due elementi generici.

Zip(collezione1, collezione2, ...)

Crea una lista in cui ogni elemento i-mo è una lista avente l'elemento i-mo di ognuna delle liste date, nell'ordine.
Quindi zip ( [a,b,c], [x,y,z],[0,1,2]) = 
[  [a,x,0], [b,y,1], [c,z,2] ]

Rif. Lodash zip

Unzip(collezione)

Effettua l'operazione esattamente inversa alla Zip, quindi si attende che gli elementi della collezione data siano delle collezioni.


Funzioni che restituiscono liste (yeld return , yeld break)

( da scrivere )
rif. 



Manipolazione di funzioni


Currying

E' la trasformazione di una funzione con n parametri in una catena di funzioni a singolo parametro. La funzione risultante è invocabile solo quando tutti i parametri sono forniti, tuttavia è possibile che i vari parametri siano avvalorati in trasformazioni successive della funzione, in momenti diversi e punti diversi del programma. I parametri saranno avvalorati eseguendo le funzioni restituite dal curry.

Ad esempio prendiamo la funziona binaria prodotto(x,y) = x*y

f = curry(prodotto) è una funzione unaria che restituisce a sua volta una funzione unaria in modo che

(f(x))(y) =12

quindi f(3)= una funzione che accetta un parametro e restituisce suo triplo 

ossia f(3)(x) = x*3

f(3)(4) = 12

Rif. Lodash curry


Partial application

E' la trasformazione di una funzione ad n parametri in una funzione con n-m parametri, di cui gli m sono forniti in anticipo. E' un concetto simile a quello del currying ma i parametri sono forniti in anticipo. Ad esempio, 

se prodotto(x,y) = x*y

e f = partial(prodotto, 3) 

f è la funzione f(x) = x*3

Ossia se confrontiamo con l'esempio precedente, è il risultato del curry invocato col valore 3.

La partial application è uno strumento comodo per realizzare lo strategy pattern, che sarà oggetto di un post a parte.

Rif. lodash partial

Memoization

E' la tecnica di memorizzazione dei risultati di una funzione, che si presuppone senza memoria. Il risultato è una nuova funzione che memorizza i risultati della funzione di partenza e li restituisce direttamente quando viene invocata con parametri già precedentemente utilizzati.

Lo scopo è quello di velocizzare le invocazioni successive, al costo di utilizzare dello spazio di memoria per salvare i risultati precedenti. Può essere molto utile nell'applicazione di alcune funzioni ricorsive o molto costose. Nel caso di funzioni ricorsive ha anche spesso il pregio di evitare un eccessivo annidamento su determinati rami di esecuzione.

Rif. memoizzazione 

Caching

La memoizzazione è uno specifico tipo di caching, sul risultato di una funzione. Il caching è un concetto più generico che prevede l'allocazione di uno spazio di memoria (con una lista o un dizionario ad esempio) da usare per accedere al risultato di calcoli computazionalmente complessi oppure che sarebbero più lenti da leggere se presi dal disco o dal database. 

Per esempio nei motori scacchistici, ci sono libri di aperture di finali, frutto di migliaia di ore di elaborazione da reti di computer, che vanno a comporre degli alberi poi consultati rapidamente nei calcoli delle varianti. 

Oppure si può pensare al caching dei primi livelli di un indice b-tree di una tabella di un database relazionale. I dati soggetti a cache possono trovarsi in liste o più tipicamente alberi o dizionari.


Lazy Invocation

Il calcolo "lazy" è una strategia di valutazione delle funzioni, detta anche "call by need" o esecuzione differita, e si pone l'obiettivo di ritardare il calcolo di espressioni / esecuzione di funzioni sin quando non sia effettivamente necessario. Di solito non ci sono istruzioni che esplicitamente creano la lazyness di una funzione, che invece è insita nella progettazione e/o nel linguaggio. 

I vantaggi dell'esecuzione differita sono la maggiore velocità di esecuzione, la possibilità di utilizzare strutture potenzialmente di cardinalità infinita, evitare la creazione di strutture intermedie per il passaggio di parametri e ridurre il numero di chiamate complessive.

La lazy invocation è presente anche in linguaggi non funzionali, come il c#, per esempio con gli operatori short circuit  && e || ed è agevolata dagli oggetti iterabili (vedi c# enumerables).

Ma nella programmazione funzionale la potenzialità della lazy invocation è maggiore potendo più agevolmente passare come parametri delle funzioni  che restituiscono liste potenzialmente infinite, i cui elementi non sono calcolati se non quando sono effettivamente necessari nel calcolo.

La lazy invocation è un vero e proprio design pattern e può essere anche implementato in proprietà read only nella programmazione object oriented, quindi non solo nel paradigma della programmazione funzionale. Ad esempio potremmo avere una classe che espone una proprietà che viene calcolata solo al momento del primo accesso in lettura, ed in quel caso poi memorizza il risultato (applicando di fatto un caching) per restituirlo direttamente nelle letture successive.

Ma la lazy invocation non implica necessariamente un caching, per esempio supponendo di avere una funzione molto complessa da eseguire f ed una funzione che debba moltiplicare tutti i valori di f calcolati usando come parametri x1,.... xn, 

Un'invocazione "non lazy" potrebbe consistere nel calcolo di tutti i valori f(x1), ... f(xn) e poi un ciclo che moltiplica tutti i valori ottenuti.  Se invece man mano calcoliamo gli f(xi) e moltiplichiamo il risultato con il prodotto precedentemente ottenuto, abbiamo un'invocazione lazy di f. A proposito abbiamo già analizzato il currying.

Possiamo affermare quindi che lazy invocation, memoization, currying, operazioni su oggetti iterabili sono i pilastri della programmazione funzionale. 



Conclusione

In generale è bene acquisire dimestichezza con tutti i paradigmi di programmazione, dal procedurale all'OOP al funzionale, ed utilizzare ognuno di essi con cognizione di causa, con l'obiettivo di rendere il codice semplice, manutenibile ed efficiente.


Riferimenti, bibliografia

Issues in the Design of anObject Oriented Programming Language 

Functional thinking di Neal Ford, O Reilly

Lodash documentation


Nessun commento:

Posta un commento

Refactoring: improving modularization

  Modularità In generale, per modularità si intende la misura in cui un sistema può essere decomposto e ricombinato. La modularizzazione è a...