Dr. Carlo Pescio
Principi, Tecniche e Trucchi

Pubblicato su Computer Programming No. 64


Trucchi, segreti, feature non documentate attirano i programmatori in modo irresistibile. In questo articolo vedremo alcune tecniche di programmazione molto diffuse, ma rese ormai obsolete da innovazioni nei compilatori, nell’hardware, nei linguaggi o nei sistemi operativi. Ed ovviamente, non mancherà qualche suggerimento su come evitare le instabilità da trucco.

Una regola ben nota agli autori di libri ed articoli sulla programmazione, speaker di conferenze, esperti e guru dell’informatica, è che vi sono alcune parole magiche capaci di attirare i programmatori come le mosche al miele. Ad esempio, è sufficiente scrivere "trucchi", "segreti", o "non documentati" in copertina per raddoppiare le vendite di un libro. In seconda battuta, possiamo usare termini come "meandri", "nascosti", o "internals" ed ottenere comunque un buon risultato. È inutile nascondere che la possibilità di accedere a qualche conoscenza arcana, che ci consenta di elevare i nostri programmi al di sopra del limite accessibile ai comuni mortali, è sempre stata una delle aspirazioni occulte di chi sviluppa software. Io stesso sono "colpevole" di aver contribuito con una dozzina di trucchi allo speciale "101 Tips&Tricks" pubblicato su Dev.
Di rado, però, ci si chiede quale sia il reale valore di un trucco, e quali siano invece i costi nascosti. In questo articolo, più breve del solito per lasciare spazio ad un confronto con Stepanov sulla programmazione generica ed ad una FAQ un po’ lunga, vedremo che i trucchi discendono spesso da assunzioni nascoste sull’intero sistema sottostante - dal compilatore al sistema operativo, sin giù alla CPU ed all’hardware di supporto - e che di conseguenza sono estremamente instabili. Il tutto condito da qualche inevitabile consiglio su come evitare la sindrome da trucco.

Array e puntatori
Chiedete ad un programmatore C della vecchia guardia come ottimizzare un programma, e la prima cosa che vi dirà sarà di usare l’aritmetica sui puntatori anziché array ed indici. Vi spiegherà come il povero compilatore sia costretto a fare delle orribili moltiplicazioni per accedere ad un elemento di array tramite indici, mentre l’incremento dei puntatori si trasforma in una veloce istruzione di somma. Non solo, vi spiegherà anche che il pre-incremento è più veloce del post-incremento, perché nel secondo caso il compilatore deve restituire il valore non ancora incrementato.
Così, se vogliamo sommare il contenuto di due vettori di interi e mettere il risultato in un terzo, l’implementazione suggerita dal guru di turno non è la semplice versione che segue, colpevole di usare gli indici:

void sum1( int* a, int* b, int*c, int n )
  {
  for( int i = 0; i < n; i++ )
    c[i] = a[i] + b[i] ;
  }

e neppure la prossima, colpevole di usare il post-incremento:

void sum2( int* a, int* b, int*c, int n )
  {
  for( int i = 0; i < n; i++ )
    *(c++) = *(a++) + *(b++) ;
  }

ma sarà invece questa, che usa solo l’aritmetica sui puntatori ed il pre-incremento:

void sum3( int* a, int* b, int*c, int n )
  {
  --a ;
  --b ;
  --c ;
  for( int i = 0; i < n; i++ )
    *(++c) = *(++a) + *(++b) ;
  }

Osserviamo che i puntatori vengono inizialmente decrementati, in modo da poter usare il pre-incremento all’interno del loop. C’é solo un "piccolo" particolare riguardo quest’ultima versione: anche se funziona con molti compilatori/CPU, il suo comportamento è indefinito (paragrafo 6.3.6 dello standard ANSI C). Decrementare un puntatore al primo elemento di un array potrebbe, in determinate implementazioni del linguaggio e con determinate CPU, causare un’eccezione.
Ovviamente il guru non legge le note in piccolo nello standard, ed avendo fatto le sue prove di timing su un compilatore dei primi anni settanta, continua ad usare ed a mostrare ai meravigliati neofiti la sua perla di programmazione. Ignaro di un secondo, piccolo particolare: su un buon compilatore moderno, la prima versione della funzione, quella semplice da leggere e mantenere, ma che osa utilizzare gli indici, è più veloce della versione super-furba basata sui due trucchi da vero programmatore.
Vediamo perché: un buon compilatore (io ho provato il codice di cui sopra con Visual C++ 5.0, con la massima ottimizzazione), è ormai in grado di trasformare l’uso degli indici in aritmetica sui puntatori; per una analisi delle numerose ottimizzazioni presenti nei compilatori moderni, potete consultare [Pes96]. Quindi la prima versione (sum1) è sostanzialmente equivalente alla seguente:

void sum1_1( int* a, int* b, int*c, int n )
  {
  for( int i = 0; i < n; i++ )
    {
    *c = *a + *b ;
    ++a ;
    ++b ;
    ++c ;
    }
  }

Come potete vedere, il compilatore usa solo aritmetica sui puntatori e pre-incremento. Rispetto alla versione super-furba, si risparmia il decremento iniziale di a, b, c, quindi è più veloce: misurando i tempi di esecuzione reali, si guadagna qualche ciclo di clock. Ma soprattutto si guadagna in chiarezza, in leggibilità e facilità di modifica, e si evita di cadere nelle trappole del comportamento indefinito. Il mio primo suggerimento è quindi: diffidate dei trucchi che si basano su una visione "compilatoresca" del linguaggio. I compilatori si evolvono molto più rapidamente dei trucchi, e la maggior parte delle tecniche basate sulla capacità del guru di pronosticare il codice generato sono destinate ad una rapidissima obsolescenza.

Eccezioni e self-assignment
Passiamo ora ad una delle regole più note della programmazione C++: quando si definisce un operatore di assegnazione, bisogna sempre ricordarsi di testare il caso di auto-assegnazione. Vediamo un caso concreto: una classe che incapsula un array di interi.

class C
  {
  public :
    // ... altro ... 
    void operator =( const C& c ) ;
      {
      if( this != &c )
        {
        l = c.l ;
        delete p ;
        p = new int[ l ] ;
        memcpy( p, c.p, l * sizeof( int ) ) ;
        }
      }
  private :
    int l ;
    int* p ;
  } ;

Osserviamo che è fondamentale gestire l’auto-assegnazione, viceversa perderemmo il buffer prima di poterne eseguire una copia. Non si tratta quindi (o almeno non soltanto) di una ottimizzazione di un caso estremamente raro (che potrebbe però emergere a causa di qualche macro), ma semplicemente di una misura di sicurezza. Questo stile di programmazione è rimasto per anni uno "standard" nel mondo C++, ed è anche documentato nel mio libro "C++ Manuale di Stile" [Pes95]. Tuttavia, come spesso avviene con gli idiomi di codifica, la stabilità delle tecniche è legata alla stabilità del linguaggio: in questo caso, ad esempio, l’introduzione delle eccezioni ha modificato in modo radicale la struttura di un buon operatore di assegnazione. Osserviamo, infatti, che "new int[ l ]" può ora sollevare un’eccezione. In tal caso, avendo distrutto p alla riga precedente, ci troviamo con un oggetto inconsistente: p punta a memoria rilasciata. Se l’oggetto target dell’assegnazione era una variabile locale, lo srotolamento dello stack conseguente all’eccezione finirà per rilasciare la memoria puntata da p una seconda volta, con comportamento imprecisato. Di conseguenza, una buona struttura per un operatore di assegnazione è ormai questa:

class C
  {
  public :
    // ... altro ... 
    void operator =( const C& c ) ;
      {
      int* tmp = new int[ c.l ] ;
      delete p ;
      l = c.l ;
      p = tmp ;
      memcpy( p, c.p, l * sizeof( int ) ) ;
      }
  private :
    int l ;
    int* p ;
  } ;

Osserviamo che se "new int[ c.l ]" solleva un’eccezione, l’oggetto target e l’oggetto destinazione sono rimasti invariati, quindi anche in caso di distruzione non si ha alcun comportamento anomalo. A questo punto, non vale assolutamente la pena di ottimizzare il caso rarissimo di auto-assegnazione; evitiamo così un test superfluo e rendiamo il codice (impercettibilmente) più veloce, oltre che più sicuro.
Arriviamo quindi al secondo suggerimento: quando vi viene proposto un trucco od una tecnica di programmazione fortemente legata al linguaggio, preoccupatevi sempre di capire a fondo le sue assunzioni e le sue conseguenze. Non a caso, nel mio libro ho sempre fatto precedere le raccomandazioni di codifica da una ampia discussione delle premesse: i linguaggi cambiano, e solo avendo compreso i principi di fondo di ogni tecnica potrete valutare l’impatto delle modifiche al linguaggio sulle tecniche di programmazione. Giusto per completare il quadro: fate un salto sulla mia home page, andate alla pagina dedica a "C++ Manuale di Stile", e troverete un link ad alcune slide (provenienti da un mio intervento al C++ Forum `96) che rappresentano un’ideale appendice al libro, dedicata all’uso corretto delle eccezioni.

Multithreading ed ottimizzazioni
Se delle eccezioni ho parlato al C++ Forum ’96, le tecniche di ottimizzazione e le loro relazioni con il multithreading sono state l’oggetto di uno dei miei interventi al C++ Forum `97. In particolare, una delle tecniche più interessanti è la lazy evaluation, ovvero la procrastinazione di operazioni potenzialmente costose sino all’ultimo istante. Un caso esemplare è quello di una classe String implementata con reference count e copy-on-write, secondo uno schema simile a quello del listato 1. Più stringhe possono condividere lo stesso buffer di caratteri che è mantenuto in una struttura (StringValue) che contiene anche un reference count, ovvero un conteggio delle stringhe che puntano a quel buffer. Quando modifichiamo la stringa, ad esempio accedendo ad un singolo carattere, se il reference count è maggiore di uno dobbiamo prima copiare la stringa in un nuovo buffer "privato", e poi modificarla (da cui il nome copy-on-write). Questa implementazione è ormai piuttosto comune, perché consente di passare oggetti di classe String per valore, senza preoccuparsi delle prestazioni: eventuali temporanei richiederanno semplicemente l’incremento ed il successivo decremento di un reference count, non la copia del buffer. Possiamo dire che una stringa con reference count e copy-on-write è stata per alcuni anni lo stato dell’arte nelle librerie C++.
Consideriamo però una delle funzioni che richiedono la copia: nel listato 1, operator[] restituisce un reference non-const al carattere selezionato, reference che può essere usato per modificare il carattere stesso. Di conseguenza, va trattato come un caso di copy-on-write. Cosa succede in un ambiente con multi-threading? Il test "value->refCount > 1" eseguito in un thread potrebbe restituire false, ma prima che il codice di operator[] termini restituendo il reference, un altro thread potrebbe agganciare un nuovo oggetto alla stessa struttura StringValue. Il codice del listato 1 non è quindi thread safe, così come non è thread safe il codice delle classi stringhe fornito con la maggior parte dei compilatori. Ad esempio, la classe CString di MFC non è thread safe, nonostante i programmatori abbiano disseminato qualche inutile InterlockedIncrement qua e là. L’intero utilizzo di refCount va racchiuso in una sezione critica, non il solo incremento (di fatto, proteggere solo l’incremento come in MFC è totalmente inutile, e contribuisce solo a rallentare l’esecuzione). D’altra parte, una classe stringa totalmente thread safe richiede la creazione di una sezione critica per ogni stringa, e le sezioni critiche in molti sistemi sono risorse relativamente "scarse", ovvero non disponibili a migliaia. Conseguenza diretta: molti produttori di librerie sono tornati sui loro passi, fornendo delle classi stringa meno efficienti (senza reference count) ma thread-safe. Altri addirittura propongono di abbandonare le stringhe in favore di altre strutture dati, un argomento di cui vi parlerò in una prossima puntata.
Veniamo quindi al terzo suggerimento: per ogni trucco o tecnica di programmazione che incontrate, cercate di capire sino a che punto è legata ad assunzioni sul sistema operativo sottostante. Solo così potrete evitare qualche scivolone pericoloso via via che i sistemi operativi diventano più potenti.

Conclusioni
Vi sarebbe ancora molto da dire sulle tante assunzioni che si nascondono dietro i vari trucchi che sembrano così cool finché funzionano. Ad esempio, molti programmatori preferiscono evitare la chiamata a funzioni di sistema come memcpy, creandosi dei loop locali per copiare zone di memoria. L’idea di fondo è che così facendo possono evitare l’overhead della chiamata di funzione, ma in realtà spesso è il desiderio di "sentire il controllo della situazione". Ora, non solo molti compilatori moderni hanno delle opzioni per eseguire l’inlining delle funzioni di libreria più comuni come memcpy, ma avere una memcpy localizzata consente di cambiare il codice in un solo punto quando l’hardware migliora. Al proposito, in [Dur96], Steve Durham mostra come attraverso l’uso dei registri MMX del Pentium si possa scrivere una versione di memcpy che è fino al 40% più veloce di una versione basata su "rep movsd", e quindi ben più veloce di quanto un loop di copia localizzato possa sperare di essere. Oppure, sempre rimanendo a livello di dipendenze dall’hardware, su certe CPU è molto più importante scrivere codice dal comportamento prevedibile in termini di salti che scrivere del codice localizzato all’interno di un’area dati/codice limitata.
In sostanza, nonostante l’indubbio fascino dei trucchi, segreti et similia, uno stile di programmazione "basato sul trucco" può impressionare i neo-programmatori ma è quasi sempre problematico a lungo termine. Un programmatore realmente esperto conosce i vari trucchi del mestiere ma sa anche da quali principi sono stati ispirati, ed è in grado di evitare le trappole nascoste via via che l’hardware, i sistemi operativi, i compilatori ed i linguaggi si evolvono. Uno dei passi fondamentali per passare dalla programmazione amatoriale a quella professionale è proprio la comprensione dei legami tra i principi e le tecniche di programmazione, in modo che quando i principi non sono più validi, anche le tecniche vengono cambiate. Viceversa, seguire alla cieca le orme di un guru è sempre pericoloso quando si esce dall’area di validità delle sue regole. Il miglior trucco, insomma, è capire che i trucchi è meglio lasciarli nel cassetto quando si programma sul serio.

Reader's Map
Molti visitatori che hanno letto
questo articolo hanno letto anche:

Bibliografia
[Dur96] Steve Durham, "A Faster memcpy for the Pentium", C/C++ Users Journal, Vol. 14 No. 12, December 1996.
[Pes95] Carlo Pescio, "C++ Manuale di Stile", Edizioni Infomedia, 1995.
[Pes96] Carlo Pescio, "Ottimizzazioni in C++", Computer Programming No. 47, Maggio 1996.

Biografia
Carlo Pescio (pescio@eptacom.net) svolge attività di consulenza in ambito internazionale nel campo delle tecnologie Object Oriented. Ha svolto la funzione di Software Architect in grandi progetti per importanti aziende europee e statunitensi. È incaricato della valutazione dei progetti dal Direttorato Generale della Comunità Europea come Esperto nei settori di Telematica e Biomedicina. È laureato in Scienze dell'Informazione ed è membro dell'ACM, dell'IEEE e della New York Academy of Sciences.