Introduzione
Le funzioni in JavaScript possono essere utilizzate in modi avanzati che vanno oltre la semplice esecuzione di codice. Comprendere questi concetti permette di scrivere codice più prevedibile, modulare e potente.
In questo capitolo si approfondiscono:
- Pure functions e side effects: funzioni prevedibili che non modificano lo stato esterno
- Factory functions: funzioni che producono altre funzioni preconfigurate
- Closures: come le funzioni “ricordano” le variabili dell’ambiente circostante
- Recursion: funzioni che chiamano se stesse per risolvere problemi complessi
Pure Functions e Side Effects
Cos’è una Pure Function
Una pure function è una funzione che:
- Sempre produce lo stesso output per lo stesso input: dati gli stessi argomenti, restituisce sempre lo stesso risultato
- Non introduce side effects: non modifica nulla al di fuori della funzione stessa
Esempio di Pure Function
function add(num1, num2) { return num1 + num2;}
console.log(add(1, 5)); // 6console.log(add(12, 15)); // 27Questa funzione è pura perché:
- Per gli stessi argomenti (
1e5), restituisce sempre6 - Non modifica variabili esterne
- Non esegue operazioni imprevedibili (come chiamate di rete o generazione di numeri casuali)
Esempio di Impure Function
Una funzione che non è pura può violare entrambi i principi:
function addRandom(num1) { return num1 + Math.random();}
console.log(addRandom(5)); // Risultato diverso ad ogni esecuzioneQuesta funzione non è pura perché il risultato non è prevedibile: anche con lo stesso input (5), l’output varia ad ogni chiamata.
Side Effects
Un side effect (effetto collaterale) è qualsiasi modifica che una funzione apporta all’ambiente esterno. Questo include:
- Modificare variabili globali
- Eseguire richieste HTTP
- Scrivere nel database
- Modificare oggetti o array passati come argomenti
let previousResult = 0;
function addWithSideEffect(num1, num2) { const sum = num1 + num2; previousResult = sum; // Side effect: modifica variabile esterna return sum;}
console.log(addWithSideEffect(5, 3)); // 8console.log(previousResult); // 8 (variabile modificata)La funzione addWithSideEffect non è pura perché modifica previousResult, una variabile definita al di fuori della funzione.
Side Effects con Oggetti e Array
Quando si passano oggetti o array come argomenti, bisogna fare attenzione: se vengono modificati all’interno della funzione, si modifica anche l’originale perché gli oggetti sono passati per riferimento.
const hobbies = ['sports', 'cooking'];
function printHobbies(h) { console.log(h); h.push('new hobby'); // Side effect: modifica l'array originale}
printHobbies(hobbies);console.log(hobbies); // ['sports', 'cooking', 'new hobby']L’array hobbies viene modificato perché quando si passa un oggetto o un array, si passa un riferimento alla stessa struttura in memoria, non una copia.
Quando Usare Pure Functions
In generale, è buona pratica preferire funzioni pure quando possibile perché:
- Sono prevedibili: il comportamento è sempre lo stesso
- Sono facili da testare: non dipendono da stato esterno
- Sono facili da comprendere: non nascondono comportamenti inattesi
Tuttavia, in un’applicazione reale è impossibile evitare completamente i side effects. Funzioni che:
- Configurano event listener
- Inviano dati a un server
- Aggiornano il DOM
necessariamente introducono side effects, ed è accettabile.
L’obiettivo è minimizzare il numero di funzioni impure e rendere esplicito quando una funzione introduce side effects attraverso un nome descrittivo:
// Nome chiaro: si capisce che invia dati (side effect)function sendDataToServer(data) { // Invia HTTP request}
// Nome che suggerisce una funzione purafunction add(a, b) { return a + b; // Non dovrebbe avere side effects}Factory Functions
Cos’è una Factory Function
Una factory function è una funzione che produce e restituisce un’altra funzione. L’idea è creare funzioni preconfigurate che possono essere riutilizzate senza dover ripetere parametri comuni.
Problema: Ripetizione di Parametri
Immagina di avere una funzione che calcola le tasse:
function calculateTax(taxRate, amount) { return amount * taxRate;}
// Devo sempre passare il taxRate ogni voltaconst vatAmount = calculateTax(0.19, 100); // IVA al 19%const incomeTax = calculateTax(0.25, 100); // Imposta sul reddito al 25%Se in diverse parti dell’applicazione si calcola sempre la stessa tassa (ad esempio l’IVA al 19%), bisogna ripetere 0.19 ogni volta.
Soluzione: Factory Function
Una factory function permette di creare funzioni preconfigurate:
function createTaxCalculator(tax) { // Funzione interna che usa il parametro 'tax' della funzione esterna function calculateTax(amount) { return amount * tax; }
// Restituisce la funzione, non il risultato della chiamata return calculateTax;}
// Creo funzioni preconfigurateconst calculateVatAmount = createTaxCalculator(0.19);const calculateIncomeTaxAmount = createTaxCalculator(0.25);
// Ora posso usarle senza ripetere il taxRateconsole.log(calculateVatAmount(100)); // 19console.log(calculateVatAmount(200)); // 38console.log(calculateIncomeTaxAmount(100)); // 25Come Funziona
createTaxCalculatorviene chiamata con un valore pertax(ad esempio0.19)- All’interno viene creata una funzione
calculateTaxche può accedere ataxgrazie allo scope - La funzione interna viene restituita (non eseguita)
- La funzione restituita “ricorda” il valore di
taxcon cui è stata creata
Ogni chiamata a createTaxCalculator crea una nuova funzione indipendente con il proprio valore di tax memorizzato.
Vantaggi delle Factory Functions
- Riduzione della ripetizione: parametri comuni vengono configurati una sola volta
- Codice più pulito: le chiamate successive sono più semplici
- Maggiore flessibilità: si possono creare diverse varianti della stessa funzione
Closures
Cos’è una Closure
In JavaScript, ogni funzione è una closure. Una closure è una funzione che “chiude” (memorizza) l’ambiente lessicale circostante, inclusi tutte le variabili e costanti accessibili nel momento in cui la funzione viene creata.
Scope e Lexical Environment
Il concetto di closure è strettamente legato a quello di scope (ambito) e lexical environment (ambiente lessicale).
Ogni funzione ha il proprio lexical environment che contiene:
- Le variabili e i parametri definiti nella funzione stessa
- Un riferimento all’ambiente lessicale esterno (funzione contenitore o globale)
const userName = 'Vito';
function greetUser() { console.log('Hi ' + userName);}
greetUser(); // "Hi Vito"La funzione greetUser può accedere a userName perché ha un riferimento all’ambiente lessicale globale dove userName è definito.
Comportamento con Variabili Modificate
Quando una funzione accede a una variabile esterna, non ne fa una copia del valore, ma mantiene un riferimento alla variabile stessa:
let userName = 'Vito';
function greetUser() { console.log('Hi ' + userName);}
userName = 'Manuel'; // Modifico la variabile dopo la creazione della funzionegreetUser(); // "Hi Manuel" (usa il valore aggiornato)La funzione usa il valore corrente della variabile quando viene eseguita, non il valore che aveva quando la funzione è stata creata.
Shadowing: Variabili con lo Stesso Nome
Se una variabile viene definita sia all’interno che all’esterno di una funzione con lo stesso nome, la variabile interna “nasconde” quella esterna (shadowing):
const name = 'Vito';
function greetUser() { const name = 'Anna'; // Variabile locale con lo stesso nome console.log('Hi ' + name);}
greetUser(); // "Hi Anna" (usa la variabile interna)Quando la funzione viene eseguita, JavaScript:
- Cerca la variabile nel proprio ambiente lessicale
- Se non la trova, cerca nell’ambiente esterno
- Continua fino all’ambiente globale
In questo caso, trova name nell’ambiente interno e non cerca oltre.
Closure nelle Factory Functions
Le factory functions sfruttano le closure per “memorizzare” i parametri della funzione esterna:
function createTaxCalculator(tax) { // La funzione interna "chiude" sul parametro 'tax' function calculateTax(amount) { return amount * tax; // 'tax' viene dall'ambiente esterno }
return calculateTax;}
const calculateVat = createTaxCalculator(0.19);Quando createTaxCalculator(0.19) viene eseguita:
- Viene creato un nuovo ambiente lessicale con
tax = 0.19 - La funzione
calculateTaxviene creata e registra questo ambiente - Anche dopo che
createTaxCalculatortermina,calculateTaxmantiene accesso atax
Questo comportamento non è scontato: in alcuni linguaggi, le variabili locali vengono eliminate quando la funzione termina. In JavaScript, le closure garantiscono che le variabili necessarie vengano mantenute in memoria.
Variabili Globali vs Parametri
C’è una differenza importante tra variabili globali e parametri di funzioni esterne:
let multiplier = 1.1;
function createTaxCalculator(tax) { function calculateTax(amount) { return amount * tax * multiplier; // 'tax' è parametro, 'multiplier' è globale } return calculateTax;}
const calc = createTaxCalculator(0.19);multiplier = 1.2; // Modifico la variabile globaleconsole.log(calc(100)); // Usa multiplier = 1.2 (valore corrente)taxviene “memorizzato” quando la funzione viene creata perché è parte dell’ambiente della funzione esternamultiplierviene letto dal valore corrente quando la funzione viene eseguita perché è nell’ambiente globale
Memoria e Ottimizzazioni
Potrebbe sembrare che le closure consumino molta memoria: ogni funzione potrebbe memorizzare tutte le variabili dell’ambiente circostante.
In realtà, i motori JavaScript moderni sono ottimizzati:
- Tracciano quali variabili vengono effettivamente utilizzate
- Eliminano le variabili non utilizzate quando è sicuro farlo
- Mantengono solo ciò che è necessario per le funzioni che potrebbero essere chiamate
Non è necessario gestire manualmente la memoria: JavaScript lo fa in modo intelligente.
Scope vs Lexical Environment
I termini scope e lexical environment sono spesso usati in modo intercambiabile:
- Scope: concetto più generale che descrive dove una variabile è accessibile
- Lexical environment: implementazione tecnica dello scope in JavaScript
Non solo le funzioni creano ambienti lessicali, ma anche:
- Blocchi
{} - Istruzioni
if - Cicli
for
Tutti questi creano un proprio ambiente con le proprie variabili.
IIFE (Immediately Invoked Function Expression)
Pattern IIFE
Un IIFE (Immediately Invoked Function Expression) è una funzione che viene definita e eseguita immediatamente:
(function() { const age = 30; console.log(age); // 30})();
console.log(age); // Error: "age is not defined"La funzione è racchiusa tra parentesi () e viene immediatamente chiamata con () alla fine.
Perché Esiste Questo Pattern
Questo pattern era comune quando si usava var, che non ha block scope ma solo function scope e global scope:
// Con var, le variabili erano globali se non dentro una funzionevar age = 30; // Globale
// IIFE creava uno scope isolato(function() { var age = 30; // Locale alla funzione})();Pattern Moderno
Con let e const, che hanno block scope, non è più necessario usare IIFE. Si può semplicemente usare un blocco:
{ const age = 30; console.log(age); // 30}
console.log(age); // Error: "age is not defined"Gli IIFE sono ancora validi ma meno comuni nel codice moderno.
Recursion
Cos’è la Recursion
La recursion (ricorsione) è un pattern in cui una funzione chiama se stessa. Questo approccio può semplificare la soluzione di problemi complessi e rendere il codice più conciso.
Esempio Base: Calcolo della Potenza
Immagina di voler calcolare x elevato a n (ad esempio, 2³ = 8).
Implementazione con ciclo for:
function powerOf(x, n) { let result = 1; for (let i = 0; i < n; i++) { result *= x; } return result;}
console.log(powerOf(2, 3)); // 8Implementazione ricorsiva:
function powerOf(x, n) { if (n === 1) { return x; // Condizione di uscita } return x * powerOf(x, n - 1); // Chiamata ricorsiva}
console.log(powerOf(2, 3)); // 8Come Funziona la Recursion
Quando si chiama powerOf(2, 3):
- Prima chiamata:
n = 3, non è1, quindi calcola2 * powerOf(2, 2) - Seconda chiamata:
n = 2, non è1, quindi calcola2 * powerOf(2, 1) - Terza chiamata:
n = 1, restituisce2 - Ritorno:
2 * 2 = 4, poi2 * 4 = 8
Ogni chiamata crea un nuovo frame nello stack delle chiamate (call stack). La funzione attende che la chiamata ricorsiva completi prima di restituire il risultato.
Condizione di Uscita
È fondamentale avere una condizione di uscita (base case) che ferma la ricorsione. Senza di essa, la funzione chiamerebbe se stessa all’infinito, causando un errore di stack overflow:
function infiniteRecursion() { infiniteRecursion(); // Chiama se stessa senza mai fermarsi}Sintassi Compatta
La funzione ricorsiva può essere scritta in modo ancora più compatto usando un operatore ternario:
function powerOf(x, n) { return n === 1 ? x : x * powerOf(x, n - 1);}Quando Usare la Recursion
La recursion è particolarmente utile quando:
- Si lavora con strutture dati annidate di profondità sconosciuta
- Il problema può essere scomposto in sottoproblemi identici
- Una soluzione iterativa richiederebbe molti loop annidati
Recursion con Strutture Dati Annidate
Problema: Strutture Annidate Sconosciute
Immagina di avere una struttura dati che rappresenta persone e i loro amici, dove ogni amico può avere altri amici, e così via:
const myself = { name: 'Vito', friends: [ { name: 'Manuel', friends: [ { name: 'Chris', friends: [] } ] }, { name: 'Julia', friends: [] } ]};Questa struttura potrebbe essere profonda quanto necessario. Non si conosce a priori quanti livelli di annidamento ci sono.
Problema con i Loop
Con un approccio iterativo, servirebbero loop annidati:
function printFriendNames(person) { const collectedNames = [];
// Primo livello for (const friend of person.friends) { collectedNames.push(friend.name);
// Secondo livello (ma quanti livelli servono?) if (friend.friends) { for (const friendOfFriend of friend.friends) { collectedNames.push(friendOfFriend.name); // E così via... } } }
return collectedNames;}Questo approccio:
- Richiede di conoscere la profondità massima
- Diventa difficile da leggere con molti livelli
- Non funziona se la profondità è variabile o sconosciuta
Soluzione con Recursion
La recursion risolve elegantemente questo problema:
function getFriendNames(person) { // Condizione di uscita: se non ci sono amici, restituisci array vuoto if (!person.friends || person.friends.length === 0) { return []; }
const collectedNames = [];
// Itera attraverso gli amici diretti for (const friend of person.friends) { collectedNames.push(friend.name);
// Chiamata ricorsiva: ottieni gli amici degli amici const nestedFriends = getFriendNames(friend);
// Aggiungi gli amici annidati usando lo spread operator collectedNames.push(...nestedFriends); }
return collectedNames;}
console.log(getFriendNames(myself));// ['Manuel', 'Chris', 'Julia']Come Funziona
- La funzione controlla se la persona ha amici
- Se non ne ha, restituisce un array vuoto (condizione di uscita)
- Per ogni amico diretto:
- Aggiunge il nome alla lista
- Chiama ricorsivamente se stessa per ottenere gli amici di quell’amico
- Aggiunge i risultati alla lista usando lo spread operator
Ogni chiamata ricorsiva gestisce un livello di profondità, e la funzione si ferma automaticamente quando non ci sono più amici da esplorare.
Esempio con Più Livelli
const myself = { name: 'Vito', friends: [ { name: 'Manuel', friends: [ { name: 'Chris', friends: [ { name: 'Harry', friends: [] }, { name: 'Amelia', friends: [] } ] } ] }, { name: 'Julia', friends: [] } ]};
console.log(getFriendNames(myself));// ['Manuel', 'Chris', 'Harry', 'Amelia', 'Julia']La funzione gestisce automaticamente qualsiasi livello di annidamento senza modifiche al codice.
Vantaggi della Recursion
- Flessibilità: gestisce strutture di profondità variabile
- Codice più pulito: una singola funzione invece di molti loop annidati
- Manutenibilità: più facile da modificare e comprendere
Debugging della Recursion
La recursion può essere difficile da seguire mentalmente. Gli strumenti di sviluppo del browser aiutano:
- Imposta un breakpoint nella funzione ricorsiva
- Usa il call stack per vedere tutte le chiamate attive
- Step through l’esecuzione per capire il flusso
Questo aiuta a visualizzare come ogni chiamata si aggiunge allo stack e come i valori vengono restituiti.
Riepilogo
-
Pure functions: funzioni che producono sempre lo stesso output per lo stesso input e non introducono side effects. Preferibili quando possibile per maggiore prevedibilità e testabilità.
-
Side effects: modifiche all’ambiente esterno (variabili globali, oggetti passati per riferimento, richieste HTTP). Da minimizzare ma necessari in applicazioni reali.
-
Factory functions: funzioni che producono altre funzioni preconfigurate. Utili per ridurre la ripetizione di parametri comuni.
-
Closures: ogni funzione in JavaScript è una closure che memorizza l’ambiente lessicale circostante. Permette alle funzioni di accedere a variabili esterne anche dopo che la funzione contenitore è terminata.
-
Lexical environment: ambiente che contiene variabili e riferimenti agli ambienti esterni. Creato da funzioni, blocchi,
if,for, ecc. -
Shadowing: quando una variabile interna nasconde una variabile esterna con lo stesso nome. La ricerca delle variabili parte dall’ambiente interno verso quello esterno.
-
Recursion: pattern in cui una funzione chiama se stessa. Richiede una condizione di uscita per evitare loop infiniti. Particolarmente utile per strutture dati annidate di profondità sconosciuta.
-
Call stack: stack che tiene traccia delle chiamate di funzione attive. Durante la recursion, ogni chiamata aggiunge un frame allo stack fino alla condizione di uscita.