La memoization è una tecnica di ottimizzazione che consiste nel salvare i risultati di una funzione in base ai suoi argomenti, per riutilizzarli nelle chiamate successive senza doverli ricalcolare.
In JavaScript questa tecnica è particolarmente utile quando abbiamo funzioni pure e costose dal punto di vista computazionale, come algoritmi ricorsivi, elaborazioni numeriche o trasformazioni di dati pesanti.
Concetto di base della memoization
L'idea può essere riassunta in tre passi:
- Creare una struttura dati (cache) dove salvare i risultati calcolati.
- Quando la funzione viene chiamata, controllare se esiste già un risultato per quegli argomenti.
- Se il risultato esiste, restituirlo direttamente; altrimenti calcolarlo, salvarlo in cache e restituirlo.
In pseudo-codice:
function memoizedF(args) {
if (cache contiene args) {
return cache[args];
}
const result = f(args);
cache[args] = result;
return result;
}
Una prima implementazione semplice
Partiamo da un caso semplice: una funzione che prende un solo argomento primitivo (numero o stringa). Useremo un oggetto come cache.
function memoize(fn) {
const cache = {};
return function (arg) {
if (arg in cache) {
return cache[arg];
}
const result = fn(arg);
cache[arg] = result;
return result;
};
}
Usiamola con una funzione costosa, ad esempio una versione ricorsiva della successione di Fibonacci.
function slowFibonacci(n) {
if (n <= 1) {
return n;
}
return slowFibonacci(n - 1) + slowFibonacci(n - 2);
}
const memoizedFibonacci = memoize(slowFibonacci);
console.log(memoizedFibonacci(40)); // Prima volta: molto lenta
console.log(memoizedFibonacci(40)); // Seconda volta: istantanea
Alla prima chiamata con 40, la funzione esegue tutti i calcoli necessari.
Alla seconda chiamata con lo stesso argomento, la funzione legge il risultato direttamente dalla cache.
Gestire più argomenti
Nella pratica, molte funzioni hanno più argomenti. Una strategia comune è quella di
costruire una chiave univoca a partire dalla lista di argomenti, ad esempio usando
JSON.stringify.
function memoize(fn) {
const cache = {};
return function (...args) {
const key = JSON.stringify(args);
if (key in cache) {
return cache[key];
}
const result = fn.apply(this, args);
cache[key] = result;
return result;
};
}
Ora possiamo memoizzare funzioni con più parametri.
function heavyComputation(a, b) {
// Simuliamo qualcosa di costoso
for (let i = 0; i < 1e8; i++) {}
return a * b + a - b;
}
const memoizedHeavy = memoize(heavyComputation);
console.time("first");
console.log(memoizedHeavy(10, 20)); // Calcolo completo
console.timeEnd("first");
console.time("second");
console.log(memoizedHeavy(10, 20)); // Letto dalla cache
console.timeEnd("second");
La differenza di tempo tra la prima e la seconda chiamata può essere enorme, specialmente se la funzione è davvero costosa.
Usare Map invece di oggetti semplici
Gli oggetti JavaScript funzionano bene come cache, ma presentano alcune limitazioni.
Una alternativa più robusta è usare Map.
Mappermette chiavi di qualunque tipo, non solo stringhe.- Non ha collisioni con proprietà ereditate dalla prototype chain.
- Offre metodi chiari come
get,setehas.
function memoize(fn) {
const cache = new Map();
return function (...args) {
const key = JSON.stringify(args);
if (cache.has(key)) {
return cache.get(key);
}
const result = fn.apply(this, args);
cache.set(key, result);
return result;
};
}
Memoization con chiavi oggetto tramite Map annidate
Se vogliamo usare oggetti come chiave senza convertirli in stringhe,
possiamo sfruttare Map o WeakMap annidate per mappare ogni
argomento a un livello diverso.
function memoizeWithObjectKeys(fn) {
const cache = new Map();
return function (...args) {
let currentCache = cache;
for (const arg of args) {
if (!currentCache.has(arg)) {
currentCache.set(arg, new Map());
}
currentCache = currentCache.get(arg);
}
if (currentCache.has("result")) {
return currentCache.get("result");
}
const result = fn.apply(this, args);
currentCache.set("result", result);
return result;
};
}
Questa tecnica evita di dover serializzare gli argomenti, a costo di gestire una struttura di cache un po' più complessa.
Usare WeakMap per evitare memory leak
Quando le chiavi sono oggetti che potrebbero non servire più, è utile usare
WeakMap, che permette al garbage collector di liberare la memoria
quando le chiavi non sono più referenziate altrove.
function memoizeWithWeakMap(fn) {
const cache = new WeakMap();
return function (obj) {
if (cache.has(obj)) {
return cache.get(obj);
}
const result = fn.call(this, obj);
cache.set(obj, result);
return result;
};
}
Questo approccio funziona bene per funzioni che accettano un singolo oggetto come argomento, ad esempio per calcolare e memorizzare proprietà derivate di un modello.
Memoization e funzioni asincrone
Possiamo memoizzare anche funzioni asincrone che restituiscono una
Promise. In questo caso salveremo la promise nella cache.
function memoizeAsync(fn) {
const cache = new Map();
return async function (...args) {
const key = JSON.stringify(args);
if (cache.has(key)) {
return cache.get(key);
}
const promise = fn.apply(this, args);
cache.set(key, promise);
return promise;
};
}
In questo modo, se la funzione viene chiamata più volte con gli stessi argomenti mentre la richiesta è ancora in corso, tutte le chiamate condivideranno la stessa promise.
Quando usare la memoization
La memoization non è sempre la scelta giusta. È particolarmente efficace quando:
- La funzione è costosa da eseguire.
- La funzione è deterministica: stessi argomenti, stesso risultato.
- Gli argomenti non cambiano spesso o ci sono molte chiamate ripetute con gli stessi valori.
- La memoria disponibile è sufficiente a mantenere in cache i risultati.
Non conviene invece memoizzare funzioni che:
- Sono molto veloci rispetto al costo di gestione della cache.
- Dipendono da effetti esterni (tempo, rete, stato globale) e non sono prevedibili.
- Ricevono argomenti sempre diversi o molto variabili, riutilizzati raramente.
Limitare la dimensione della cache
Per evitare che la cache cresca indefinitamente, possiamo implementare una cache limitata.
Un approccio semplice è quello di tenere al massimo maxSize elementi e cancellare il più vecchio.
function memoizeWithLimit(fn, maxSize = 50) {
const cache = new Map();
return function (...args) {
const key = JSON.stringify(args);
if (cache.has(key)) {
const value = cache.get(key);
cache.delete(key);
cache.set(key, value);
return value;
}
const result = fn.apply(this, args);
cache.set(key, result);
if (cache.size > maxSize) {
const firstKey = cache.keys().next().value;
cache.delete(firstKey);
}
return result;
};
}
Questo schema implementa una cache approssimativamente di tipo LRU (Least Recently Used): gli elementi usati più di recente restano in cache più a lungo.
Riassunto e best practice
Per usare la memoization in modo efficace in JavaScript, è utile tenere a mente alcuni principi:
- Applicarla a funzioni pure e costose.
- Usare
MapoWeakMapquando le chiavi non sono semplici stringhe. - Evitare di serializzare oggetti grandi se non strettamente necessario.
- Considerare un limite alla dimensione della cache per evitare consumi eccessivi di memoria.
- Per funzioni asincrone, memoizzare le promise per evitare richieste duplicate.
Con queste tecniche puoi costruire facilmente utilità di memoization riusabili e adattarle alle esigenze della tua applicazione JavaScript, migliorando le prestazioni in modo pulito e controllato.