Già nelle prime fasi di sviluppo di un nuovo progetto ci si può rendere conto che perdere il controllo sulla code base è fin troppo facile. Certo, l'esperienza ci può aiutare ad anticipare i cambi di rotta che il progetto subirà, oppure tutti i modi in cui la nostra web app potrà espandersi in futuro. Purtroppo però, tutto questo non sarà sufficiente sul lungo periodo: un codice modificato troppo spesso può infatti diventare caotico e confuso, mentre un codice scritto e mai ritoccato potrebbe semplicemente trasformarsi in una misteriosa scatola chiusa che nessuno ha il coraggio di toccare. Di solito, la tendenza in questi casi è quella di aggiungere strati su strati, sovrapponendo al codice vecchio del codice nuovo che lo estende.
Si può fare di meglio? Decisamente sì. Se leggendo il paragrafo qui sopra ti si è stagliata davanti agli occhi cubitale la parola refactoring, hai capito perfettamente di cosa parla questo articolo.
Il refactoring: cos'è
Sia che il codice invecchi per le troppe modifiche, sia che invecchi perché nessuno lo tocca da tempo, bisogna prendere atto di una cosa: un progetto che probabilmente richiederà modifiche future (cioè quasi ogni progetto) è un progetto che ha bisogno di refactoring. Possiamo quindi considerare il refactoring come un trattamento anti-aging per i nostri progetti. Ovviamente questa è solo una delle ragioni per cui bisognerebbe, durante lo sviluppo e possibilmente ad ogni occasione (vedi la regola del boy scout a fine articolo) fare del refactoring periodico sui nostri progetti.
Ma diamoci anche una definizione un po' più ufficiale:
Refactoring è l'attività di rendere il codice più controllabile senza modificarne il comportamento.
Alcuni esempi di refactoring
Ragioniamo in piccolo con questa semplice funzione:
function range1(min, max) {
// proceed only if interval is valid
if (min <= max) {
const toReturn = [] // the array to be returned
// fill the array
for (let i = min; i <= max; i++) {
toReturn.push(i)
}
return toReturn // return array once filled
}
}
È piccola e semplice, i commenti ci dicono bene o male che cosa fa, lo scopo della funzione è chiaro. Serve un refactoring? Sì. Perché? Perché la nostra funzione, in qualsiasi progetto nel mondo reale, è destinata a cambiare e cambiare; i commenti non verranno aggiornati e diventeranno fuorvianti, finché la nostra funzione verrà dimenticata (ma resterà in produzione!). Un bel giorno, qualche nostro collega sarà chiamato a modificarla di nuovo. Vediamo quindi come possiamo fare in modo che la nostra funzione range invecchi bene e non faccia impazzire i nostri colleghi (o i noi stessi) del futuro.
function range2(from, to) {
ensureValidRange()
const rangeInstance = []
fillRangeInstance(rangeInstance)
return rangeInstance
function ensureValidRange() {
const isValid = from <= to
if (!isValid) throw new Error('Invalid range')
}
function fillRangeInstance(instance) {
for (let i = from; i <= to; i++) {
instance.push(i)
}
}
}
Facciamo un velocissimo diff tra range1 e range2:
- I nomi dei parametri sono esattamente quelli che metteresti in una frase: "un intervallo da 3 a 5"
- La nomenclatura rende inutili i commenti, quindi li ho rimossi
- Cosa? Funzioni dichiarate dopo il return statement? Ho usato l'hoisting* per lasciare le implementazioni in fondo, in modo da facilitare la lettura del comportamento
- L'if che in range1 stabilisce se procedere o meno, in range2 stabilisce se spiegare al chiamante che qualcosa è andato storto*, e lo fa senza creare nuovi strati di indentazione, che riduce la leggibilità
- La costante isValid è puramente esplicativa; serve a rendere leggibile la condizione sottostante: "Se (il range) non è valido, scatena un errore"
*Che cos'è il JavaScript Hoisting
Il JavaScript Hoisting è la dichiarazione di una variabile dopo che questa è già stata utilizzata. (vedi: https://www.w3schools.com/js/js_hoisting.asp)
Infatti in JavaScript funzioni e variabili dichiarate come function e var vengono "sollevate", cioè possono essere dichiarate sotto al codice che le usa. Questo non vale per funzioni e variabili dichiarate come const o let.
Questo approccio è utile per seguire la regola dell'articolo di giornale: l'ideale è che il codice segua verticalmente un passaggio progressivo da astrazione a dettaglio, in modo che il lettore possa interrompere la lettura quando ha raggiunto la quantità di dettaglio che gli serve. Unica eccezione a questo principio sono le costanti esplicative, che vanno necessariamente dichiarate prima di essere usate, e infatti dovrebbero essere dichiarate sempre e solo immediatamente prima di essere usate!
*Come scrivere gli if statement
- Quando un if stabilisce se uscire da una funzione, viene definito guardia
- A me personalmente, piace scrivere le guardie su una sola riga
- A tutti gli effetti qui c'è stato un cambio di comportamento: in caso di intervallo non valido, range1 ritorna undefined, range2 invece scatena un'eccezione. Fu vero refactoring? Ai posteri…
A questo punto potremmo ritenerci soddisfatti, ma possiamo ancora migliorare le cose modificando leggermente l'approccio:
function range3(from, to) {
ensureValidRange()
const length = to – from + 1
return Array.from({ length }, mapToRange)
function ensureValidRange() {
const isValid = from <= to
if (!isValid) throw new Error('Invalid range')
}
function mapToRange(_, index) {
return index + from
}
}
Vediamo cosa è cambiato. Anzitutto, il grande elefante nella stanza: nessun array è stato maltrattato nel corso di range3. Nessun array istanziato vuoto e poi riempito. A tutti gli effetti, l'unico array che viene creato è quello nel return statement*. Questa è un'enorme differenza nell'approccio.
In realtà nel return statement vengono creati due array: uno vuoto della lunghezza desiderata, e uno che mappa il precedente con i valori del range.
Un'altra differenza è che per creare l'array è stata usata un'api nativa del linguaggio; ci arriviamo a breve.
Infine, anche in questo caso, abbiamo separato il comportamento dall'implementazione, rendendo più leggibile l'istruzione di ritorno: cosa sto restituendo al chiamante? un Array di lunghezza length mappato a range, ovviamente!
Perché il refactoring è importante
Seguendo il refactoring della nostra funzione range, ci si potrebbe chiedere: ma se uno sviluppatore esperto conosce già il modo migliore per implementare una funzionalità, a che serve continuare a ritoccarla?
In parte, l'abbiamo già detto: per mantenerne il controllo nel tempo. Quanta confidenza ho di poter modificare questa funzione senza romperla? Se ne ho tanta, vuol dire che ho il codice sotto controllo.
Ma la ragione principale per cui il refactoring del codice è importante è la seguente: il codice invecchia perché il mondo esterno evolve. Competenze nuove emergono continuamente nel corso dello sviluppo; i linguaggi e le librerie si evolvono per facilitare la vita agli sviluppatori. Anche i requisiti possono emergere nel tempo; la stessa architettura dei progetti può subire variazioni qualora quella precedente si riveli inadeguata a gestire nuove casistiche. Quando questo avviene, è importante trovarsi davanti un codice uniforme, leggibile e aggiornato.
Ovviamente, non è così che gli esseri umani ragionano quando programmano: il pensiero salta di palo in frasca, alterna convinzioni e dubbi, cerca i collegamenti tra le cose. Per questo è sostanzialmente ingiusto pretendere che la prima stesura di una funzione o di una classe sia quella buona. D'altra parte bisogna entrare in quest'ottica: quando il codice funziona, hai fatto metà del lavoro, hai spiegato alla macchina cosa fare; l'altra metà è spiegare ai tuoi colleghi sviluppatori cosa fa la macchina.
In cosa consiste un buon refactoring
Come si evince da tutti gli esempi di refactoring che si possono trovare nella letteratura, è chiaro che l'80% di un buon refactoring consiste in:
- Come vengono nominate le cose
- Dove vengono messe le cose
- La coerenza
La prima è facile: il nome giusto per una variabile, funzione o classe è quello che starebbe bene in una frase che usa quella variabile, funzione o classe.
La seconda è altrettanto facile: la regola dell'articolo di giornale generalmente è una garanzia di qualità.
La terza è difficilissima: il motivo per cui non si finisce mai di refactorare è che il contesto cambia sempre, la nostra nomenclatura e il nostro stile devono adeguarsi per rimanere coerenti. Qui bisogna saper fare dei compromessi, e per i più perfezionisti, a volte accettare che il codice non sarà mai in ordine come vorremmo. Alcune cose restano complesse, anche nella loro versione più semplificata.
Per non ammalarsi di refactoring e renderla una pratica sostenibile nel corso della vita di un progetto, una buona pratica è seguire la regola del boy scout:
Lascia sempre un posto più pulito e in ordine di come l'hai trovato.
Se tutti gli sviluppatori cogliessero ogni occasione per fare piccoli aggiustamenti, vivremmo certamente in un mondo con meno bachi.