Come non uccidere un progetto con cattive pratiche di codifica?
Bartosz Slysz
Software Engineer
Molti programmatori all'inizio della loro carriera considerano l'argomento della denominazione di variabili, funzioni, file e altri componenti come poco importante. Di conseguenza, la loro logica di progettazione è spesso corretta: gli algoritmi vengono eseguiti rapidamente e producono l'effetto desiderato, mentre possono essere a malapena leggibili. In questo articolo cercherò di descrivere brevemente ciò che dovrebbe guidarci quando nominiamo i diversi elementi del codice e come non passare da un estremo all'altro.
Perché trascurare la fase di denominazione prolunga (in alcuni casi enormemente) lo sviluppo del progetto?
Supponiamo che voi e il vostro squadra si stanno impadronendo del codice da altri programmatori. Il progetto ereditate è stato sviluppato senza alcun amore: funzionava bene, ma ogni suo singolo elemento avrebbe potuto essere scritto in modo molto migliore.
Quando si parla di architettura, nel caso dell'ereditarietà del codice si scatena quasi sempre l'odio e la rabbia dei programmatori che l'hanno ottenuta. A volte ciò è dovuto all'uso di tecnologie in via di estinzione (o estinte), a volte al modo sbagliato di pensare all'applicazione all'inizio dello sviluppo e a volte semplicemente alla mancanza di conoscenza del programmatore responsabile.
In ogni caso, con il passare del tempo è possibile arrivare a un punto in cui i programmatori si arrabbiano con le architetture e le tecnologie. Dopotutto, ogni applicazione richiede la riscrittura di alcune parti o la modifica di parti specifiche dopo qualche tempo: è naturale. Ma il problema che farà diventare grigi i capelli dei programmatori è la difficoltà di leggere e comprendere il codice che hanno ereditato.
Soprattutto in casi estremi, quando le variabili sono nominate con singole lettere senza senso e le funzioni sono un improvviso slancio di creatività, che non è in alcun modo coerente con il resto dell'applicazione, i programmatori possono andare fuori di testa. In tal caso, qualsiasi analisi del codice che potrebbe essere eseguita in modo rapido ed efficiente con una denominazione corretta richiede un'analisi aggiuntiva degli algoritmi responsabili della produzione del risultato della funzione, ad esempio. E tale analisi, per quanto poco appariscente, fa perdere un'enorme quantità di tempo.
Implementare nuove funzionalità in diverse parti dell'applicazione significa passare attraverso l'incubo dell'analisi, dopo qualche tempo si deve tornare al codice e analizzarlo di nuovo perché le sue intenzioni non sono chiare, e il tempo precedente speso a cercare di capire il suo funzionamento è stato uno spreco perché non si ricorda più qual era il suo scopo.
E così, veniamo risucchiati in un tornado di disordine che regna nell'applicazione e consuma lentamente ogni partecipante al suo sviluppo. I programmatori odiano il progetto, i project manager odiano spiegare perché i tempi di sviluppo iniziano ad aumentare costantemente e il cliente perde fiducia e si arrabbia perché nulla va secondo i piani.
Come evitarlo?
Ammettiamolo: alcune cose non possono essere saltate. Se abbiamo scelto determinate tecnologie all'inizio del progetto, dobbiamo essere consapevoli che con il tempo smetteranno di essere supportate o che sempre meno programmatori saranno esperti di tecnologie di qualche anno fa che stanno lentamente diventando obsolete. Alcune librerie, nei loro aggiornamenti, richiedono modifiche più o meno complesse al codice, che spesso comportano un vortice di dipendenze in cui ci si può incastrare ancora di più.
D'altra parte, non è uno scenario così nero; certo, le tecnologie stanno invecchiando, ma il fattore che rallenta decisamente i tempi di sviluppo dei progetti che le coinvolgono è il codice in gran parte brutto. E naturalmente dobbiamo citare il libro di Robert C. Martin: si tratta di una bibbia per i programmatori, in cui l'autore presenta molte buone pratiche e principi da seguire per creare codice che aspiri alla perfezione.
La cosa fondamentale quando si nominano le variabili è trasmettere in modo chiaro e semplice il loro intento. Sembra abbastanza semplice, ma a volte viene trascurato o ignorato da molte persone. Un buon nome specificherà cosa esattamente la variabile deve memorizzare o cosa deve fare la funzione: non può essere un nome troppo generico, ma d'altra parte non può diventare una lunga brodaglia la cui semplice lettura provoca una sfida per il cervello. Dopo un po' di tempo con un codice di buona qualità, si sperimenta l'effetto immersione, in cui si è in grado di organizzare inconsciamente la denominazione e il passaggio dei dati alla funzione in modo tale che il tutto non lasci illusioni su quale sia l'intenzione che la guida e quale sia il risultato atteso dalla sua chiamata.
Un'altra cosa che si può trovare in JavaScript, tra l'altro, è un tentativo di sovraottimizzare il codice, che in molti casi lo rende illeggibile. È normale che alcuni algoritmi richiedano una cura particolare, che spesso riflette il fatto che l'intenzione del codice può essere un po' più contorta. Tuttavia, i casi in cui abbiamo bisogno di ottimizzazioni eccessive sono estremamente rari, o almeno quelli in cui il nostro codice è sporco. È importante ricordare che molte ottimizzazioni legate al linguaggio avvengono a un livello di astrazione leggermente inferiore; per esempio, il motore V8 può, con un numero sufficiente di iterazioni, velocizzare in modo significativo i cicli. L'aspetto da sottolineare è che viviamo nel XXI secolo e non scriviamo programmi per la missione Apollo 13. Abbiamo molto più spazio di manovra. Abbiamo molto più spazio di manovra per quanto riguarda le risorse: sono lì per essere usate (preferibilmente in modo ragionevole :>).
A volte suddividere il codice in parti dà davvero molto. Quando le operazioni formano una catena il cui scopo è eseguire azioni responsabili di una specifica modifica dei dati, è facile perdersi. Pertanto, in modo semplice, invece di fare tutto in un'unica stringa, si possono suddividere in singoli elementi le singole parti del codice che sono responsabili di una determinata cosa. Questo non solo renderà chiaro l'intento delle singole operazioni, ma permetterà anche di testare frammenti di codice che sono responsabili di una sola cosa e che possono essere facilmente riutilizzati.
Alcuni esempi pratici
Credo che la rappresentazione più accurata di alcune delle affermazioni precedenti consista nel mostrare come funzionano in pratica: in questo paragrafo, cercherò di delineare alcune cattive pratiche di codice che possono essere più o meno trasformate in buone pratiche. Indicherò cosa disturba la leggibilità del codice in alcuni momenti e come evitarlo.
La rovina delle variabili a lettera singola
Una pratica terribile che purtroppo è abbastanza comune, anche nelle università, è quella di nominare le variabili con una sola lettera. È difficile non essere d'accordo sul fatto che a volte si tratta di una soluzione piuttosto comoda: si evita di pensare inutilmente a come determinare lo scopo di una variabile e, invece di usare diversi o più caratteri per nominarla, si usa una sola lettera - ad esempio, i, j, k.
Paradossalmente, alcune definizioni di queste variabili sono dotate di un commento molto più lungo, che determina ciò che l'autore aveva in mente.
Un buon esempio potrebbe essere quello di rappresentare l'iterazione su un array bidimensionale che contiene i valori corrispondenti all'intersezione di colonna e riga.
const array = [[0, 1, 2], [3, 4, 5], [6, 7, 8]];
// piuttosto male
per (let i = 0; i < array[i]; i++) {
for (let j = 0; j < array[i][j]; j++) {
// questo è il contenuto, ma ogni volta che i e j vengono usati devo tornare indietro e analizzare per cosa sono usati
}
}
// ancora brutto, ma divertente
let i; // riga
let j; // colonna
per (i = 0; i < array[i]; i++) {
for (j = 0; j < array[i][j]; j++) {
// questo è il contenuto, ma ogni volta che i e j vengono usati devo tornare indietro e controllare i commenti per cosa sono usati
}
}
// molto meglio
const rowCount = array.length;
for (let rowIndex = 0; rowIndex < rowCount; rowIndex++) {
const row = array[rowIndex];
const columnCount = row.length;
per (let columnIndex = 0; columnIndex < columnCount; columnIndex++) {
const column = row[columnIndex];
// qualcuno ha dei dubbi su cosa sia cosa?
}
}
Sovraottimizzazione subdola
Un bel giorno, mi sono imbattuto in un codice altamente sofisticato scritto da un ingegnere del software. Questo ingegnere aveva capito che l'invio dei permessi dell'utente come stringhe che specificano azioni specifiche poteva essere notevolmente ottimizzato utilizzando alcuni trucchi a livello di bit.
Probabilmente una soluzione del genere andrebbe bene se il target fosse il Commodore 64, ma lo scopo di questo codice era una semplice applicazione web, scritta in JS. È arrivato il momento di superare questa stranezza: Diciamo che un utente ha solo quattro opzioni nell'intero sistema per modificare i contenuti: creare, leggere, aggiornare, cancellare. È abbastanza naturale inviare questi permessi in forma JSON come chiavi di un oggetto con stati o come array.
Tuttavia, il nostro ingegnere intelligente ha notato che il numero quattro è un valore magico nella presentazione binaria e lo ha risolto come segue:
L'intera tabella delle capacità ha 16 righe, ne ho elencate solo 4 per rendere l'idea della creazione di questi permessi. La lettura dei permessi avviene come segue:
Quello che vedete qui sopra non è Codice WebAssembly. Non voglio essere frainteso: queste ottimizzazioni sono normali per i sistemi in cui certe cose devono richiedere poco tempo o poca memoria (o entrambi). Le applicazioni Web, d'altra parte, non sono assolutamente un luogo in cui queste sovra-ottimizzazioni hanno un senso assoluto. Non voglio generalizzare, ma nel lavoro degli sviluppatori front-end raramente vengono eseguite operazioni più complesse che raggiungono il livello di astrazione dei bit.
Semplicemente non è leggibile, e un programmatore in grado di fare un'analisi di tale codice si chiederà sicuramente quali vantaggi invisibili abbia questa soluzione e cosa possa essere danneggiato quando la team di sviluppo vuole riscriverlo per trovare una soluzione più ragionevole.
Inoltre, sospetto che l'invio dei permessi come oggetto ordinario consentirebbe a un programmatore di leggere l'intento in 1-2 secondi, mentre l'analisi dell'intera cosa dall'inizio richiederà almeno alcuni minuti. Ci saranno diversi programmatori nel progetto, ognuno dei quali dovrà imbattersi in questo pezzo di codice e dovrà analizzarlo più volte, perché dopo un po' di tempo dimenticherà cosa sta succedendo. Vale la pena salvare quei pochi byte? A mio parere, no.
Dividere e conquistare
Sviluppo web è in rapida crescita e non c'è alcuna indicazione che qualcosa cambierà presto in questo senso. Dobbiamo ammettere che di recente la responsabilità degli sviluppatori front-end è aumentata in modo significativo: essi hanno assunto la parte di logica responsabile della presentazione dei dati nell'interfaccia utente.
A volte questa logica è semplice e gli oggetti forniti dall'API hanno una struttura semplice e leggibile. A volte, però, richiedono diversi tipi di mappatura, ordinamento e altre operazioni per adattarli ai diversi punti della pagina. E questo è il punto in cui possiamo facilmente cadere nella palude.
Molte volte mi è capitato di rendere praticamente illeggibili i dati delle operazioni che stavo eseguendo. Nonostante l'uso corretto dei metodi di array e la corretta denominazione delle variabili, le catene di operazioni in alcuni punti hanno quasi perso il contesto di ciò che volevo ottenere. Inoltre, alcune di queste operazioni a volte dovevano essere usate altrove e a volte erano globali o abbastanza sofisticate da richiedere la scrittura di test.
Lo so, lo so: non si tratta di un banale pezzo di codice che illustra facilmente ciò che voglio trasmettere. E so anche che la complessità computazionale dei due esempi è leggermente diversa, ma in 99% dei casi non c'è bisogno di preoccuparsene. La differenza tra gli algoritmi è semplice: entrambi preparano una mappa dei luoghi e dei proprietari dei dispositivi.
Il primo prepara questa mappa due volte, mentre il secondo la prepara una sola volta. L'esempio più semplice che ci dimostra che il secondo algoritmo è più portabile sta nel fatto che dobbiamo cambiare la logica di creazione di questa mappa per il primo e, ad esempio, fare l'esclusione di alcune località o altre cose strane chiamate business logic. Nel caso del secondo algoritmo, si modifica solo il modo di ottenere la mappa, mentre tutto il resto delle modifiche ai dati che avvengono nelle righe successive rimane invariato. Nel caso del primo algoritmo, dobbiamo modificare ogni tentativo di preparazione della mappa.
E questo è solo un esempio: in pratica, ci sono molti casi in cui è necessario trasformare o rifattorizzare un certo modello di dati per l'intera applicazione.
Il modo migliore per evitare di stare al passo con i vari cambiamenti aziendali è preparare strumenti globali che ci permettano di estrarre le informazioni di interesse in modo abbastanza generico. Anche a costo di quei 2-3 millisecondi che potremmo perdere a causa della de-ottimizzazione.
Sintesi
Essere un programmatore è una professione come un'altra: ogni giorno impariamo cose nuove, spesso commettendo molti errori. La cosa più importante è imparare da questi errori, migliorare nella propria professione e non ripeterli in futuro. Non si può credere al mito che il nostro lavoro sia sempre impeccabile. È possibile, tuttavia, basandosi sulle esperienze degli altri, ridurre i difetti di conseguenza.
Spero che la lettura di questo articolo vi aiuti ad evitare almeno alcuni dei problemi che si presentano. cattive pratiche di codifica che ho sperimentato nel mio lavoro. In caso di domande sulle migliori pratiche di codice, è possibile contattare Equipaggio The Codest per consultare i vostri dubbi.