giovedì 6 maggio 2021

Stile di programmazione funzionale in Java, funzioni di ordine superiore e funzioni pure

Java nasce come linguaggio di programmazione imperativo orientato agli oggetti. E' stato poi anche implementato uno stile di programmazione funzionale. La programmazione funzionale esiste da molto tempo, da molto più tempo della programmazione orientata agli oggetti, ma quella orientata agli oggetti ha avuto maggiore seguito. Ma perchè utilizzare la programmazione funzionale?

Nella scrittura del codice esistono due tipi di complessità. Una è la complessità intrinseca, ossia quella intrinseca al dominio dell'applicazione. La seconda è costituita da quelle note come complessità accidentali.

La complessità intrinseca è inevitabile. Ciò in cui la programmazione funzionale ci viene in soccorso è la la complessità di ogni giorno, quella in cui ci imbattiamo e che dobbiamo superare in modo agile. La programmazione imperativa e orientata agli oggetti è intrinsecamente complessa, quindi la programmazione funzionale aiuta nel superamento di quelle complessità accidentali senza dover ricorrere ad una programmazione intrinsecamente complessa. Rende semplice la soluzione di problemi comuni.

Nella programmazione orientata agli oggetti ci si scontra quasi subito con la mutabilità condivisa, che può diventare un problema. In un ambiente multicore, in cui non si ha più a che fare con thread su una sola macchina e su un solo processore, il multithread diventa reale e così un programma correttamente eseguito in un singolo core può portare a risultati inattesi in un processore multicore. Quindi come gestire questa complessità derivante dalla mutabilità prodotta da un multiprocesso associata alla condivisione di tale mutabilità.

La mutabilità va bene, tutto muta nel tempo ed è necessario, la condivisione va bene, la nostra stessa esistenza si basa sulla condivisione. Come coniugare quindi queste due cose. E' un compito difficile, incline all'errore. Allora bisogna chiedersi se, in ogni pezzo di codice, sia realmente necessario introdurre una mutabilità, se i valori delle variabili debbano necessariamente cambiare andando ad introdurre un elemento di rischio per la stabilità del codice stesso. Si potrebbe pensare di implementare una programmazione concorrente, ma anche in questo caso stiamo risolvendo una complessità con una maggiore complessità andando a sostituire una struttura di codice sequenziale con una profondamente differente struttura concorrente che presuppone una esecuzione simultanea.

La programmazione funzionale, risalente agli anni '30, è rimasta in attesa in questi anni sviluppandosi sottopelle fino ad esplodere nell'industria moderna del codice in questi anni. Uno dei concetti base della programmazione funzionale è nella programmazione in assenza di assegnazione. Ciò significa che non c'è l'assegnazione? No. Significa che agisce dietro le quinte un po' come avviene per l'istruzione goto, non più ammessa nella programmazione strutturata ma ancora in azione all'atto della compilazione di un programma. L'istruzione di salto incondizionato è un elemento di rischio per la stabilità di un programma, eliminato con la programmazione strutturata. Ciò non toglie però che il salto incondizionato ad un certo livello sia ancora utilizzato e necessario. Si prenda ad esempio il seguente frammento di codice.

public class Esempi {

    public static void main(String ... args){

        for(int i = 0; i<10; i++){

            if(i%2==0){

                System.out.println(i);

            }

        }

    }

}

ha come bytecode un programma che ha in se il salto incondizionato (riga 24)

public class esempi.Esempi {

  public esempi.Esempi();

    Code:

       0: aload_0

       1: invokespecial #1                  // Method java/lang/Object."<init>":()V

       4: return


  public static void main(java.lang.String...);

    Code:

       0: iconst_0

       1: istore_1

       2: iload_1

       3: bipush        10

       5: if_icmpge     27

       8: iload_1

       9: iconst_2

      10: irem

      11: ifne          21

      14: getstatic     #7                  // Field java/lang/System.out:Ljava/io/PrintStream;

      17: iload_1

      18: invokevirtual #13                 // Method java/io/PrintStream.println:(I)V

      21: iinc          1, 1

      24: goto          2

      27: return

}

Quindi sebbene si faccia uso della programmazione strutturata, ad un livello inferiore esistono strutture che ad un livello superiore introdurrebbero una complessità che potrebbe minare il programma.

Quindi dire che la programmazione funzionale è una programmazione in assenza di assegnazioni non significa che non ci siano le assegnazioni, ma piuttosto che agiscono ad un livello inferiore a quello utilizzato per scrivere il programma.

Da questo punto di vista la mutablità è il male che vorremmo evitare nel multiprocesso e quindi la programmazione funzionale con la sua assenza di assegnazione può essere la soluzione rimuovendo la mutabilità. Ora però come ci rapportiamo alla immutabilità. Non vogliamo mutare lo stato ma possiamo trasformarlo. La trasformazione è un argomento più semplice della mutazione con cui operare. La questione che si solleva è però, avendo a che fare con elementi immutabili non si ha una degradazione delle prestazioni in seguito alle creazione di elementi trasformati ancora e ancora e ancora considerato che ognuno è immutabile? Qui subentrano librerie e livelli sottostanti che possono realizzare copie intelligenti degli elementi immutabili implementando solo le differenze dato che gli elementi non cambiano. Quindi con un uso intelligente dei garbage collector e dei dati è possibile eliminare il sovraccarico computazionale derivante dall'utilizzo dell'immutabilità e della programmazione funzionale.

Nella programmazione funzionale le funzioni sono considerate cittadini di prima classe ma che significa?

Con le funzioni siamo abituati a passare oggetti, a creare al loro interno oggetti e ritornare oggetti. Dire che una funzione è un cittadino di prima classe significa che non saranno più gli oggetti a spostarsi nel programma, ma saranno le funzioni a essere passate, a essere create e restituite. Queste funzioni che accettano funzioni come parametri e restituiscono delle funzioni sono dette funzioni di ordine superiore. Proprio come per gli oggetti, è quindi possibile comporre funzioni e scomporle.

Le funzioni di ordine superiore hanno il vantaggio di non avere effetti collaterali se sono pure. Con pure si intende che la funzione non deve cambiare nulla e che non deve dipendere da nulla che cambi qualcosa

Ciò perchè le funzioni nella programmazione funzionale sono definite pigre (lazy) e se dipendono da qualcosa che cambia non possono essere più valutate o eseguite in modo pigro. Per esecuzione pigra s'intende che una funzione non debba essere valutata appena inserita nel codice, ma la sua esecuzione può essere differita in un altro momento o comunque quando strettamente necessario.

Il maggior beneficio che otteniamo quindi con la programmazione funzionale è la riduzione della complessità accidentale nel codice e la semplificazione nella lavorazione del codice stesso. Ma c'è differenza fra uno stile funzionale rispetto a un linguaggio di programmazione funzionale puro. In un linguaggio di programmazione funzionale puro non è permessa alcuna mutabilità nel tempo. In uno stile funzionale invece è permessa la mutabilità a rischio di fare la cosa sbagliata e la responsabilità ricade sul programmatore. E' l'antica questione per cui un ambiente permette un certo stile mentre un altro lo implementa. Ciò non di meno il supporto alla programmazione funzionale permette di beneficiare dell'uso delle funzioni di ordine superiore. Quindi possiamo affermare che se un linguaggio permette l'uso di funzioni di ordine superiore allora permette uno stile di programmazione funzionale, se invece un linguaggio elimina la mutabilità oltre a fornire funzioni di ordine superiore allora implementa la programmazione funzionale.

Da questo punto di vista Java permette uno stile di programmazione funzionale mentre un linguaggio come Haskell implementa la programmazione funzionale pura, dato che fornisce funzioni di ordine superiore in comunione alla immutabilità.

Tornando a Java. Cos'è un metodo? Un metodo alla fine è una funzione che appartiene a una classe o a un oggetto. Una funzione a sua volta ha 4 cose:

  1. un nome
  2. un tipo di ritorno
  3. una lista di parametri
  4. il corpo della funzione

Vediamo nella pratica di cosa parliamo

Thread th = new Thread();        

th.start();

Quando si crea un thread, lo si avvia chiamando una funzione, ma un thread così si avvia e termina senza fare nulla. Se vogliamo che svolga una qualche attività occorre definire una classe che estenda Thread o implementi Runnable anche se con una classe anonima come segue

Thread th2 = new Thread(new Runnable() {

    @Override

    public void run() {

        System.out.println("Hello World");

    }

});

th2.start();

Di tutto questo ammasso di codice la parte necessaria è la funzione, e della funzione non serve neanche tutto. Quindi quali sono le parti realmente importanti? Ovviamente il corpo della funzione, anche i parametri necessari al funzionamento della funzione stessa qualora siano previsti. Questi sono gli elementi necessari alla definizione di una lambda expression. Quindi una lambda expression è definita da 

(lista parametri) -> corpo della funzione

Ma qual è il tipo di ritorno della lambda expression? Il tipo di ritorno si ottiene per inferenza ossia dalla valutazione del contesto della funzione. Il nome poi non è definito, quindi le lambda expression sono funzioni anonime.

new Thread(()->System.out.println("Hello Lambda Expression")).start();

In tale contesto Thread non è un costruttore ma è una funzione di ordine superiore che accetta come parametro una funzione grazie alle lambda expression. Le funzioni sono così cittadini di prima classe con uno stile di programmazione funzionale permesso da Java.

Con lo stile di programmazione funzionale si possono fare cose notevoli. Vediamo ad esempio un classico ciclo.

    List<Integer> ls = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);

    for(int i = 0; i<ls.size(); i++){

        System.out.print(ls.get(i) + "; ");

    }

Questo codice permette di stampare a video gli elementi dell'array. Si potrebbe pensare che sia un codice semplice. In realtà è un codice complesso a cui ci siamo abituati. Una prima riduzione alla complessità di questo ciclo può essere introdotta da una variante del ciclo for

    List<Integer> ls = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);

for(int t:ls){

        System.out.print(t + "; ");

    }

Questi due cicli sono noti come iteratori esterni. Ciò che è possibile utilizzare è un iteratore interno per tramite della funzione forEach

    ls.forEach(new Consumer<Integer>(){

            @Override

            public void accept(Integer t) {

                System.out.print(t + "; ");

            }            

        });

Il beneficio di questo approccio è che stiamo dicendo al programma cosa fare e non come farlo. Stiamo quindi sfruttando il polimorfismo, per cui l'iterazione interna cambia il proprio modo di operare su ogni elemento a seconda del cosa diciamo di fare. Per fare ciò però stiamo utilizzando una classe anonima. Ma Consumer appartiene a un insieme di interfacce chiamate interfacce funzionali (Le interfacce funzionali si trovano sotto java.util.function). Possiamo quindi sostituire la classe anonima con una lambda exression riducendo il tutto all'essenziale ossia niente classe, niente nome di funziona, niente tipo di ritorno. Una lista di parametri e il corpo della funzione.

    ls.forEach( t->System.out.print(t + "; "));

Essendo poi la lista di parametri formata da uno e un solo parametro le parentesi tonde possono essere omesse. In più i parametri non hanno bisogno che venga indicato il tipo, perchè questo è ottenuto per inferenza. I tipi dei parametri in più sono statici e definiti al momento della compilazione.

Alla base di questo meccanismo c'è l'idea per cui in una interfaccia in cui esista un solo metodo astratto che sia callable, runnable o consumer, come in questo esempio, è possibile passare una lambda expression.

        ls.forEach( t->System.out.print(t + "; "));             

    ls.forEach( t->System.out.print((t * 2) + "; "));            

    ls.forEach( t->System.out.print((t%2==0)?t + "; ":""));

Le lambda expression rendono estremamente semplice variare il funzionamento dell'iterazione sulla lista.

Esiste poi un caso ancora più specifico in cui la lambda expression non fa nulla, neanche trasformare il dato ma passarlo semplicemente ad un'altra funzione che si occuperà di trasformare il dato e lavorare sul parametro. In questo caso non è neanche necessaria la lambda expression, ma la si potrà sostituire direttamente con un riferimento alla funzione che lavorerà con il parametro.

    ls.forEach(t -> System.out.println(t));        

  ls.forEach(System.out::println);

Si è così passati ad uno stile di codice di tipo decorativo ossia in cui si dice cosa fare e non come farlo, nonostante l'uso di un linguaggio imperativo in cui va detto cosa fare e come farlo.

Vediamo un altro esempio interessante. Supponiamo di voler calcolare dalla nostra lista la somma del quadrato degli elementi con valore dispari. Il metodo imperativo che richiede di indicare sia  cosa fare che come farlo sarà:

int res = 0;

    for(int e:ls){

        if(e%2!=0)

            res += e*e;

    }

    System.out.println(res);

Oltre a dover indicare cosa e come, si può osservare come il linguaggio imperativo richieda una notevole mutabilità con ripetute assegnazioni alla variabile res.

Con uno stile di programmazione funzionale invece potremmo scrivere quanto segue:

System.out.println(

    ls.stream()

        .filter(e->e%2!=0)

        .mapToInt(e->e*e)

        .sum()

);

Nel metodo imperativo bisogna indicare passo per passo come fare una certa cosa, introducendo elementi di mutabilità ossia una complessità accidentale. Con il secondo codice non c'è nessuna assegnazione esplicita, è rimosso ogni elemento di mutabilità e ci si concentra sul cosa fare e non sul come, rendendo il codice più chiaro oltre che sicuro. Infatti stiamo dicendo che dato il flusso d'interi questo viene filtrato secondo una certa regola producendo una trasformazione in un nuovo flusso che è trasformato in un altro flusso in cui ogni elemento è il quadrato di quello in ingresso ed infine quest'ultimo flusso è trasformato in una aggregazione (reduce) che è la sommatoria. Con lo stile funzionale e decorativo ci si concentra su cosa fare, eliminando la mutabilità o complessità accidentale. Andiamo quindi a ottenere delle funzioni pure che eliminano gli effetti collaterali. Infine la composizione funzionale è molto semplice ed intuitiva a differenza della composizione di oggetti che introduce effetti collaterali derivanti dalla mutabilità intrinseca agli oggetti stessi.

Le lambda expression sono funzioni senza uno stato proprio. Esse hanno accesso a tutti gli elementi nell'ambito in cui sono definite e portano con se questi elementi nei livelli sottostanti in cui sono invocate. Ma in quanto funzioni pure non possono introdurre mutabilità nè dipendere da elementi che introducono mutabilità. Ciò rende immutabili, o almeno dovrebbe, tutti gli elementi utilizzati all'interno della lambda expression.  Ad esempio:

int immu = 4;

System.out.println(

    ls.stream()

        .filter(e->e%2!=0)

        .mapToInt(e->e*immu)

        .sum()

);   

immu = 5;

La variabile immu è trascinata ai livelli inferiori in quanto utilizzata dalla lambda expression 

e->e*immu 

Questo codice però produce un errore a livello della definizione della lambda expression perché la stessa fa uso di un elemento mutabile. Il fatto di utilizzare la variabile immu dentro una labda expression rende la stessa final per definizione anche se non indicato esplicitamente, quindi non è consentito modificarne il valore. Essendo quindi immu utilizzata nella labda expression va definita come

final int immu = 4;

E' anche possibile omettere la parola chiave final, ma il programmatore sa che se immu è utilizzata in una lambda expression esiste un contratto inviolabile fra programmatore e compilatore per cui puoi omettere la parola chiave final fintanto che siamo d'accordo che le funzioni lambda restino funzioni pure.

Questo sistema di protezione di Java per funzioni pure però non va oltre le semplici variabili locali. Campi o array non sono controllati e la responsabilità di rendere non pura la funzione ricade sul programmatore che volontariamente sta cercando di aggirare i controlli. Ad esempio:

int[] immu = new int[] {4};

Stream<Integer> strm = 

    ls.stream()

        .filter(e->e%2!=0)

        .map(e->e*immu[0]);

immu[0] = 6;

strm.forEach(System.out::println);

Questo codice non solleva nessun errore ma la responsabilità di aver violato le buone pratiche di programmazione è del programmatore. E le buone pratiche prevedono che la lambda expression sia una funzione pura ossia che non modifichi nulla, non introduca mutabilità e questa lo fa. In più è necessario che non dipenda da elementi mutabili e qui stiamo volontariamente violando le regole facendo in modo che questa non sia più una funzione pura.

Ma perché è così importante che la lambda expression sia pura e quindi non introduca mutabilità. Si prenda ad esempio questo cattivo stile di programmazione funzionale in cui è violata la purezza della lambda expression

int[] immu2 = new int[]{4};

Stream<Integer> strm = ls.stream()

                         .filter(e -> e % 2 != 0)

                         .map(e -> e * immu2[0]);

immu2[0] = 6;

strm.forEach(System.out::println);

Questo codice introduce dei gravi problemi derivanti dall'aver utilizzato un elemento mutabile nella lambda expression in un frammento di codice in stile funzionale in cui le funzioni sono lazy (pigre) ossia valutate non necessariamente subito ma comunque quando strettamente necessarie. 

Quindi quale sarà il valore di immu2[0] quando la funzione sarà valutata? Dipende. Non possiamo supporlo a priori. Per esempio sul mio PC, con la mia versione di Java all'atto della valutazione di immu2[0] valeva 6 per cui l'output è stato 6, 18, 30, 42, 54 e non 4 come si sarebbe potuto immaginare da un linguaggio imperativo.

Nello stile di programmazione funzionale quindi non è garantita l'immediata esecuzione delle funzioni perchè il compilatore può ottimizzare le prestazioni di esecuzione ritardando l'esecuzione delle funzioni fino al momento in cui non siano strettamente necessarie, o eseguirle parallelamente in sistemi multi processo o multi processore. Introdurre elementi di mutabilità diretta o indiretta nelle funzioni pure rende quindi la funzione non deterministica causando potenziali gravi problemi a tutto il programma. Quindi una funzione non può avere una valutazione pigra se non è immutabile. Di conseguenza una funzione pura può essere eseguita ora, dopo o mai, mentre una funzione impura, dipendente da elementi mutabili, ha urgenza di essere eseguita ora prima che l'ambiente in cui opera cambi.