In questo post parleremo di

Design Pattern e perché abbiamo bisogno di Iteratori

Standard Library e degli Algoritmi che creano sinergia con gli Iteratori

che creano sinergia con gli Iteratori Nomi parlanti e dell'importanza di creare un contesto nel codice che scriviamo

Se ci siamo mai affacciati al mondo dell'Ingegneria del Software, prima o poi ci siamo imbattuti nei famigerati Design Pattern. Resi celebri dall'omonimo libro della Gang of Four, i Design Pattern rappresentano soluzioni riutilizzabili per problemi ricorrenti di frequente nel contesto del design del software. In molti riconoscono a questo libro il vanto di aver introdotto una terminologia comune nell'ambito dell'Ingegneria del Software per definire con un unico nome un complesso sistema di classi e delle loro interazioni. Tra questi Design Pattern, figurano gli Iteratori.

Gli iteratori sono classi che ci consentono di esplorare tutti gli elementi contenuti all'interno di una struttura dati senza preoccuparci di come questa struttura dati sia organizzata in memoria. Sappiamo tutti che per esplorare il contenuto di un array a ci basta sapere quanti elementi contiene per imbastire un ciclo for e accedere ad ogni elemento attraverso l'espressione a[i]. Diverso è se abbiamo a che fare con una lista a puntatori. In questo caso la cosa si fa più difficile. Dobbiamo tenere conto di puntatori a null e di utilizzare un cursore per muoverci verso l'elemento successivo. Ancora più complesso è invece esplorare un albero binario di ricerca. Per questo tipo di struttura dobbiamo ricorrere a routine note come esplorazioni ordinate dell'albero. E poi ancora! Hash-table, buffer circolari, grafi... AAAAH!

E' chiaro che in questo panorama tra il pensare di "sommare tutti gli elementi del container" e riuscire a scrivere effettivamente un pezzo di codice per farlo, ci stanno dei bei grattacapi. Ma diciamo che siamo bravi e scriviamo una bella funzione che esplori la nostra struttura dati e produca il risultato atteso. Siamo contenti, ma accade poi che per questioni di efficienza ci sia richiesto di cambiare la struttura dati. E' tutto da rifare.

Iteratori in C++

La soluzione a questo problema è il motivo per cui gli iteratori sono diventati così celebri, tanto da permeare una miriade di linguaggi di programmazione. Attraverso la definizione di un'interfaccia comune, lo sviluppatore può così dimenticarsi dei dettagli implementativi delle strutture dati che usa e può esplorare gli elementi del container indipendentemente dalla sua natura.

In C++, ogni container della standard library espone il suo iteratore attraverso la coppia di metodi begin() e end(). L'iteratore ritornato da begin() punta al primo elemento del container, mentre l'iteratore ritornato da end() si comporta da sentinella, rappresentando concettualmente un iteratore all'elemento appena dopo dell'ultimo. Da qui il motivo per cui dereferenziare l'iteratore restituito da end() può dare esiti spiacevoli.

Attraverso l'astrazione degli iteratori possiamo risolvere il nostro problema di sommare gli elementi contenuti in un container una volta per tutte.

Fantastico. Abbiamo il nostro pezzo di codice che si comporta in maniera generica in funzione del tipo di container. Ma il linguaggio fa uno step in più. Si osserva infatti che una volta che si prendono in considerazione gli iteratori, il codice che scriviamo inizia ad avere un certo pattern ricorrente.

Parti dal primo elemento - auto it = begin() Fai un ciclo for fino all'ultimo elemento - it != end() Esegui qualche operazione Avanza l'iteratore all'elemento successivo - ++it

Questa porzione di codice ricorrente, seppur necessaria, non massimizza la leggibilità del codice. Infatti, uno sviluppatore che incontri questo blocco, dovrebbe ancora dedicare una certa attenzione per capirne lo scopo. Va bene, si esplorano tutti gli elementi di un container, ma per fare cosa? Sarebbe bello poter fattorizzare tutto questo codice prolisso e far balzare immediatamente agli occhi del lettore l'obiettivo dell'esplorazione. Ci piacerebbe poter dare un nome che comunichi l'intento dello sviluppatore. Da qui nascono gli algoritmi.

(Massimizzare la leggibilità del nostro codice è fondamentale per aumentarne la manutenibilità e ridurre lo sforzo necessario per comprenderlo, magari in fase di debug. Ne abbiamo parlato anche nelle scorse settimane: Costanti e Immutabilità in C++)

#include <algorithm>

Gli algoritmi in C++, definiti negli header <algorithm> e <numeric>, sono il naturale complemento degli iteratori. Forniscono a noi sviluppatori un coltellino svizzero di operazioni da eseguire su una coppia di iteratori. Non solo. Proprio come i Design Pattern, forniscono un vocabolario comune di operazioni eseguibili su qualsiasi container, portando ad un aumento drammatico della leggibilità. Invece di dover tenere sotto controllo iteratori, ciclo for e corpo del ciclo per capire la semantica dell'operazione eseguita, gli algoritmi ci consentono di partire da una cosa molto umana, il nome dell'operazione, così da accendere subito nel nostro cervello la lampadina giusta. E il vantaggio in termini di carico d'attenzione è tanto maggiore quanto l'operazione da svolgere diventa articolata.

Facciamo un esempio. Consideriamo un'implementazione naive di una classe che rappresenti una serie storica. Una serie storica è una sequenza di valori, ordinati nel tempo. Il problema delle serie storiche è che a volte presentano valori mancanti. A titolo d'esempio rappresentiamo questa situazione con uno std::pair<double, bool>. Diciamo poi di voler essere interessati a calcolare la media dei valori della serie storica. Naturalmente, dobbiamo considerare nel calcolo della media solo i valori presenti, ed ignorare quelli mancanti.

In prima battuta potremmo giungere ad una situazione così.

Neanche troppo male. Alla fine, sappiamo tutti cosa fa la funzione media, ma proviamo ad uscire dal contesto (che è quello che tipicamente accade quando leggiamo il codice scritto da qualcun altro, magari con nomi di funzioni poco parlanti). Incontriamo due variabili, inizializzate, ma non-const, quindi assumiamo vengano modificate in seguito. Poi incontriamo un ciclo for. Che cosa farà? Mah, vedremo, intanto so che cicla su tutti gli elementi. Poi trovo la prima condizione. Leggo la documentazione e imparo che se il secondo campo di Element è false, il valore è mancante. Ok poi sommo il valore dell'elemento presente a sum e incremento n. Infine calcolo la media.

Perfetto ce l'abbiamo fatta. Ma possiamo fare di meglio? Come diventerebbe la lettura del nostro codice se usassimo gli algoritmi?

Mettiamoci sempre nella condizione di non avere un contesto. La prima cosa che incontro è una variabile, n. Non è const, assumo che cambierà. Primo vantaggio, la variabile è solo una invece che due come prima. Meno carico mentale. Poi incontro una funzione lambda. Ha un nome, sum_non_missing. Ed esegue lo stesso corpo dell'esempio precedente. Cosa cambia? La prima cosa che ho incontrato è un nome. Mi è stato dato un contesto su cui ragionare. Sum_non_missing... Mi aspetto bene o male delle somme e un controllo se il dato sia presente o meno. Me lo aspetto prima ancora di leggere il codice. Ho già una semantica che sottende quello che leggerò. Poi proseguiamo e incontriamo la seconda variabile sum, che viene inizializzata direttamente col valore per cui è stata introdotta, dunque è dichiarata const. E il suo contenuto? E' reso esplicito dal nome dell'algoritmo: accumulate. I parametri passati completano il racconto. Accumula gli elementi di s dall'inizio alla fine, partendo da zero e solo per quelli non missing. Infine, calcola la media.

Parlo per me, ma se mi trovassi a leggere un codice così, sarei eternamente grato al suo sviluppatore per avermi fatto risparmiare tempo ed energie grazie ad un uso sapiente di nomi descrittivi e incapsulamento dei dettagli implementativi.

Std::accumulate è solo uno degli innumerevoli algoritmi disponibili nella standard library. Possiamo trovare l'elenco completo su cppreference.com.

Abusiamo di questi algoritmi. Scriviamone di nuovi nelle nostre code base. Facciamo il possibile di dare nomi esplicativi a porzioni di codice grazie a iteratori e funzione lambda. E' meglio per noi quando dovremo fixare un bug per codice scritto mesi prima. E' meglio per i nostri collaboratori che troveranno traccia delle idee che avevamo in testa quando abbiamo scritto quel pezzo di codice.

Grazie per aver letto fino a qui. Buona settimana e buon lavoro! 😊