Ottimizzare il codice: ridurre le iterazioni
domenica, 10 maggio 2020Un occhio di riguardo al codice può aiutare a limitare i problemi di performance al crescere dei dati
Foto di Marc-Olivier Jodoin su Unsplash
Con l’avvento di ES6 sono stati introdotti tantissimi metodi utili per operare su liste (array) come filter
, map
e reduce
. Questi metodi permettono di leggere meglio il codice e arrivare direttamente all’obiettivo grazie ad una sintassi succinta e chiara ma possono anche nascondere alcune insidie.
Eccitati dalla novità e dalla praticità di queste funzionalità potremmo trascurare l’impatto del codice sulle performance.
Per rendere la mia considerazione più chiara possibile provo a proporvi un esempio pratico nel quale è necessario elaborare i dati da un elenco di libri. Gli stessi dati saranno infatti utilizzati per:
- rappresentare graficamente i libri in una lista;
- mostrare il totale di libri per ogni categoria;
- mostrare una lista testuale dei libri contrassegnati come “preferiti”;
Un libro è rappresentato da un oggetto con le seguenti proprietà:
const bookSample = {
favorite: true, // valore boolean, "true" se è tra i preferiti
title: "Addio alle armi", // il titolo del libro
category: "guerra", // la categoria in cui rientra il libro
}
Ecco una prima implementazione non ottimizzata che utilizza ES6 e React per la creazione dei componenti.
// con map trasformo l'elenco di oggetti in un elenco di componenti che potrò stampare al rendering
const bookComponents = bookList.map(book => (
<Book title={book.title} category={book.category} />
))
// con filter creo una nuova lista ridotta ai soli libri segnati come preferiti
const favoriteList = bookList.filter(book => book.favorite)
// con reduce raccolgo i contatori per categoria in un oggetto
const categoryCounter = bookList.reduce((counter, book) => {
// estraggo la categoria del libro
const { category } = book
// se il contatore non ha ancora la categoria la aggiungo
if (!counter.hasOwnProperty(category)) counter[book.category] = 0
// incremento il contatore della categoria del libro
counter[category] = counter[category] + 1
return counter
}, {})
Qui trovate l’esempio completo:
Il problema
Come potete vedere la stessa lista di libri è passata in rassegna almeno tre volte (nell’esempio completo compare una quarta volta nel rendering). Dato che spesso questi dati non sono sotto il nostro diretto controllo può capire che nel tempo questi aumentino drasticamente (a meno di applicare strategie di ottimizzazione lato server). Nel dubbio è quindi buona norma analizzare il proprio codice dopo una prima stesura e valutare di ottimizzarlo prima di dichiarare il lavoro terminato.
Al crescere del numero dei libri il tempo richiesto per eseguire il codice aumenterà di almeno 3 volte per ogni libro aggiunto, una per ogni ciclo che scorre la lista.
La soluzione
Proviamo dunque ad analizzare i diversi cicli e verifichiamo se possiano unirne alcuni: il ciclo di reduce
è particolarmente pratico per ospitare anche le altre operazioni perché possiamo estenderne l’accomulatore che sarà restituito alla fine delle iterazioni come risultato della funzione.
Il metodo reduce
esegue una funzione per ogni elemento della lista passandole come argomenti il singolo elemento e una variabile detta “accomulatore” condivisa tra le iterazioni. L’accomulatore può contenere un valore di partenza a scelta (intero, stringa, array o oggetto): se prima era un oggetto che memorizzava i contatori delle categorie ora possiamo estenderlo aggiungendo un livello annidamento.
Ecco il nuovo oggetto di partenza:
const acc = {
// spostiamo qui il vecchio contatore delle categorie
categoryCounter: {},
// salveremo qui l'elenco dei preferiti
favoriteList: [],
// qui invece accumularemo i componenti
bookComponents: [],
}
all’interno della funzione ora abbiamo tutti e tre i contenitori che possiamo popolare spostando le logiche dentro al reducer.
bookList.reduce(
(acc, book) => {
// estraggo la categoria del libro
const { category } = book
const { categoryCounter, favoriteList, bookComponents } = acc
// aggiungo i preferiti alla lista
if (book.favorite) favoriteList.push(book)
// salvo il componente nella lista dedicata
bookComponents.push(<Book title={book.title} category={book.category} />)
// se il contatore non ha ancora la categoria la aggiungo
if (!categoryCounter.hasOwnProperty(category))
categoryCounter[book.category] = 0
// aumento di uno il contatore della categoria del libro
categoryCounter[category] = categoryCounter[category] + 1
return acc
},
{ categoryCounter: {}, favoriteList: [], bookComponents: [] }
)
Infine possiamo esplodere l’oggetto ritornato da reduce
in diverse variabili utilizzando al destrutturazione dell’oggetto di ES6.
const { categoryCounter, favoriteList, bookComponents } = bookList.reduce(
// ...
Ed ecco l’esempio aggiornato:
Una nota finale sulle performance
Innanzitutto bisogna specificare che il numero di libri deve essere ben superiore a quattro per avere impatti sulle performance: l’impatto sarà visibile al crescere dei dati e influenzato ma molti altri fattori. Anche il resto del codice può avere la sua responsabilità nel rallentare l’esecuzione del codice e, guardando al di fuori del codice, browser e computer hanno un ruolo ancora più importante.
Detto questo va anche detto che ripetere più volte lo stesso ciclo in punti diversi del codice ha un impatto relativamente limitato. Quando si studia l’impatto del proprio codice sul tempo impiegato è abitudine adottare una notazione detta O-grande o “big O” che aiuta a definire la complessità in termini di tempo (o di spazio) di un algoritmo.
La complessità del nostro esempio è passata O(4n)
a O(2n)
dove n
è il numero di input ricevuti. In genere questo tipo di complessità viene semplificata in O(n)
perché il moltiplicatore sui grandi numeri ha un impatto basso.
Se avessimo annidato due cicli della stessa lista la complessità sarebbe stata esponenziale e quindi O(n^2)
con un impatto decisamente superiore al crescere degli input.
Se vi affascinano questo tipo di considerazioni vi consiglio di approfondire cercando uno dei tanti articoli a riguardo: in futuro questo tipo di analisi potrebbe aiutarvi a ridurre drasticamente i tempi di caricamento della vostra applicazione.