Programmazione Funzionale in JavaScript

Concetti Chiave
  • JavaScript supporta la programmazione funzionale, permettendo di trattare le funzioni come valori.
  • I metodi degli array come map(), reduce(), e filter() sono strumenti potenti per la programmazione funzionale.
  • Le funzioni di ordine superiore possono essere utilizzate per creare funzioni che operano su altre funzioni, come componi() e mapper().
  • L'applicazione parziale delle funzioni consente di creare nuove funzioni con argomenti predefiniti, facilitando la riusabilità del codice.

Programmazione Funzionale

JavaScript non è un linguaggio di programmazione funzionale come Lisp o Haskell, ma il fatto che JavaScript possa manipolare le funzioni come oggetti significa che si possono utilizzare tecniche di programmazione funzionale in JavaScript. I metodi degli array come map() e reduce() si prestano particolarmente bene a uno stile di programmazione funzionale. Le sezioni che seguono dimostrano tecniche per la programmazione funzionale in JavaScript. Sono intese come un'esplorazione che espande la mente del potere delle funzioni di JavaScript, non come una prescrizione per un buono stile di programmazione.

Elaborazione di Array con Funzioni

Si supponga di avere un array di numeri e si voglia calcolare la media e la deviazione standard di quei valori. Si potrebbe farlo in stile non funzionale così:

// Questo è il nostro array di numeri
let dati = [1, 1, 3, 5, 5];

// La media è la somma degli elementi divisa per il numero di elementi
let totale = 0;

for (let i = 0; i < dati.length; i++) {
    totale += dati[i];
}

// media == 3; La media dei nostri dati è 3
let media = totale / dati.length;

// Per calcolare la deviazione standard, prima si sommano i quadrati delle
// deviazioni di ogni elemento dalla media.
totale = 0;

for (let i = 0; i < dati.length; i++) {
    let deviazione = dati[i] - media;
    totale += deviazione * deviazione;
}

// devStandard == 2
let devStandard = Math.sqrt(totale / (dati.length - 1));

Si possono eseguire questi stessi calcoli in stile funzionale conciso usando i metodi array map() e reduce() così:

// Prima, si definiscono due funzioni semplici
const somma = (x, y) => x + y;
const quadrato = x => x * x;

// Poi si usano quelle funzioni con i metodi Array per calcolare media e devStandard
let dati = [1, 1, 3, 5, 5];

// media == 3
let media = dati.reduce(somma) / dati.length;

let deviazioni = dati.map(x => x - media);

let devStandard = Math.sqrt(deviazioni.map(quadrato).reduce(somma) / (dati.length - 1));

// => 2
devStandard;

Questa nuova versione del codice sembra molto diversa dalla prima, ma sta ancora invocando metodi su oggetti, quindi ha alcune convenzioni orientate agli oggetti rimanenti. Si scrivano versioni funzionali dei metodi map() e reduce():

const map = function(a, ...args) { 
    return a.map(...args); 
};

const reduce = function(a, ...args) { 
    return a.reduce(...args); 
};

Con queste funzioni map() e reduce() definite, il codice per calcolare la media e la deviazione standard ora appare così:

const somma = (x, y) => x + y;
const quadrato = x => x * x;

let dati = [1, 1, 3, 5, 5];

let media = reduce(dati, somma) / dati.length;

let deviazioni = map(dati, x => x - media);

let devStandard = Math.sqrt(reduce(map(deviazioni, quadrato), somma) / (dati.length - 1));

// => 2
devStandard;

Funzioni di Ordine Superiore

Una funzione di ordine superiore è una funzione che opera su funzioni, prendendo una o più funzioni come argomenti e restituendo una nuova funzione. Ecco un esempio:

// Questa funzione di ordine superiore restituisce una nuova funzione che passa i suoi
// argomenti a f e restituisce la negazione logica del valore di ritorno di f;
function non(f) {
    // Restituisce una nuova funzione
    return function(...args) {
        // che chiama f
        let risultato = f.apply(this, args);

        // e nega il suo risultato.
        return !risultato;
    };
}

// Una funzione per determinare se un numero è pari
const pari = x => x % 2 === 0;

// Una nuova funzione che fa l'opposto
const dispari = non(pari);

// => true: ogni elemento dell'array è dispari
[1, 1, 3, 5, 5].every(dispari);

Questa funzione non() è una funzione di ordine superiore perché prende un argomento funzione e restituisce una nuova funzione. Come altro esempio, si consideri la funzione mapper() che segue. Prende un argomento funzione e restituisce una nuova funzione che mappa un array a un altro usando quella funzione. Questa funzione usa la funzione map() definita in precedenza, ed è importante che si comprendano come le due funzioni sono diverse:

// Restituisce una funzione che si aspetta un argomento array e applica f a
// ogni elemento, restituendo l'array dei valori di ritorno.
// Si confronti questo con la funzione map() di prima.
function mapper(f) {
    return a => map(a, f);
}

const incrementa = x => x + 1;
const incrementaTutti = mapper(incrementa);

// => [2,3,4]
incrementaTutti([1, 2, 3]);

Ecco un altro esempio più generale che prende due funzioni, f e g, e restituisce una nuova funzione che calcola f(g()):

// Restituisce una nuova funzione che calcola f(g(...)).
// La funzione restituita h passa tutti i suoi argomenti a g, poi passa
// il valore di ritorno di g a f, poi restituisce il valore di ritorno di f.
// Sia f che g sono invocate con lo stesso valore this con cui è stata invocata h.
function componi(f, g) {
    return function(...args) {
        // Si usa call per f perché si sta passando un singolo valore e
        // apply per g perché si sta passando un array di valori.
        return f.call(this, g.apply(this, args));
    };
}

const somma = (x, y) => x + y;
const quadrato = x => x * x;

// => 25; il quadrato della somma
componi(quadrato, somma)(2, 3);

Le funzioni parziale() e memoizza() definite nelle sezioni che seguono sono altre due importanti funzioni di ordine superiore.

Applicazione Parziale delle Funzioni

Il metodo bind() di una funzione f restituisce una nuova funzione che invoca f in un contesto specificato e con un insieme specificato di argomenti. Si dice che vincola la funzione a un oggetto e applica parzialmente gli argomenti. Il metodo bind() applica parzialmente gli argomenti a sinistra, cioè, gli argomenti che si passano a bind() sono posizionati all'inizio della lista degli argomenti che è passata alla funzione originale. Ma è anche possibile applicare parzialmente gli argomenti a destra:

// Gli argomenti a questa funzione sono passati a sinistra
function applicazioneParzialeSinistra(f, ...argomentiEsterni) {
    // Restituisce questa funzione
    return function(...argomentiInterni) {
        // Costruisce la lista degli argomenti
        let args = [...argomentiEsterni, ...argomentiInterni];

        // Quindi invoca f con essa
        return f.apply(this, args);
    };
}

// Gli argomenti a questa funzione sono passati a destra
function applicazioneParzialeDestra(f, ...argomentiEsterni) {
    // Restituisce questa funzione
    return function(...argomentiInterni) {
        // Costruisce la lista degli argomenti
        let args = [...argomentiInterni, ...argomentiEsterni];

        // Quindi invoca f con essa
        return f.apply(this, args);
    };
}

// Gli argomenti a questa funzione servono come template. I valori undefined
// nella lista degli argomenti sono riempiti con valori dall'insieme interno.
function applicazioneParziale(f, ...argomentiEsterni) {
    return function(...argomentiInterni) {
        // copia locale del template degli argomenti esterni
        let args = [...argomentiEsterni];

        // quale argomento interno è il prossimo
        let indiceInterno = 0;

        // Cicla attraverso gli args, riempiendo i valori undefined dagli argomenti interni
        for (let i = 0; i < args.length; i++) {
            if (args[i] === undefined) {
                args[i] = argomentiInterni[indiceInterno++];
            }
        }

        // Ora aggiunge tutti gli argomenti interni rimanenti
        args.push(...argomentiInterni.slice(indiceInterno));

        return f.apply(this, args);
    };
}

// Ecco una funzione con tre argomenti
const f = function(x, y, z) { 
    return x * (y - z); 
};

// Si noti come queste tre applicazioni parziali differiscono
// => -2: Vincola il primo argomento: 2 * (3 - 4)
applicazioneParzialeSinistra(f, 2)(3, 4);

// =>  6: Vincola l'ultimo argomento: 3 * (4 - 2)
applicazioneParzialeDestra(f, 2)(3, 4);

// => -6: Vincola l'argomento di mezzo: 3 * (2 - 4)
applicazioneParziale(f, undefined, 2)(3, 4);

Queste funzioni di applicazione parziale consentono di definire facilmente funzioni interessanti a partire da funzioni che si sono già definite. Ecco alcuni esempi:

const incremento = applicazioneParzialeSinistra(somma, 1);
const radiceQuadra = applicazioneParzialeDestra(Math.pow, 1/3);

// => 3
radiceQuadra(incremento(26));

L'applicazione parziale diventa ancora più interessante quando si combina con altre funzioni di ordine superiore. Ecco, per esempio, un modo per definire la funzione not() precedente appena mostrata usando composizione e applicazione parziale:

const not = applicazioneParzialeSinistra(componi, x => !x);
const pari = x => x % 2 === 0;
const dispari = not(pari);
const èNumero = not(isNaN);

// => true
dispari(3) && èNumero(2);

Si può anche usare composizione e applicazione parziale per rifare i calcoli di media e deviazione standard in stile funzionale estremo:

// Le funzioni somma() e quadrato() sono definite sopra. Eccone alcune altre:
const prodotto = (x, y) => x * y;
const negativo = applicazioneParziale(prodotto, -1);
const radiceQuadrata = applicazioneParziale(Math.pow, undefined, .5);
const reciproco = applicazioneParziale(Math.pow, undefined, negativo(1));

// Ora si calcola la media e la deviazione standard.
let dati = [1, 1, 3, 5, 5];   // I nostri dati

let media = prodotto(riduci(dati, somma), reciproco(dati.length));

let deviazioneStandard = radiceQuadrata(prodotto(riduci(mappa(dati,
                                     componi(quadrato,
                                             applicazioneParziale(somma, negativo(media)))),
                                 somma),
                          reciproco(somma(dati.length, negativo(1)))));

// => [3, 2]
[media, deviazioneStandard];

Si noti che questo codice per calcolare media e deviazione standard è interamente composto da invocazioni di funzioni; non ci sono operatori coinvolti, e il numero di parentesi è cresciuto così tanto che questo JavaScript sta iniziando a sembrare codice Lisp. Ancora una volta, questo non è uno stile che si consiglia per la programmazione JavaScript, ma è un esercizio interessante vedere quanto profondamente funzionale può essere il codice JavaScript.

Memoizzazione

Si è già vista nelle lezioni precedenti una funzione fattoriale che memorizzava nella cache i suoi risultati precedentemente calcolati. Nella programmazione funzionale, questo tipo di memorizzazione nella cache è chiamata memoizzazione. Il codice che segue mostra una funzione di ordine superiore, memoizza(), che accetta una funzione come suo argomento e restituisce una versione memoizzata della funzione:

// Restituisce una versione memoizzata di f.
// Funziona solo se gli argomenti di f hanno tutti rappresentazioni stringa distinte.
function memoizza(f) {
    const cache = new Map();  // Cache dei valori memorizzata nella closure.

    return function(...args) {
        // Crea una versione stringa degli argomenti da usare come chiave della cache.
        let chiave = args.length + args.join("+");

        if (cache.has(chiave)) {
            return cache.get(chiave);
        } 
        else {
            let risultato = f.apply(this, args);
            cache.set(chiave, risultato);
            return risultato;
        }
    };
}

La funzione memoizza() crea un nuovo oggetto da usare come cache e assegna questo oggetto a una variabile locale in modo che sia privata per (nella closure di) la funzione restituita. La funzione restituita converte il suo array di argomenti in una stringa e usa quella stringa come nome di proprietà per l'oggetto cache. Se un valore esiste nella cache, lo restituisce direttamente. Altrimenti, chiama la funzione specificata per calcolare il valore per questi argomenti, memorizza nella cache quel valore e lo restituisce.

Ecco come si potrebbe usare memoizza() applicata a una funzione che calcola il massimo comune divisore (MCD) di due numeri e che usa l'algoritmo di Euclide:

// Restituisce il Massimo Comun Divisore di due interi
// usando l'algoritmo di Euclide.
function mcd(a, b) {  // Il controllo del tipo per a e b è stato omesso
    if (a < b) {           // Si assicura che a >= b quando si inizia
        [a, b] = [b, a];   // Assegnazione destrutturata per scambiare variabili
    }

    while (b !== 0) {       // Questo è l'algoritmo di Euclide per MCD
        [a, b] = [b, a % b];
    }

    return a;
}

const mcdmemo = memoizza(mcd);

// => 17
mcdmemo(85, 187);

// Si noti che quando si scrive una funzione ricorsiva che si andrà a memoizzare,
// tipicamente si vuole ricorrere alla versione memoizzata, non all'originale.
const fattoriale = memoizza(function(n) {
    return (n <= 1) ? 1 : n * fattoriale(n - 1);
});

// => 120: memorizza nella cache anche i valori per 4, 3, 2 e 1.
fattoriale(5);