Scrivere codice... per umani

Consigli e riflessioni su leggibilità del codice e produttività.

Qualche settimana fa ho avuto l’occasione di parlare di leggibilità del codice in un webinar preparato per i ragazzi di TomorrowDevs.

È stato davvero interessante scegliere gli argomenti da presentare nel talk; soprattutto perché mi ha dato la possibilità di riflettere sul mio modo di scrivere codice e su alcuni principi basilari che ormai do per scontati, ma che vale la pena riesaminare, di tanto in tanto.

Questo post prende il titolo proprio dal webinar che ho tenuto per TomorrowDevs ed è una trascrizione più o meno libera di quanto ho raccontato in quell’occasione.

Scrivere codice leggibile: è davvero così importante?

La leggibilità del codice è un tema troppo spesso trascurato, sottovalutato, considerato qualcosa di superficiale.

Eppure, in un mondo come quello del software, in cui tutto gira attorno alle macchine, è sempre bene ricordare quanto la programmazione sia un’attività strettamente legata all’essere umano: alla creatività, alla capacità di pensare fuori dagli schemi, alla necessità di risolvere problemi concreti.

Proprio per questo è importante tenere a mente che il codice dev’essere, prima di tutto, comprensibile agli esseri umani.

“Any fool can write code that a computer can understand. Good programmers write code that humans can understand.”

— Martin Fowler

Non basta dunque che il nostro codice sia privo di errori di sintassi, che funzioni (qualunque cosa questo significhi nel contesto di business nel quale operiamo), o che faccia esattamente quello che ci aspettavamo facesse.

Serve uno sforzo in più, perché nella programmazione la forma influenza fortemente la sostanza. Il modo in cui scriviamo codice ha conseguenze concrete sul nostro lavoro e su quello dei nostri colleghi, in termini di manutenibilità, comprensione e produttività.

Lavorare in team, sempre

Lavorare in team è una costante nella vita di un programmatore. Capita spesso di dover mettere mano a codice scritto da altri; così come può capitare che i nostri colleghi debbano prendere in carico progetti avviati da noi.

Il nostro modo di scrivere codice finisce, inevitabilmente, per influenzare la vita di chi lavora con noi. Dobbiamo quindi sforzarci di scrivere codice comprensibile.

Lavorare sempre come se qualcun altro dovesse leggere il nostro codice. Anche quando lavoriamo da soli.

Potremmo infatti dover rimettere mano a un nostro progetto, messo in soffitta per mesi, e non avere il coraggio di guardare…

3 aspetti fondamentali

In questo post ci concentreremo su tre concetti basilari, ma significativi, da applicare se vogliamo scrivere del buon codice:

  • assegnare nomi chiari a variabili e metodi (o funzioni);
  • commentare correttamente, solo ciò che serve;
  • formattare e organizzare il codice in modo coerente.

Si tratta di aspetti in apparenza semplici e “superficiali”, ma che, se curati nel modo corretto, possono fare la differenza in una codebase.

Le parole sono importanti

“Design and programming are human activities; forget that and all is lost.”

— Bjarne Stroustrup

Nei mesi scorsi, un po’ inaspettatamente e per vicende decisamente poco affini al mondo dei fumetti, è tornato alla ribalta un noto personaggio Disney: Pippo.

Il tutto è legato ai disservizi del portale INPS, dovuti all’elevato numero di accessi per le richieste d’indennità Covid-19.

Una delle problematiche riscontrate, probabilmente la meno importante, ha suscitato sdegno e disapprovazione da parte di molti programmatori.

Si trattava della presenza, nel codice del frontend del portale, di una variabile di nome pippo.

Premesso che l’utilizzo di nomi come pippo, pluto o paperino ha più che altro una valenza goliardica, la scelta di nomi poco chiari per variabili o funzioni dovrebbe in ogni caso far scattare un campanello d’allarme nella nostra testa.

Se non riusciamo ad assegnare un nome preciso è perché probabilmente non abbiamo ben chiara la logica su cui stiamo lavorando.

Chiariti eventuali dubbi e incomprensioni, i criteri da seguire sono pochi e semplici:

  • scegliere nomi brevi, ma espressivi, per le variabili;
  • scegliere verbi il più specifici possibile, per le funzioni.

Un esempio può essere utile a chiarire il concetto:

fun processNumbers(numbers: IntArray) {
    val retVal = numbers.filter { it % 2 == 0 }
    retVal.forEach { println(it) }
}

Per quanto questa funzione sia semplice, i nomi processNumbers e retVal ci dicono davvero poco su cosa dovremmo aspettarci da questa fantomatica “elaborazione”.

Con pochi passaggi possiamo rendere il codice più chiaro, sia per chi dovrà utilizzare la funzione, sia per chi dovrà aggiornarla o correggerla in futuro:

fun printEvenIntegers(integers: IntArray) {
    val evenIntegers = integers.filter { it % 2 == 0 }
    evenIntegers.forEach { integer -> println(integer) }
}

Sostituendo processNumbers, retVal e numbers con nomi più significativi, la funzione diventa subito meno ambigua.

Un altro esempio riguarda l’utilizzo poco attento delle variabili:

fun veryLongFunction(): List<String> {
    var fruits = listOf("mela", "arancia", "fragola", "banana", "kiwi")

    ...

    fruits = fruits.filter { fruit -> "a" in fruit }

    ...

    fruits = fruits.sortedBy { it.length }

    ...

    return fruits
}

Tralasciando il nome della funzione, volutamente generico in questo caso, il problema di questo blocco di codice sta nella riassegnazione della variabile fruits.

Immaginando una funzione di un centinaio di righe, già di per sé un campanello d’allarme, che per qualche motivo non possa essere “spezzata” in funzioni più elementari, potrebbe diventare presto difficile capire cosa contenga esattamente la variabile fruits in uno specifico punto della funzione.

L’utilizzo di variabili con uno scope più limitato può invece fare chiarezza sull’algoritmo implementato nel blocco di codice:

fun veryLongFunction(): List<String> {
    val fruits = listOf("mela", "arancia", "fragola", "banana", "kiwi")

    ...

    val filteredFruits = fruits.filter { fruit -> "a" in fruit }

    ...

    val sortedFruits = filteredFruits.sortedBy { it.length }

    ...

    return sortedFruits
}

Alcune considerazioni finali:

  • il nome di una variabile o di una funzione deve veicolare quante più informazioni possibile;
  • quanto più ampio è lo scope di utilizzo di una variabile, tanto più specifico deve essere il suo nome;
  • usare variabili “riepilogative” per fornire informazioni su quanto sta succedendo nella nostra funzione.

No comment

“Code should be written to minimize the time it would take for someone else to understand it.”

— D. Boswell & T. Foucher

Commenti sì, commenti no. Qualcuno ritiene che il buon codice non richieda commenti. Altri pensano che, al contrario, aggiungendo molti commenti, si ottengano codebase più chiare.

Come al solito, la verità sta nel mezzo; e il problema, nella maggior parte dei casi, non è la quantità dei commenti, ma la loro qualità: il valore che aggiungono alla codebase in termini di informazioni.

Vale quindi la pena partire da un esempio che mostri alcuni casi in cui i commenti non dovrebbero essere utilizzati:

/**
 * Print all the even integers found in the input array.
 */
fun printEvenIntegers(integers: IntArray) {
    // take only the even numbers
    val evenIntegers = integers.filter { it % 2 == 0 }

    // print a line for every integer in evenIntegers
    evenIntegers.forEach { integer -> println(integer) }
}

Esaminando con attenzione il precedente frammento di codice, si nota che tutti i commenti aggiunti alla funzione printEvenIntegers sono, di fatto, inutili.

Non aggiungono alcun valore al codice, in quanto non forniscono alcuna informazione in più rispetto a quelle direttamente deducibili dal codice stesso.

Possiamo quindi rimuovere del tutto quei commenti dal codice, oppure possiamo scegliere di sostituirli con commenti utili, come in questo esempio:

/**
 * Print all the even integers found in the input array.
 * Numbers are printed out in the same order they were found,
 * without sorting them.
 */
fun printEvenIntegers(integers: IntArray) {
    // TODO: add a length limit for the input array
    val evenIntegers = integers.filter { it % 2 == 0 }
    // The client explicitly asked us not to sort the numbers.
    evenIntegers.forEach { integer -> println(integer) }
}

In questo caso stiamo aggiungendo molte informazioni in più; ma soprattutto stiamo fornendo informazioni che sarebbe impossibile reperire leggendo solamente il codice.

Innanzitutto abbiamo migliorato la descrizione della funzione in modo da chiarire eventuali dubbi sul valore di ritorno della stessa. Abbiamo poi aggiunto dettagli che potrebbero tornare utili a chi si troverà a lavorare in futuro sulla funzione: un TODO per annotare possibili evoluzioni e una nota che chiarisca una scelta implementativa.

In definitiva, meglio usare i commenti per:

  • fornire informazioni non direttamente deducibili dal codice;
  • registrare i pensieri del programmatore: scelte, intenzioni ed eventuali dubbi;
  • fornire uno storico e/o una panoramica della codebase.

Evitiamo invece di:

  • commentare codice incomprensibile: sforziamoci invece di correggerlo e renderlo più chiaro;
  • commentare riga per riga: il codice dovrebbe “parlare” da sé.

L’apparenza è sostanza

“Indeed, the ratio of time spent reading versus writing is well over 10 to 1. We are constantly reading old code as part of the effort to write new code. [Therefore,] making it easy to read makes it easier to write.”

— Robert C. Martin

Spesso dimentichiamo che, oltre a essere scrittori di codice, siamo anche e soprattutto lettori di codice.

Se i calcoli di Robert Martin sono corretti, infatti, durante la nostra vita da programmatori passiamo la maggior parte del nostro tempo a leggere codice già esistente.

Alla luce di questa spiazzante rivelazione, appare ancora più chiaro quanto la forma in cui presentiamo il codice sia importante per il nostro lavoro.

L’analogia con la scrittura di un libro può essere più o meno calzante, ma certamente anche in una codebase è utile mantenere uno stile e una coerenza che facilitino lettura e comprensione.

Una prima serie di principi da seguire potrebbe quindi essere la seguente:

  • non scrivere blocchi di codice troppo lunghi;
  • mantenere coerenza nelle spaziature e negli stili;
  • seguire (possibilmente) convenzioni e stili definiti dal linguaggio utilizzato.

Nell’ultimo punto ho messo il possibilmente tra parentesi perché, oltre agli stili propri del linguaggio di programmazione con cui stiamo lavorando, è importante anche seguire le regole relative al code style definite dal team.

Possono infatti esistere convenzioni stabilite internamente all’azienda o al gruppo di lavoro, derivanti dall’esperienza o da principi condivisi.

È dunque importante conoscere queste regole e, se si ritiene opportuno, metterle in discussione; ma una volta comprese le ragioni alla base della loro esistenza, è fondamentale attenersi a esse, in modo da mantenere la coerenza all’interno della codebase.

Vediamo ora un esempio per capire meglio come una corretta formattazione possa migliorare la comprensione di una porzione di codice:

/**
 * Input format: "Count: <n> items"
 */
fun incrementTextCount(textCount: String, incrementBy: Int): String {
    val inputTokens = textCount.split(" ")
    if (inputTokens.size != 3)
        throw IllegalArgumentException("Input has invalid format")
    val itemsCount = inputTokens[1].toInt()
    val updatedItemsCount = itemsCount + incrementBy
    return "Count: $updatedItemsCount items"
}

La funzione incrementTextCount si occupa di incrementare un contatore presente all’interno di una stringa, il cui formato viene indicato nel commento iniziale.

Il codice riportato funziona correttamente e non presenta particolari problemi a livello di naming. L’aspetto che può essere migliorato è la formattazione. A prima vista, il corpo della funzione, per quanto breve, appare come un muro di codice, e questo può rendere meno comprensibile la logica implementata.

Un primo miglioramento in termini di leggibilità si può ottenere sfruttando i commenti e una spaziatura che suddivida il codice in base alla logica implementata:

/**
 * Input format: "Count: <n> items"
 */
fun incrementTextCount(textCount: String, incrementBy: Int): String {
    // Parse input string
    val inputTokens = textCount.split(" ")
    if (inputTokens.size != 3)
        throw IllegalArgumentException("Input has invalid format")
    val itemsCount = inputTokens[1].toInt()

    val updatedItemsCount = itemsCount + incrementBy

    // Format output string
    return "Count: $updatedItemsCount items"
}

Il passo successivo è estrarre le logiche di parsing e formattazione in funzioni indipendenti.

Questo non solo rende la funzione più comprensibile, ma permette anche di riutilizzare logiche che potrebbero essere condivise con altre funzioni:

/**
 * Input format: "Count: <n> items"
 */
fun incrementTextCount(textCount: String, incrementBy: Int): String =
    parseTextCounter(textCount)
        .plus(incrementBy)
        .formatCounterTextValue()

fun parseTextCounter(textCount: String): Int {
    val inputTokens = textCount.split(" ")
    if (inputTokens.size != 3)
        throw IllegalArgumentException("Input has invalid format")
    return inputTokens[1].toInt()
}

fun Int.formatCounterTextValue() = "Count: $this items"

Alcune considerazioni finali:

  • suddividere il codice in “paragrafi”, come se fosse un testo, usando commenti o definendo funzioni;
  • mantenere in tutta la codebase le stesse convenzioni, rispettando eventuali stili definiti dal team;
  • utilizzare in modo intelligente gli idiomi specifici del linguaggio di programmazione per aggiungere ulteriore espressività al codice.

Letture consigliate

Infine, qualche suggerimento di lettura:

  • The Art of Readable Code, D. Boswell e T. Foucher
  • Clean Code: A Handbook of Agile Software Craftsmanship, R. C. Martin
  • Refactoring: Improving the Design of Existing Code, M. Fowler
  • The Pragmatic Programmer: From Journeyman to Master, A. Hunt e D. Thomas