C++, Java, C#: qualche considerazione

Pubblicato su C++ Informer No. 12, Ottobre 2000

Nota: l'articolo, originariamente scritto ad ottobre 2000, ha suscitato un notevole interesse, nonche' alcune osservazioni critiche, a seguito delle quali ho aggiunto (agosto 2003) alcuni chiarimenti relativi a C# ed all'istruzione using, ed ancora in seguito (giugno 2007) alcune note sul linguaggio D. Potete trovarle a fine articolo, che ho preferito lasciare inalterato.

Come molti di voi sapranno, da qualche mese Microsoft ha annunciato un "nuovo" linguaggio C-based, denominato C-Sharp (scritto C#, dove il '#' e' il diesis, con un "gioco di notazione" che non sara' molto ovvio fuori dalla cultura anglosassone, dove la lettera C indica la nota musicale 'do').
Seguendo la tradizione (che negli ultimi anni ha raggiunto il suo apice con Java) la nascita e' stata accompagnata da un coro di acclamazioni, piu' o meno concordanti nel sostenere le doti del C# come "C++ migliore", "Java migliore", eccetera eccetera. Ovviamente, come si compete in questi casi, senza fornire nessuna misura oggettiva di cosa significhi "migliore", ma semplicemente facendo leva sul folklore popolare, secondo cui, ad esempio, avere "friend" nel linguaggio e' una cosa cattiva, e cosi' via.

In realta', C++, Java e C# hanno alcuni aspetti palesemente simili, soprattutto a livello sintattico (con qualche trappola semantica), un certo numero di sottili incompatibilita' facili da gestire (una volta capite), un certo numero di differenze del tutto arbitrarie (e anche qui ci si abitua facilmente) ed infine alcune fratture vere e proprie negli idiomi di programmazione piu' basilari.
In questo articolo vorrei concentrarmi solo su questi ultimi aspetti, prendendo in considerazione due elementi di grande separazione "culturale" tra C++ da un lato e Java/C# dall'altro (Java e C# si somigliano decisamente molto). Nel fare questo, mi limitero' a considerare Java e C# a livello di linguaggio, anche se Java e' in realta' una piattaforma e C# e' un mattone di una piattaforma.
Come vedrete, l'articolo sara' un po' anomalo per C++ Informer, e per certi aspetti sara' piu' vicino agli articoli della mia vecchia rubrica Principles&Techniques su Computer Programming. Se pensate che articoli dal taglio simile siano interessanti per voi lettori di Informer, vi invito a mandarmi un breve commento via email.

Una premessa essenziale
Da una newsletter dal titolo "C++ Informer" ci si dovrebbe aspettare, a questo punto, una forma di critica piu' o meno crudele di Java e/o del C#. Non e' invece questo il mio obiettivo.
Come accennavo poco sopra, vorrei approfondire alcuni aspetti di grande frattura tra i linguaggi, che nei molti articoli "di comparazione" che ho letto non vengono neppure mai menzionati. Con l'occasione, vorrei anche far osservare come i diversi aspetti di un linguaggio siano spesso fortemente correlati, al punto che presa una decisione alcune altre vengono in qualche modo forzate. Anche questo e' un punto che viene spesso ignorato da chi pensa che si possa creare il linguaggio perfetto scegliendo una lista di feature e mettendole insieme, o "sfoltendo" in modo piu' o meno arbitrario un linguaggio esistente.
In particolare, vorrei discutere l'influenza profonda che ha avuto, nel passaggio da C++ a Java/C#, la decisione di avere solo oggetti heap-based. Un aspetto apparentemente secondario, che porta invece con se' notevoli punti di distacco sia nei linguaggi, sia (maggiormente!) negli idiomi di programmazione da adottare.
Inoltre, vorrei dedicare un po' di spazio a discutere un aspetto poco compreso del C++ rispetto ad altri linguaggi (come Java/C# ed altri), ovvero la sua natura di linguaggio "a due livelli", contrapposta a quella di linguaggi "ad un livello", e le profonde implicazioni di questa differenza sulla cultura della programmazione.
Poiche' non tutti i lettori di Informer conoscono Java o il C#, nel seguito faro' uso di un C++ utilizzato "in stile Java" per mostrare le differenze a livello di codifica. Credo che tutto sommato l'articolo diventi, in questo modo, piu' semplice da seguire per chi conosce soltanto il C++.

Oggetti heap-based
In Java, cosi' come in C#, tutti gli oggetti sono allocati sullo heap. In C++ gli oggetti possono essere allocati sullo stack (dichiarandoli come variabili locali) o sullo heap (tramite una new expression); ad essere precisi, dovremmo dire che in C++ esistono oggetti con storage automatico o dinamico (ed anche statico, ma faro' finta di niente :-), solitamente realizzati tramite allocazione su stack o su heap.
Vista semplicemente cosi', questa differenza puo' sembrare marginale. In realta' e' alla radice di un forte scostamento tra i due linguaggi.

In un linguaggio con soli oggetti dinamici abbiamo due sole scelte per definire la vita di un oggetto: lasciare al programmatore la responsabilita' di distruggerlo (come accade in C++ usando i puntatori nativi: dobbiamo chiamare "delete" manualmente) o lasciare questa responsabilita' al supporto run-time del linguaggio. Quasi tutti i linguaggi con soli oggetti dinamici scelgono la seconda possibilita', ed integrano quindi un meccanismo di garbage collection.
Sull'efficienza dei garbage collector (o sulla sua assenza) sono state scritte centinaia di articoli, e tutto sommato l'argomento "efficienza" non e' importante per quanto mi accingo scrivere. Mi interessano invece le sue implicazioni sugli idiomi di codifica.
Infatti, gestire la vita degli oggetti attraverso un garbage collector ha un effetto collaterale importante dal punto di vista di chi usa il linguaggio: non sappiamo il momento esatto in cui l'oggetto verra' distrutto. Questo rende il concetto di "distruttore", come lo conosciamo in C++, molto meno interessante. Mentre in C++ il distruttore puo' avere una responsabilita' comportamentale importante, in un linguaggio con garbage collector il distruttore diventa solo un meccanismo di clean-up.
Un esempio chiarira' meglio il significato di quanto sopra. Pensiamo alla classe standard std::ofstream, che possiamo usare per scrivere su file. Un uso come il seguente e' perfettamente lecito:

void f()
  {
  // ... codice ...
  if( something )
    {
    ofstream outFile1( "a.txt" ) ;
    outFile1 << "pippo" ;
    // (1)
    }
  // ... codice ...
  if( something_else )
    {
    // (2)
    ofstream outFile2( "a.txt", ios::app ) ;
    outFile2 << "pluto" ;
    }
  }
Notiamo che in (1) non chiudiamo lo stream: sappiamo che il distruttore di outFile1 verra' sicuramente invocato uscendo dal blocco, e che questo comportera' il flush e la chiusura del file. Siamo cosi' certi, in (2), di poter aprire nuovamente lo stesso file in modalita' append e di avere il file in uno stato consistente.
Questo non e' ovviamente il caso di un linguaggio con soli oggetti dinamici e garbage collection: usciti dal primo blocco, outFile1 (se fosse allocato dinamicamente) sarebbe candidato alla collection, ma questo potrebbe avvenire in qualunque momento. Non avremmo alcuna garanzia, arrivati in (2), di una avenuta chiusura (con flush) del file che abbiamo aperto poco sopra.
La conseguenza e' quindi immediata, e vale tanto per Java quanto per C# o Eiffel: la funzione "equivalente" al distruttore (finalize in Java, dispose in Eiffel, ecc) ha uno scopo di clean-up su cui il programmatore non puo'/deve contare dal punto di vista della logica applicativa.
In un C++ usato "alla Java/C#" (cosa possibilissima: esistono dei garbage collector, tipicamente conservativi, per C++) il codice visto sopra diventerebbe quindi:

void f()
  {
  // ... codice ...
  if( something )
    {
    ofstream* outFile1 = NULL ;
    outFile1 = new ofstream( "a.txt" ) ;
    *outFile1 << "pippo" ;
    // (1)
    outFile1->close() ;
    }
  // ... codice ...
  if( something_else )
    {
    ofstream* outFile2 = NULL ;
    // (2)
    outFile2 = new ofstream( "a.txt" ) ;
    *outFile2 << "pluto" ;
    outFile2->close() ;
    }
  }
Notate che ho dovuto chiudere esplicitamente lo stream in entrambi i casi (nel caso in questione, era essenziale solo in (1); ovviamente, non chiudere il file nel secondo caso avrebbe aperto la porta a problemi in fase di manutenzione). In altre parole, cio' che ho fatto e' stato barattare la gestione della memoria con la gestione di tutte le altre risorse, ma su questo tornero' piu' avanti.
La differenza puo' ancora sembrare marginale, anche se per chi ha piu' esperienza dovrebbe essere piuttosto chiaro che stiamo rinunciando ad uno degli idiomi centrali della "cultura C++", il cosiddetto "resource acquisition is initialization". Proseguiamo pero' ad esplorare le conseguenze della nostra scelta iniziale di avere solo oggetti dinamici e garbage collection.

Un linguaggio "serio" deve fornire ai programmatori un buon supporto per la gestione e la propagazione degli errori: negli anni, un approccio che si e' dimostrato molto efficace e' quello delle eccezioni, presenti sia in C++ che in Java e C#. In tutti e tre i linguaggi possiamo fare uso del try/catch per gestire un errore. Java e C# hanno anche il "finally", che identifica un blocco di codice da eseguire comunque, sia in caso di errore che di successo; in C++ manca, ed anche questo va ricondotto alla scelta di cui sopra.
Proviamo infatti a rendere piu' robusto il nostro codice C++ "Java-like". Invece del listato messo sopra, potremmo inizialmente scrivere:

void f()
  {
  // ... codice ...
  if( something )
    {
    ofstream* outFile1 = NULL ;
    try
      {
      outFile1 = new ofstream( "a.txt" ) ;
      *outFile1 << "pippo" ;
      // (1)
      outFile1->close() ;
      }
    catch( ... )
      {
      // ... faccio qualcosa ...
      // (3)
      if( outFile1 != NULL )
        outFile1->close() ;
      }
    }
  // ... codice ...
  if( something_else )
    {
    ofstream* outFile2 = NULL ;
    try
      {
      // (2)
      outFile2 = new ofstream( "a.txt" ) ;
      *outFile2 << "pluto" ;
      // (4)
      
      outFile2->close() ;
      }
    catch( ... )
      {
      // ... faccio qualcosa ...
      // (5)
      if( outFile2 != NULL )
        outFile2->close() ;
      }
    }
  }
Notiamo che, dovendo gestire "a mano" le risorse, ho duplicato una parte di codice (una sola riga in questo caso minimale), tra i due blocchi try ed i due blocchi catch ((1) e' duplicato in (3), (4) e' duplicato in (5)). Poiche' duplicare il codice va contro un principio fondamentale di buona codifica (pensate alla manutenzione!) emerge la vera e propria *necessita'* di avere un blocco di codice che viene comunque eseguito, sia in caso di eccezione che di normale uscita dal blocco. Il nostro codice potrebbe cosi' diventare:


void f()
  {
  // ... codice ...
  if( something )
    {
    ofstream* outFile1 = NULL ;
    try
      {
      outFile1 = new ofstream( "a.txt" ) ;
      *outFile1 << "pippo" ;
      }
    catch( ... )
      {
      // ... faccio qualcosa ...
      }
    finally
      {
      // (1) + (3)
      if( outFile1 != NULL )
        outFile1->close() ;
      }
    }
  // ... codice ...
  if( something_else )
    {
    ofstream* outFile2 = NULL ;
    try
      {
      // (2)
      outFile2 = new ofstream( "a.txt" ) ;
      *outFile2 << "pluto" ;
      }
    catch( ... )
      {
      // ... faccio qualcosa ...
      }
    finally
      {
      // (4) + (5)
      if( outFile2 != NULL )
        outFile2->close() ;
      }
    }
  }
Notiamo anche che se f() non fosse interessata a gestire l'eccezione (parte catch) ma solo a fare il giusto cleanup, dovremmo comunque scrivere un codice piuttosto prolisso: una variabile dichiarata fuori dal blocco try, il blocco try, il blocco finally con test sulla variabile, tutto ripetuto sia per outFile1 che outFile2. Compariamo questo con lo stile C++ "corretto", ovvero con il primo listato visto, che usa oggetti automatici ed ha identico significato, e noteremo una differenza sensibile.

Riprendiamo brevemente le fila: scegliendo un linguaggio con soli oggetti dinamici, diventa comodo (e facile) introdurre un garbage collector. Questo rende il distruttore (o il suo "analogo") un punto di solo clean-up, non di garanzia comportamentale, e quindi invalida l'idioma "resource acquisition is initialization". A sua volta, essendo tale idioma centrale nella gestione degli errori, la sua assenza ci porta quasi per necessita' ad introdurre il concetto di finally block per evitare la duplicazione del codice.
Come vedete, e' una catena logica piuttosto stretta, che lascia pochi spazi di manovra: una scelta iniziale ha una serie di conseguenze in punti che potrebbero sembrare anche "distanti" ad uno sguardo meno attento. La scelta di avere solo oggetti dinamici ha anche altre conseguenze molto interessanti, come la facilita' di implementare le chiusure, ampiamente sfruttata in Java con le inner class. Come dire... chiudendo una opportunita', se ne aprono altre.

Il vero punto di frattura con il C++ e' comunque la rinuncia all'idioma "resource acquisition is initialization", che richiede oggetti con lifetime automatico. Questo significa, come accennavo sopra e come vorrei ora approfondire, barattare la gestione "gratuita" della memoria con la gestione "manuale" di tutte le altre risorse (file, handle, finestre, lock, qualunque cosa).
In particolare, l'uso "corretto" (tornero' piu' avanti su questo punto) di ogni risorsa in C++ si riassume nella creazione di una classe apposita, che di solito nel costruttore acquisisce l'ownership della risorsa e nel distruttore rilascia la risorsa. Questo idioma si applica, come dicevo, ad ogni risorsa, memoria inclusa; questo implica l'abbandono dei puntatori nativi nel codice applicativo.
Iniziamo cosi' a capire la natura "a due livelli" del C++, che spieghero' meglio al punto successivo: il C++ offre un supporto nativo low-level per costruire astrazioni di piu' alto livello. Il programmatore dovrebbe lavorare con queste astrazioni, costruendole quando servono, e non lavorare costantemente al livello nativo.
La gestione della memoria, in particolare, dovrebbe sempre avvenire attraverso una famiglia di smart pointer, che rappresentino perlomeno i tre casi fondamentali: ownership esclusiva (gia' presente nello standard come auto_ptr), ownership condivisa (e qui esistono molte varianti di puntatori con reference count), navigazione senza ownership (essenziale per evitare le dipendenze circolari che vengono spesso portate come giustificazione dell'assoluta necessita' di un garbage collector). Gli oggetti dinamici verranno quindi gestiti da una loro controparte (smart pointer) con lifetime automatica. In un simile programma, non avremo alcuna delete expression, se non dentro le astrazioni utilizzate (gli smart pointer).
Cio' significa, naturalmente, progettare il lifetime di ogni risorsa, memoria inclusa, e poi utilizzare le giuste astrazioni nel codice: in particolare, l'uso di puntatori nativi non ha posto nella programmazione C++ "moderna", se non nella realizzazione delle astrazioni in questione, in situazioni di confine (interoperabilita' con il C o con funzioni di sistema), o in presenza di compilatori incapaci di espandere in linea le funzioni inline piu' elementari.

Viceversa, in Java/C# non e' teoricamente necessario progettare il lifetime degli oggetti. Ma questa e' una falsa sicurezza, in quanto in molte situazioni (non necessariamente in tutte) dovremo comunque gestire a mano acquisizione e rilascio delle risorse. Ed e' piuttosto ovvio che una volta rilasciata la risorsa "contenuta" in un oggetto, il fatto che l'oggetto rimanga accessibile ad altri (che non sanno nulla circa il suo stato interno) e' assai poco sensato, per cui non si puo' realmente fare a meno di progettare il lifetime: si puo' solo fare a meno di implementare una parte della sua gestione (vi lascio da pensare quale parte della gestione del lifetime non si puo' fare a meno di realizzare)

Prima conclusione: pur dietro una sintassi molto simile, se confrontiamo un programma "reale" e "ben scritto" in C++ ed uno equivalente e "ben scritto" in Java o C# noteremo una differenza piuttosto profonda. In particolare, in C++ faremo uso di molte piccole astrazioni (puntatori intelligenti, classi Lock, ecc) con il solo scopo di gestire il rilascio delle risorse, e di classi "piu' grosse" come stream, Window, ecc in cui anche il distruttore gioca un ruolo centrale nel comportamento dell'applicazione. Viceversa, in Java/C# ci troveremo di fronte a codice con allocazioni ed aliasing piu' liberal :-), ma con una gestione totalmente manuale della fase di rilascio delle risorse, spesso in un blocco finally. Mediamente, e qui sta una sorpresa per molti, il codice C++ ben scritto sara' piu' breve, perche' la stessa piccola astrazione verra' probabilmente riusata in molti punti diversi, e conterra' una parte di logica che in Java/C# andra' replicata in tanti blocchi finally diversi (da questo, chi vuole potra' trarre un giudizio su quale sia la tecnica migliore dal punto di vista dell'ingegneria del software).
Viceversa, se confrontiamo codice C++ "mal scritto" (grande uso di puntatori nativi, gestione degli errori assente o manuale, senza classi per le risorse) con codice Java/C# "mal scritto" (semplicemente privo di gestione degli errori) noteremo che il codice Java/C# risulta molto piu' snello: non dovendo gestire la memoria evitiamo le delete expression, e trascurando la gestione degli errori non abbiamo try/finally a mostrare il lato "debole" degli oggetti esclusivamente dinamici. Chissa' come mai, nei tanti articoli di comparazione C++/Java (e presto, immagino, C++/C#) il codice che viene mostrato non si preoccupa quasi mai di gestire gli errori :-).

Purtroppo, va comunque riconosciuto che una gran parte del codice C++ esistente non e' "ben scritto" secondo l'accezione vista sopra. Non si tratta (soltanto) di una mancanza dei programmatori: solo in tempi relativamente recenti il C++ e' diventato sufficientemente potente da supportare, al suo livello piu' basso, la creazione delle corrette astrazioni da usare al livello piu' alto (come gli smart pointer in grado di supportare il polimorfismo). Esiste, ovviamente, anche un problema culturale; e qui diventa fondamentale capire un'altra e piu' profonda differenza tra C++ e Java/C#, ovvero la natura di linguaggio ad uno o due livelli.

Application design is language design
Lo sviluppo di una applicazione complessa si puo' affrontare seguendo approcci diversi. Un approccio che personalmente ritengo tra i migliori e' quello architetturale, in cui la fase di design inizia con la definizione di una macro-struttura (portante) per l'applicazione. Durante questo momento si identificano alcune astrazioni fondamentali che finiranno con il permeare l'intero codice applicativo. Spesso queste astrazioni diventano un vero e proprio "linguaggio" in cui i programmatori inizieranno a pensare e a comunicare fra loro; questo "linguaggio", nel lungo termine, diventa parte della cultura di una azienda, o di un settore applicativo. Ad esempio, nel settore dell'automazione esistono concetti ricorrenti come gli eventi, i device, i comandi. Nel settore business esistono concetti ricorrenti come i record, le regole di validazione, i report. E cosi' via. Questi concetti ricorrenti formano, a seconda di chi li guarda, un pattern language, o un problem frame, o semplicemente, come accennavo, una "cultura di fondo" che distingue chi ha lavorato nel settore da chi ne e' rimasto fuori.
Alcuni progettisti e programmatori si accontentano di pensare in termini di eventi e programmare in termini di long int. Altri cercano una piu' forte risonanza tra il "linguaggio" in cui pensano e quello in cui programmano: il modo piu' semplice di ottenere questo effetto e' creare delle astrazioni nel proprio linguaggio di programmazione (ad es. delle classi per chi adotta il paradigma ad oggetti). Cio' che in realta' vorremmo avere e' una impedenza minima tra le astrazioni che possiamo creare e gli elementi gia' presenti nel nostro linguaggio di programmazione: vorremmo cioe', durante la progettazione di una applicazione, estendere il nostro linguaggio di programmazione per avvicinarlo al "linguaggio" tipico del nostro settore applicativo.

Non tutti i linguaggi di programmazione supportano questo modo di operare. Tipicamente, i linguaggi nati con obiettivi didattici (come il Pascal) fanno una netta distinzione tra quello che puo' fare il compilatore e quello che puo' fare il programmatore. Ad esempio, in Pascal esistono funzioni overloaded variadiche (come read e write) ma il programmatore non puo' crearne di nuove. Esistono dei tipi elementari che fanno uso dei comuni operatori come + e - (gli interi, i reali), ma i tipi introdotti dal programmatore non possono farne uso. E cosi' via. Io chiamo questi linguaggi "ad un livello", perche' offrono astrazioni di livello intermedio, su cui il programmatore puo' costruire nuovi elementi che tuttavia sono visibilmente diversi da quelli built-in del linguaggio.

Il C++ e' invece in larga misura (ma non completamente) un linguaggio "a due livelli". Espone astrazioni di livello intermedio ma anche elementi di livello molto basso, attraverso i quali il programmatore puo' arricchire il linguaggio per "adattarlo" al proprio ambito applicativo, con una impedenza minima tra elementi built-in ed elementi di libreria. Vedremo qualche esempio concreto tra poco. Java e C# non seguono la strada a due livelli del C++. Sono linguaggi ad un livello, e questo plasma in modo piuttosto profondo l'approccio del buon programmatore.
Vediamo qualche esempio: il C# aggiunge una parola chiave "event" per modellare il concetto (che diventa built-in, un po' come il Visual Basic) di evento di notifica. E' evidente che nel settore applicativo che Microsoft ipotizza per il C# il concetto di evento e' importante, ed il linguaggio deve supportarlo in modo adeguato. Tuttavia, per supportarlo a livello di libreria il linguaggio deve essere di gran lunga piu' potente e flessibile del C#. Avendo realizzato nel corso degli anni molte applicazioni event-driven in C++, solo in tempi recenti sono riuscito finalmente ad implementare degli eventi soddisfacenti (con una impedenza minima rispetto al resto del linguaggio, type-safe, thread-safe, e cosi' via). Tutto sommato, ho dovuto "dare fondo" a molte novita' del C++, come member template et similia. In C#, dobbiamo avere un supporto built-in specializzato, perche' non possiamo costruirlo come parte di libreria senza renderne l'uso eccessivamente "scomodo" (ovvero, troppo disomogeneo con il resto del linguaggio). Lo stesso avviene per la keyword "delegate" del C#. Java e' ancora piu' restrittivo del C#, che perlomeno consente un parziale overloading degli operatori. Ad esempio, il "comune programmatore" non potrebbe implementare una classe String come quella built-in di Java, in quanto non potrebbe ridefinire l'operator + (e +=) in modo analogo a quanto avviene per String.
Questa differenza tra "il comune programmatore" e "l'autore del linguaggio/libreria standard" e' tipica dei linguaggi ad un livello, mentre tende a svanire (in modo piu' o meno netto) nei linguaggi a due livelli.

E' tra l'altro importante capire che la natura "a due livelli" del C++ non deriva dalla sua radice nel C. Anzi, delle astrazioni low-level che il C++ offre al programmatore, soltanto una arriva dal C: la capacita' di manipolazione della memoria. Le altre astrazioni sono state introdotte da Stroustrup, sia riportando inizialmente in C++ alcune caratteristiche "interessanti" di altri linguaggi, sia strada facendo, per supportare al meglio la creazione di librerie come STL.
I lettori piu' affezionati di Informer non faranno fatica a riconoscere, nei vari "No Limits" che ho presentato in questi dodici numeri, un uso creativo di building-block elementari, che come ho piu' volte sottolineato nel tempo si dimostrano eccezionalmente flessibili e ricombinabili. Questi building-block non sono stati ereditati dal C: sono "nati" in C++.

E' altrettanto importante notare che la "cultura C++ mainstream" non ha ancora completamente realizzato la natura a due livelli del linguaggio. Anche i riferimenti in letteratura sono relativamente scarsi. Il primo articolo di cui sono a conoscenza [Alger94] e' relativamente datato, ma nonostate in esso l'autore dica chiaramente "C++ is really 'yacc++', not so much a language as a way of creating your own languages", pochi programmatori devono averlo letto e capito a fondo. Altri riferimenti si possono trovare nei lavori di Andrew Koenig, che nei suoi articoli sul JOOP ha piu' volte ricordato una massima dei laboratori AT&T ("Library Design is Language Design") cui ho ispirato anche il titoletto del presente paragrafo; questi articoli sono poi stati raccolti in [KM96].
Lo stesso Stroustrup, a mio avviso, solo in tempi recenti ha completamente "realizzato" le potenzialita' della strada intrapresa: in una recente intervista [Nelson00], Bjarne ha risposto alla domanda "If you were to start from scratch today and design C++ over again, what would you do differently?" osservando, tra le altre cose (e per la prima volta, a quanto mi risulta) "I'd try to create a relatively small core language-containing key abstraction features along the lines of classes and templates-and put much more of 'the language' into libraries. However, I'd try hard to make the core language powerful enough for those libraries to be written in the core language. I do not like the idea of having standard library writers rely on extra-linguistic facilities not available to 'ordinary users'. I'd work on getting this core language very precisely defined".
Questa e' l'essenza di un linguaggio "a due livelli", che il C++ si avvicina a realizzare anche se non riesce ancora a sfruttare completamente (in un linguaggio realmente "a due livelli", il programmatore potrebbe aggiungere ad esempio nuove strutture di controllo come il foreach del C#; in C++ si puo' andare vicini, ma rimane una certa impedenza).

Tutto sommato, come vedete, l'idea di fondo e' molto semplice, ma e' anche largamente ignorata dai programmatori, e talvolta persino fraintesa (su quest'ultimo punto tornero' piu' avanti). Pertanto, credo possa essere utile rivedere in questa luce un paio di esempi concreti.
Nel numero 2 di C++ Informer, nella rubrica "No Limits", ho mostrato come implementare in C++ un analogo delle funzioni Java forName() e newInstance() (della classe built-in Class). Il risultato finale non era perfetto, in quanto rimaneva un po' scomodo dichiarare una classe in modo che beneficiasse del nuovo supporto; in particolare, era necessario definire uno string literal con il nome della classe e linkage esterno, ed anche se avevo fornito una apposita macro, rimaneva una certa impedenza rispetto ai costrutti built-in del linguaggio.
La soluzione "ad un livello", di fronte ad un problema del genere, e' piuttosto semplice: se si ritiene che forName/newInstance siano utili, si aggiungono al linguaggio. La soluzione "a due livelli" e' invece diversa: se si ritiene che forName/newInstance siano utili, e non si riescono a realizzare "bene" nel linguaggio corrente, si cercano i building block elementari che, aggiunti al linguaggio, consentono di costruire un forName/newInstance ben integrato. Ad esempio, potremmo ipotizzare un potenziamento di typeid(), che oggi restituisce un valore run-time di classe type_info, attraverso il quale non possiamo comunque ottenere il nome completamente qualificato della classe ma solo un nome implementation-defined. Una versione piu' potente potrebbe consentire di ottenere informazioni a compile-time, ovviamente riferite al tipo statico dell'espressione. Su questo building-block piu' elementare potremmo costruire un forName/newInstance migliore, ed in futuro anche altre funzionalita': piu' elementare e' il building-block, piu' e' probabile che si presti ad utilizzi alternativi.
Lo stesso criterio vale per alcuni elementi proposti per l'inclusione nello standard C++, come implicit_cast. E' emerso che sfruttando la qualificazione esplicita dei template function, unitamente alla trail argument deduction, si poteva implementare implicit_cast direttamente nel linguaggio, senza modifiche al compilatore. Con lo stesso meccanismo, possiamo definire anche nuovi, interessanti cast, come interpret_cast (si veda ad esempio [Henney00] per una breve spiegazione), sempre a livello di libreria.
Ancora un esempio: in fase di standardizzazione e' stato rifiutato "typeof", un costrutto molto utile gia' implementato da alcuni compilatori come lo GNU. Inizialmente mi e' sembrato un errore, perche' typeof e' molto piu' utile di quanto si tenda a credere; piu' volte ho provato a realizzarlo "nel linguaggio" raggiungendo solo risultati molto parziali, non dissimili da quelli presentati recentemente da Bill Gibbons sul C++ Users Journal [Gibbons00]. In tempi successivi, ripensando meglio al problema, ho capito che "dietro" typeof si nasconde l'opportunita' di migliorare il linguaggio creando un building-block piu' elementare, utile anche in altri casi. Un buon esercizio per i guru o aspiranti tali e' pensare un po' a quali building-block potrebbero rendere semplice l'implementazione di typeof. Ne esistono diversi, che ovviamente portano a realizzazioni e potenzialita' differenti; in particolare, se avete letto l'articolo di Gibbons potreste valutare l'utilita' in questa situazione dello stesso building-block cui ho accennato sopra per forName/newInstance.

Naturalmente, non mi aspetto che la programmazione "di ogni giorno" avvenga in termini di questi building block elementari. E spesso neppure dei costrutti creati immediatamente sopra di essi: ad esempio, mi aspetto che forName/newInstance sia, in molti casi, ulteriormente incapsulata da una factory. Tuttavia, la differenza metodologica (mi verrebbe da dire "filosofica") tra un linguaggio "a due livelli" ed un linguaggio "ad un livello" e' veramente profonda, anche per le sue implicazioni sulle capacita' dei programmatori che lo utilizzeranno. Un linguaggio "ad un livello" suppone che i progettisti del linguaggio siano molto piu' in gamba dei programmatori, e che sappiano prevedere l'esatto insieme di concetti utili, senza esagerare in un verso o nell'altro. Il progettista di un linguaggio "a due livelli" cerca di trovare i building-block elementari, sopra i quali costruire elementi di libreria indistinguibili da elementi del linguaggio, e concede questa possibilita' anche ai programmatori (pur senza costringerli a lavorare anche a basso livello).

Esiste, ovviamente, un "lato oscuro" di un linguaggio "a due livelli": molti programmatori non realizzano questa natura del linguaggio (anche perche' di solito nessuno la spiega :-) e lo utilizzano come linguaggio "ad un livello", facendo un uso eccessivamente diffuso delle funzionalita' elementari. Devo purtroppo dire di aver visto questa tendenza trovare il suo apice tra alcuni (alcuni, non tutti!!) programmatori provenienti dal C con un background embedded/real-time, dove la giusta attenzione per le prestazioni talvolta sfocia nella convinzione che solo lavorando sempre ad un basso livello di astrazione si ottengano performance adeguate. I programmatori piu' esperti sanno invece che una buona incapsulazione porta spesso a codice *piu' efficiente* di quello che potremmo scrivere (senza impazzire in sviluppo e manutenzione) lavorando esclusivamente a basso livello. Per qualche semplice esempio potete dare un'occhiata alle slide del mio intervento "++Efficienza" (presentato al C++ Forum '97), recentemente rese disponibili sulla mia pagina web, nella sezione "Formazione e Mentoring").

Seconda conclusione: dietro la facciata di "semplificazione del C++" di Java e C# si cela in realta' una visione del programmatore molto diversa. Il programmatore C++, nell'idea di Stroustrup, puo' passare dalla creazione di librerie sfruttando concetti di livello basso (come i puntatori nativi, il placement new, ecc) all'uso di astrazioni di livello piu' alto (da lui stesso create, ma anche di terze parti) rimanendo sempre "dentro" il linguaggio e in molti casi con una minima impedenza del risultato.
Al programmatore Java/C# non e' riservato lo stesso trattamento. Come il programmatore Pascal, hanno a disposizione un linguaggio piu' "chiuso", dove possiamo costruire nuove astrazioni (classi) su un substrato di livello medio, ma siamo molto distanti dal creare vere e proprie estensioni al linguaggio, indistinguibili dagli elementi built-in. Questo puo' essere visto come un lato positivo ("il programmatore fara' meno pasticci") o negativo ("il programmatore sara' limitato nel suo lavoro ed il risultato inferiore"), ed anche questo rivela una frattura culturale tra un linguaggio che ha mantenuto alcune idee del C ("il programmatore sa quello che fa") ed altri che ne hanno mantenuto principalmente la sintassi.

Conclusioni
Ovviamente, vi sarebbe ancora molto da dire sulle differenze semantiche tra C++, Java, C#, che rischiano invece di essere facilmente mascherate dalle forti similitudini sintattiche. Ho voluto affrontare, in questo numero, i due elementi che ritengo di maggiore distacco, uno molto pragmatico, l'altro piu' astratto/metodologico.
Se vi interessa l'argomento, vi invito a farmelo sapere via email, e magari ne riparleremo per un futuro numero di C++ Informer.

AGGIORNAMENTI (04/08/2003)
Negli anni trascorsi dalla sua pubblicazione, questo articolo ha ricevuto un feedback notevole. Non poche persone l'hanno trovato "illuminante" (e ovviamente la cosa non puo' che farmi piacere), un numero relativamente ristretto non ha colto il punto essenziale nella discussione su garbage collector e risorse (ovvero che esistono altre risorse oltre la memoria), ed in tempi piu' recenti un numero crescente mi ha fatto notare che, parlando di C#, e' tutto sommato scorretto non citare l'istruzione using, che a detta di alcuni risolve completamente il problema dei try/catch.
Ci troviamo di fronte ad una questione non banale, in quanto ad una prima analisi potrebbe effettivamente sembrare che l'istruzione using risolva il problema, ma ad un esame piu' approfondito e' semplice notare come cio' non corrisponda al vero. Ho pertanto deciso di ampliare l'articolo discutendo anche l'istruzione using ed i suoi reali effetti sul codice e sulla gestione delle risorse.

C# e "using"
L'istruzione using consente una distruzione deterministica al termine del blocco in cui l'oggetto viene dichiarato.
La distruzione deterministica avviene con un idioma classico del C#: la classe deve implementare l'interfaccia IDisposable, ed il metodo dispose deve eseguire la distruzione deterministica.
Vediamo un semplice esempio:
class A : System.IDisposable
  {
  public A() 
    {
    System.Console.WriteLine( "constructor" ) ;
    }
  ~A() 
    {
    System.Console.WriteLine( "destructor" ) ;
    }
  public void Dispose()
    {
    System.Console.WriteLine( "dispose" ) ;
    }
  }


class B
  {
  public static void Main()
    {
    using( A obj = new A() )
      {
      System.Console.WriteLine( "inside using block" ) ;
      }
    System.Console.WriteLine( "just outside using block" ) ;
    }
  }
L'output di questo programma sara':
constructor
inside using block
dispose
just outside using block
destructor
Notiamo che non si e' resa necessaria alcuna istruzione try/catch, ma il codice e' ugualmente exception-safe. Un oggetto dichiarato all'interno di una istruzione using verra' comunque distrutto deterministicamente (ovvero, ne verra' chiamato il metodo Dispose) qualunque sia la ragione che ci porta ad uscire dal blocco, ovvero il normale flusso di esecuzione o una eccezione.
Ovviamente, il comportamento non e' esattamente identico a quanto avviene in C++: in C# abbiamo una distruzione deterministica ma il rilascio della memoria viene posticipato ad un momento successivo. Tuttavia, come accennavo, il nucleo dell'idioma RAII e' la gestione delle risorse. Il fatto che la memoria venga solo resa disponibile al collector non costituisce un grave problema.

Problema risolto?
Quanto sopra ha portato molte persone a concludere che il problema sia completamente risolto. Tuttavia, questo e' vero solo se consideriamo situazioni estremamente semplici (anche se tutto sommato frequenti) come quella su riportata. L'istruzione using ha infatti una limitazione enorme (ed una minore), che la rende un sostituto decisamente incompleto di quanto realizzabile in C++.
La limitazione minore riguarda la necessita' di implementare l'interfaccia IDisposable. Non e' un problema reale, perche' in molti casi, anche se la classe originale non la implementa, possiamo rimediare con un opportuno wrapper.
La limitazione enorme riguarda il possibile uso dell'oggetto. In pratica, con l'istruzione using possiamo "mimare" il comportamento di un oggetto C++ allocato sullo stack, che non puo' quindi essere restituito (se non dopo una copia per valore) o comunque condiviso (se non stando molto attenti alla lifetime degli oggetti).
Accennavo comunque alla possibilita' di usare famiglie di smart pointer per risolvere il problema. Vediamo un caso banale di una funzione C++ che restituisce una risorsa (di classe Resource) lasciandone l'ownership al chiamante:
std::auto_ptr< Resource > f()
  {
  std::auto_ptr< Resource > r = new Resource() ;
  // do something
  return r ;
  }
questa funzione ha diverse caratteristiche interessanti:
- e' exception safe: se tra la costruzione e la restituzione viene sollevata un'eccezione, l'oggetto [puntato da] r verra' distrutto.
- crea un oggetto locale, ma al termine dell'esecuzione ne cede l'ownership al chiamante
- se il chiamante non utilizza il risultato, l'oggetto restituito verra' comunque distrutto deterministicamente al termine della funzione (in realta', subito dopo il ritorno).

Proviamo a realizzare qualcosa di simile in C#. Ho aggiunto una classe ed una funzione per poter verificare meglio cosa succede all'oggetto.
class A : System.IDisposable
  {
  public A() 
    {
    disposed = false ;
    System.Console.WriteLine( "constructor" ) ;
    }
  ~A() 
    {
    System.Console.WriteLine( "destructor" ) ;
    }
  public void Dispose()
    {
    disposed = true ;
    System.Console.WriteLine( "dispose" ) ;
    }
  public void g() 
    {
    System.Console.Write( "object is " ) ;
    if( ! disposed )
      System.Console.Write( "not " ) ;
    System.Console.WriteLine( "disposed" ) ;
    }
  private bool disposed ;      
  }


class B
  {
  public A f()
    {
    using( A obj = new A() )
      {
      System.Console.WriteLine( "inside using block" ) ;
      obj.g() ;
      return obj ;
      }
    }
  }


class C
  {
  public static void Main()
    {
    B objB = new B() ;
    A objA = objB.f() ;
    objA.g() ; // !!
    }
  }
L'output del programma sara' ovviamente:
constructor
inside using block
object is not disposed
dispose
object is disposed
destructor
Ovvero, nel punto che ho evidenziato con "!!" l'oggetto objA, che abbiamo ottenuto tramite la chiamata precedente, e' invalido. Se provassimo ad utilizzarlo, avremmo malfunzionamenti vari, il migliore dei quali sarebbe una segnalazione di errore seguita da una eccezione.

Conseguenze
Ovviamente potremmo riscrivere la nostra classe A, in modo tale che ogni funzione verifichi lo stato (disposed o meno) ed eventualmente ricostruisca l'oggetto. Questo modello ha una serie di effetti collaterali indesiderabili talmente ampia che, in tutta onesta', non ritengo neppure che abbia molto senso discuterla.
Notiamo peraltro che ho solo cercato di emulare quanto possiamo realizzare con il pur limitato auto_ptr, ed usando una semplice funzione non-membro. Il fatidico "esercizio per il lettore", che lascio a chi voglia ulteriormente approfondire il tema, e' capire come gli effetti si compongano se pensiamo a scenari di creazione, passaggio di ownership e condivisione tra oggetti, che in C++ possiamo ben modellare con classi risorsa, auto_ptr, puntatori con reference count ed eventualmente puntatori deboli.
Il nucleo del discorso, tuttavia, non cambia: l'istruzione using e' una soluzione comoda, copre una casistica relativamente ampia, ma non e' una soluzione metodologicamente completa, a differenza dell'idioma RAII del C++. Puo' evitarci, in scenari elementari, qualche istruzione try/catch. Uscendo dagli scenari elementari (che invariabilmente sono gli unici ad essere presentati dagli evangelisti :-), tutto torna sulle spalle del programmatore.
Come considerazione a latere, notiamo come una volta di piu' emerga la natura "ad un livello" di linguaggi come Java e C#: per ottenere qualcosa di piu' rispetto a Java (che non ha una istruzione analoga) e' stato necessario modificare il linguaggio. In C++, l'idioma RAII e' totalmente realizzabile all'interno del linguaggio.

Conclusioni (2003 :-)
Molto tempo fa, nella recensione di un (ottimo) libro scrivevo: "ormai la buona critica e' un elemento raro in ogni testo nel campo informatico".
A dire il vero, escludendo alcuni casi sporadici, il trend non mi sembra particolarmente cambiato. Cio' che e' nuovo deve essere migliore, e quindi (per necessita') cio' che esisteva deve essere peggiore. Questo viene spesso assunto come assioma, e ci si perde poi ad enumerare le bellezze del nuovo, senza mai fermarsi ad approfondire, ma limitandosi ad esempietti accademici che non riflettono la reale complessita' dello sviluppo (in progetti non giocattolo).
Dal canto mio, per quanto decisamente controcorrente, vi invito non a rifiutare le novita' (C# e .NET, per fare un esempio, hanno comunque alcuni aspetti interessanti) ma a valutare ogni informazione in modo critico. Nei miei corsi di progettazione insegno sempre che ogni cosa (che siano gli oggetti, il C++, il C#, una scelta di design piuttosto che un'altra) deve avere aspetti positivi ed aspetti negativi. Imparare a trovare entrambi e' un passo indispensabile per diventare dei buoni progettisti e dei migliori programmatori.

AGGIORNAMENTI (23/06/2007)
Pur avendo ormai 7 anni di vita, questo articolo continua ad essere citato con una certa frequenza. In tempi recenti, mi e' stato fatto notare che in un paio di occasioni qualcuno ha scritto sui newsgroup che coniugare garbage collection e distruzione deterministica e' possibile, e che ad esempio D (un interessante linguaggio creato da Walter Bright) lo fa senza problemi. Credo valga la pena mostrare come questo non sia affatto vero, e come D ricada ne' piu' ne' meno nelle stesse problematiche e soluzioni del C# (o del C++/CLI).
Sicuramente e' vero che D include alcuni costrutti specializzati per supportare il paradigma RAII. Per chi si ferma alla lettura della feature comparison list (http://www.digitalmars.com/d/comparison.html), questo potrebbe essere sufficiente a dire che il problema e' risolto.
Guardiamo in pratica come viene risolto:
http://www.digitalmars.com/d/memory.html#raii
http://www.digitalmars.com/d/attribute.html#scope
L'idea e' molto semplice e spero che se ne colga facilmente l'analogia con lo using: implementando un distruttor nella classe e e decorando una dichiarazione di oggetto tramite la keyword scope, possiamo far si' che il distruttore venga chiamato in modo automatico (come peraltro dice la feature comparison list) quando il reference decorato con scope esce (per l'appunto) dal suo scope.
Notiamo anche cosa dice, con molta chiarezza, la documentazione di cui sopra:
be careful that any reference to the object does not survive the return of the function.
e ancora:
scope cannot be applied to globals, statics, data members, ref or out parameters. Arrays of scopes are not allowed, and scope function return values are not allowed. Assignment to a scope, other than initialization, is not allowed.
A ben vedere si tratta di un costrutto ancora piu' restrittivo del semplice auto_ptr, e come lo using del C#, risolve una casistica limitata di distruzione deterministica, per quanto molto frequente (appunto, una distruzione deterministica all'uscita dallo scope).
Considerazioni del tutto analoghe valgono per il C++/CLI, dove il linguaggio puo' simulare sintatticamente l'allocazione sullo stack di un oggetto di tipo reference. In realta', tutto si risolve con una allocazione su heap ed una chiamata a Dispose nel momento in cui l'oggetto esce di scope.
Per chi ha seguito sin qui, vale la pena osservare come tutti i tentativi di "riconciliare" distruzione deterministica e garbage collection seguano uno stesso percorso:
- la distruzione deterministica viene limitata ad oggetti mono-referenziati (possesso esclusivo)
- per ragioni di semplicita' implementativa, questo viene ulteriormente ristretto ad oggetti che non sopravvivono al loro scope di dichiarazione
- in alcuni casi (C#, C++/CLI), viene separato il concetto di "distruzione" dal concetto di "rilascio del blocco di memoria", in particolare, permettendo alla distruzione di avvenire prima (all'uscita dallo scope) rispetto al rilascio del blocco (ad opera del garbage collector)
- in altri casi (D), imponendo ulteriori restrizioni, l'oggetto viene effettivamente allocato sullo stack.

In nessun caso viene coniugato il garbage collector con un piu' generale problema di gestione deterministica della vita degli oggetti multi-referenziati, come invece e' possibile fare in C++ attraverso una famiglia di smart pointer (possesso strong, shared, weak). Da notare, peraltro, che sarebbe sicuramente possibile creare un linguaggio dove NON esiste il concetto di puntatore "nudo", ma solo strong, shared o weak. Ovviamente, non e' un trend perseguito attivamente dai linguaggi moderni.

Conclusioni (2007)
Si vedano le conclusioni 2003 :-).

Bibliografia
[Alger94] Jeff Alger, "How to stop worrying and start loving C++, part 1", IEEE computer, Vol. 27 No. 6, June 1994.
[Gibbons00] Bill Gibbons, "A Portable typeof operator", C++ Users Journal, Vol. 18 No. 11, November 2000.
[Henney00] Kevlin Henney, "Valued Conversions", C++ Report, Vol. 12 No. 7, July/August 2000.
[KM96] Andrew Koenig, Barbara Moo, "Ruminations on C++", Addison-Wesley, 1996.
[Nelson00] Elden Nelson, "Interview With Bjarne Stroustrup", Visual C++ Developers Journal, May 2000.

Biografia
Carlo Pescio (pescio@eptacom.net) svolge attività di consulenza, progettazione e formazione in ambito internazionale. Ha svolto la funzione di Software Architect in grandi progetti per importanti aziende europee e statunitensi. È autore di numerosi articoli su temi di ingegneria del software, programmazione C++ e tecnologia ad oggetti, apparsi sui principali periodici statunitensi, nonché dei testi "C++ Manuale di Stile" ed "UML Manuale di Stile". Laureato in Scienze dell'Informazione, è membro dell'ACM, dell'IEEE e dell'IEEE Technical Council on Software Engineering.