MokaByte Numero 01 - Ottobre 1996
Java e C++ 

 

di
Fabrizio Giudici 
 

 


 
Il nostro collaboratore inizia qui una serie di articoli su Java e C++, una coppia quanto mai attuale di questi tempi. L'argomento sembra nasconda notevoli sorprese future.
Java e C++

Il linguaggio Java mostra una chiara simiglianza con il C++, tuttavia ne differisce in molti aspetti sostanziali. Il C++ è stato chiaramente preso come punto di riferimento grazie alla sua indiscutibile popolarità (almeno negli USA), ed in questo modo Sun ha drasticamente ridotto le difficoltà nell'apprendimento del suo nuovo linguaggio: i programmatori che conoscono il C++ devono faticare veramente poco per imparare la sintassi di Java. Ma sbaglieremmo di grosso se pensassimo che Java è solo un "C++ migliorato". Anzi, su quest'argomento è significativo quello che ci racconta uno dei tanti "miti" che circolano per la Rete, riguardante James Gosling, il "papà" di Java (personaggio definitivamente famoso in quanto, tra l'altro, è stato il creatore di Emacs, un ambiente di sviluppo per programmatori tra i più popolari nel mondo Unix). Egli avrebbe avuto l'illuminazione su quello che sarebbe poi divenuto Java mentre stava prendendo parte al progetto di un editor SGML (un linguaggio di cui l'HTML è sostanzialmente un sottoinsieme): scontrandosi con i problemi quotidiani che il C++ gli presentava, sviluppò una viscerale avversione per quel linguaggio e giurò che non lo avrebbe mai più utilizzato! Ed infatti il primo compilatore Java fu scritto in C... In questo articolo (il primo di una serie) esamineremo Java come linguaggio, trascurando tutte le altre caratteristiche legate a Java come strumento di sviluppo. In particolare confronteremo il linguaggio Java con il linguaggio C++, cominciando da quelle che per prime ci saltano agli occhi fino ad arrivare a quelle concettualmente più importanti. Saranno dati per scontati una basilare conoscenza del C++ e dei concetti fondamentali di Programmazione Orientata agli Oggetti. 

È bene affrontare con cautela il confronto tra Java e C++. Durante i primi mesi della sua diffusione, sui gruppi specializzati di Usenet sono scoppiate delle violente flame wars, guerre di religione tra i fanatici del C++ ed i nuovi fanatici di Java: le frasi tipo "Java is better than C++", "Java will kill C++" e così via fioccavano numerose, insieme a riposte spesso molto... colorite. Niente di più assurdo. Prima di tutto Java non è solo un linguaggio, ma è un intero ambiente di sviluppo che Sun ha progettato appositamente per il network computing. Ma anche riducendosi all'analisi di Java-linguaggio, Java e C++ non sono due cose intercambiabili (almeno per il momento). Anche se Java consente lo sviluppo di applicazioni molto complesse, non può sostituire, allo stato attuale delle cose, il C++ (e Sun non lo ha inventato per questo scopo). Tanto per dirne una (oltre ad una palese differenza di prestazioni), Java non permette programmazione di sistema a basso livello, né manipolazione diretta dell'I/O (per fare queste cose Java richiede l'intervento del vecchio glorioso C). Non è possibile attualmente scrivere un sistema operativo come Unix o Windows in Java puro, mentre sappiamo che la stragrande maggioranza dei sistemi operativi esistenti sono scritti in C/C++. Il C/C++ mantiene uno stretto contatto con l'hardware su cui gira, mentre Java crea volutamente un livello di astrazione intermedio. Java è certamente un progetto più omogeneo, elegante e moderno del C++, che ha sicuramente parecchie cose da "imparare" dal nuovo arrivato (io sono proprio curioso di vedere se e come Java influenzerà le nuove versioni dello standard C++), però i due linguaggi proseguiranno le loro strade, vicine ma separate, per parecchio tempo ancora.

Il primo impatto

Prendiamo un programma in Java, il primo che ci capita sottomano, e diamogli un primo sguardo superficiale. 

class B extends A { private int x; protected void method1(); public void method2(); } 

Ciò che ci colpisce subito è senz'altro la sua somiglianza con il C++: vediamo la definizione di una classe, con i suoi campi ed i suoi metodi. Ci sono alcune piccole differenze, come la parola chiave extends in luogo dei due punti usata per definire una nuova sottoclasse, oppure il fatto che gli specificatori di scopo (public, private, protected) vengono ripetuti ad ogni dichiarazione, mentre in C++ influenzano tutte le dichiarazioni che li seguono. Ma queste differenze riguardano solo l'estetica del programma e servono per migliorare la sua leggibilità. Una differenza sicuramente più marcata è che non esiste più separazione tra il sorgente vero e proprio (quello che in C++ ha desinenza .cpp o .cc) e il file che contiene i prototipi (quello con desinenza .h o .hh). Tutto il codice è contenuto in un unico file con desinenza .java. Da un punto di vista pratico questa organizzazione ci semplifica la vita: un file anziché due è certamente più facile da gestire e non dobbiamo nemmeno più preoccuparci di mantenere la sincronia dei prototipi. Il nome del file deve assolutamente combaciare con il nome della classe in esso definita e questo vale anche per il file che contiene il codice compilato (vedremo più avanti che è una via di mezzo tra un file oggetto ed un eseguibile). Questo vuol dire che ci può essere una sola classe dentro ogni file (con la parziale eccezione delle classi non pubbliche, la cui visibilità rimane comunque limitata all'interno del file in cui sono definite). Quindi le classi coincidono con i moduli componenti un programma. Su questo punto il compilatore è categorico e non ci lascia altre strade. Anche questa impostazione contribuisce a rendere le cose più ordinate: in effetti la regola "una sola classe dentro un solo file" era una delle tante raccomandazioni stilistiche del C++... ma quanti la seguivano (anche a causa del famigerato limite di 8 caratteri di MS-DOS...)? Questa minor libertà di scelta che il compilatore Java ci offre rispetto ad un compilatore C++ è una delle caratteristiche peculiari del linguaggio di Sun e la ritroveremo più avanti anche relativamente ad aspetti ben più importanti. Non è una limitazione, come può apparire a prima vista. Il C++ è un buon linguaggio, ma si è evoluto per mezzo di "stratificazioni" successive per iniziativa di differenti gruppi di lavoro, diventando un qualcosa che a volte può apparire persino informe: questo fatto è uno dei suoi peggiori difetti. Java invece è stato progettato con in mente una maggior omogeneità ed eleganza. E l'eleganza di un programma, oltre che un fatto estetico, spesso favorisce la sua robustezza ed efficienza. Come dicono alla Sun, "Java leaves you less ways to kill yourself", Java ci limita i modi di farci del male... tutto l'opposto del C++, un servitore anche troppo ubbidiente che ci accompagna volentieri nel caos!

I package

Ma procediamo con la nostra analisi. Avevamo detto che non esistono più i file con i prototipi... ma allora come si fa a collegare tra loro più moduli dello stesso programma? Come fa il compilatore a sapere quali metodi sono definiti in una determinata classe? Bene, è semplice: è in grado di ispezionare il codice compilato ed estrarre le informazioni necessarie (in questo modo di fatto non è più necessario generare esplicitamente i file delle dipendenze). Chi conosce per esempio il Borland Pascal avrà familiarità con questo approccio. In Borland Pascal i moduli di un programma che si vogliono impacchettare in librerie formano dei file con desinenza .tpu (Turbo Pascal Unit), che contengono sia il codice compilato che le informazioni simboliche relative ad esso. A dire la verità, qualsiasi file oggetto di un qualsiasi sistema operativo contiene ancora informazioni simboliche: basta provare a aprire con un editor di testo un file .obj in MS-DOS/Windows o un .o in Unix per trovarle, insieme ad una massa di codice binario illeggibile (i più esperti possono utilizzare i comandi tdump nel primo caso e nm nel secondo). Queste informazioni, però, servono solo ad identificare la posizione di una variabile o il punto di ingresso di un subroutine, ma non sono in grado di descrivere, per esempio, né il tipo della variabile né gli argomenti della subroutine (informazioni aggiuntive possono essere esplicitamente generate, ma queste sono destinate ad essere interpretate solo dai programmi per il debugging). Per dichiarare che si ha bisogno di importare alcune definizioni esterne si usa la direttiva import con il nome della classe da importare, in maniera analoga alla direttiva #include del C++. Più classi possono essere impacchettate logicamente in un unico contenitore, un package, ed in tal caso esse vengono individuate dall'indicazione package.classe. Questa caratteristica non è un'idea originale, in quanto molti altri linguaggi la possiedono da tempo, ed in realtà anche il C++ ha finalmente recepito questa necessità: l'ultima versione dello standard ha definito un costrutto funzionalmente simile, detto namespace. La divisione logica ed il raggruppamento di classi affini permette una miglior organizzazione del codice ed in particolare impedisce i conflitti tra nomi uguali (name clashes): per esempio due classi di nome Point possono tranquillamente esistere in due package diversi. Questo garantisce una maggior riusabilità del codice: due package diversi possono sempre essere utilizzati contemporaneamente da un unico programma. Ad una più attenta analisi si nota che in realtà il problema dei conflitti di nomi si ripresenta, seppur semplificato, nella scelta del nome del package. La proposta di Sun è la seguente: far precedere ad ogni nome di package l'indirizzo mnemonico di Internet invertito dell'ente per cui si lavora. Per esempio, tutto il software scritto all'interno del mio gruppo di lavoro, il Networking Competence Center (NCC) al Dipartimento di Ingegneria Biofisica ed Elettronica (DIBE) dell'Università di Genova dovrebbe essere confezionato in package il cui nome inizia con it.unige.dibe.ncc, perché l'indirizzo Internet del DIBE è dibe.unige.it. L'unicità dei nomi di package viene così garantita dall'unicità dei nomi di Internet, automaticamente, senza preoccuparsi di dover definire alcuna autorità di controllo centralizzata (da notare che l'idea dei nomi Internet è stata proposta anche al comitato ANSI/ISO C++, riscuotendo peraltro pochi consensi). In realtà il significato di questa scelta è ancora più sottile. Nello spirito di quello che sarà il network computing il codice dovrebbe essere riutilizzato il più possibile e distribuito via rete ogni volta che viene richiesto. Se qualcuno nel mondo scriverà un programma che utilizza un package scritto da me, non sarà necessariamente costretto a ridistribuirlo con il suo codice, ma potrà semplicemente importarlo con il suo nome completo: it.unige.dibe.ncc.package. Io, d'altro canto, dovrò configurare un server presso il mio dipartimento in grado di distribuire il mio package su richiesta. Il nome completo rimane quindi codificato nel codice eseguibile, così le virtual machine Java delle prossime generazioni saranno in grado di risalire al nome del server in grado di distribuire il package richiesto e quindi di ricuperarlo via rete anche se vengono eseguite a Giringiro, Australia, da Mr. Crocodile Dundee... Ovviamente le cose non sono così facili e richiedono che molti altri dettagli di questo meccanismo siano definiti. I package basati sui nomi DNS non sono però perfetti... a parte il fatto che neanche Sun segue perfettamente lo standard da lei proposto (!), in quanto i suoi package si chiamano sun.xxx e non com.sun.xxx, cosa succede se un package sviluppato da una ditta viene venduto ad un altra? E se io sviluppassi codice su base personale, sarei autorizzato a distribuirlo con il prefisso della mia Università? In caso negativo come lo potrei identificare, visto che io, personalmente, non ho un nome di dominio Internet? Le cose evidentemente non sono così semplici...

I tipi primitivi

Torniamo ad aspetti più legati al linguaggio. Mentre il C++ è sostanzialmente un C più il supporto della OOP, ma conserva tutte le caratteristiche originali del C, Java è pienamente orientato agli oggetti. A parte i tipi numerici primitivi, tutti i tipi di Java sono oggetti, e viene sempre garantita la discendenza da un antenato comune, la classe Object (in C++ si potevano avere più radici di gerarchie indipendenti). Non esistono neanche più le funzioni, almeno formalmente, ma solo metodi di classi. Ho detto formalmente, perché è chiaro che una funzione matematica come sin() non ha significato come metodo di alcunché... è in realtà un metodo statico della classe Math. Di fatto questo è un modo elegante per estendere anche alle funzioni la funzionalità delle gerarchie di nomi. I tipi primitivi invece non sono oggetti, proprio come in C++. Tenuto conto che molti linguaggi OOP definiscono anche i tipi primitivi come oggetti (si veda per esempio SmallTalk VERIFICARE), questa scelta progettuale appare come un compromesso per mantenere comunque una certa efficienza. La novità di Java è che il numero di bit di ciascun tipo primitivo è indipendente dalla piattaforma hardware. Così, per esempio, un int ha sempre 32 bit, uno short sempre 16, e così via. In C/C++, invece, la dimensione dell'int dipendeva strettamente dall'architettura della CPU. Non esistono quantità non segnate (unsigned) e l'intero a 64 bit (long) fa parte dello standard. Una curiosità è data dai caratteri, che sono a 16 bit anziché i consueti 8. Questi bit extra permettono la codifica UNICODE che supporta praticamente tutti gli alfabeti nazionali del mondo (in C esistono i cosiddetti wide chars con tanto di libreria di supporto, ma, come al solito, sono un'aggiunta poco omogenea, incompatibile tra l'altro con la libreria standard per la manipolazione delle stringhe). Tra le altre cose, finalmente una "e" accentata avrà lo stesso codice su un sistema Windows, Unix e Macintosh... Anche l'ordine con cui sono disposti i byte all'interno delle parole da più di 8 bit (la cosiddetta endianness) è fisso... e lo direste mai? Sono disposti con prima i byte più significativi, secondo lo schema noto come network order (tra l'altro definito da Sun come eXternal Data Representation (XDR) già dall'introduzione delle Remote Procedure Calls (RPC)). I floating point (da 32 e 64 byte) sono sempre conformi allo standard IEEE e non esistono più specifiche lasciate libere all'implementazione (per quanto riguarda la gestione di "casi patologici" come infinità o not-a-number (NAN), praticamente ogni compilatore C/C++ dice la sua...). Per concludere, tutte le variabili vengono sempre inizializzate a zero, a meno che non si specifichi un valore alternativo. L'inizializzazione si può fare dovunque sia consentito dichiarare una variabile, quindi anche dentro la dichiariazione di una classe. Tutte queste caratteristiche portano ad una perfetta portabilità del codice e fanno risparmiare parecchie aspirine ai programmatori per i quali il multi-piattaforma è un must...

Conversioni di tipo (type casting) 

Un linguaggio di programmazione può adottare diverse politiche riguardo la conversione da un tipo all'altro. Se è molto rigoroso nell'effettuare i controlli su quali conversioni sono lecite e quali no, viene detto strong typed. Quando nacque, il C non fu certamente pensato come tale: doveva essere semplicemente una via di mezzo tra un linguaggio ad alto livello ed un assemblatore, pertanto si fidava ciecamente di (quasi) tutto quello che il programmatore gli chiedeva di fare. In particolare, uno dei punti più delicati era l'assoluta interscambiabilità tra puntatori ed interi. Questa caratteristica, tra le più pericolose, è stata successivamente modificata con le versioni successive ed ancora di più con il C++: le conversioni di tipo richiedono in generale un'esplicita richiesta da parte del programmatore (tranne per casi banali come le conversioni tra interi di formato diverso). Questo però non ha risolto il problema. Un programmatore può richiedere di convertire un puntatore ad un oggetto in un puntatore ad un altro oggetto assumendosi tutta la responsabilità sulle conseguenze... che spesso sono disastrose! Vediamo questo punto più in dettaglio. Spesso capita di avere definito una classe (Base) ed una da essa derivata (Derived). Il modello di ereditarietà del C++ prevede che il layout delle classi derivate sia quello delle classi base con i nuovi campi aggiunti in fondo. Si veda la figura per un esempio:

class Base { int b; }; [ b ] class Derived : public Base { int d; }; [ b | d ]
Pertanto un puntatore Derived* derivedPtr è anche un buon puntatore di tipo Base*, cioè si può sempre usare il costrutto derivedPtr->b dal momento che il puntatore "vede" lo stesso layout (nel nostro esempio la variabile b). L'opposto non è ovviamente sempre vero: con un puntatore Base *basePtr il costrutto ((Derived*)basePtr)->d ha senso solo se basePtr sta effettivamente puntando ad un tipo Derived. In generale non è possibile saperlo al momento della compilazione, perché durante l'esecuzione del programma potremmo assegnare di volta in volta a basePtr l'indirizzo sia di classi Base che di classi Derived. Comunque il compilatore si fida sempre di noi e non compie alcuna verifica. Se ci stiamo sbagliando verranno compiuti degli accessi in memoria con esito assolutamente imprevedibile (si leggerà - o peggio scriverà - la memoria dove si crede erroneamente che d sia memorizzato e dove invece sono contenuti altri dati). La conversione di un puntatore da un tipo in un altro non è usata solo in programmi mal progettati, come si potrebbe pensare a prima vista. Questo errore di programmazione è difficilissimo da scoprire e da isolare, perché può anche non manifestarsi per parecchio tempo (generalmente, come previsto dalla legge di Murphy, non si manifesta durante il debugging, ma solo quando il programma viene consegnato al cliente). L'approccio C++ è sostanzialmente legato alle originarie necessità di efficienza, per cui si mira a generare il codice più compatto possibile, e la verifica dinamica dei tipi viene vista come uno spreco. D'altronde non viene neanche generata l'informazione necessaria per effettuare questa verifica. L'Annotated Reference di Stroustrup già suggeriva come porre rimedio al problema, costruendo del codice per gestire l'informazione dinamica sui tipi e usando opportune macro al posto del type casting nativo, ma spetta all'autodisciplina del programmatore limitarsi all'uso di questi costrutti più robusti. Il nuovo ANSI/ISO C++ formalizza e standardizza questi metodi, ma le vecchie sintassi rimarranno purtroppo valide per motivi di compatibilità con il codice esistente. La soluzione per questi errori andrà quindi ancora cercata nell'autodisciplina del programmatore: peccato però che gli esseri umani sono creature imperfette... La soluzione per Java è molto semplice: non esistono più cast impliciti, quindi il programmatore deve sempre richiedere le conversioni esplicitamente. Le situazioni che non possono essere risolte definitivamente a tempo di compilazione vengono verificate a runtime ed in caso di errori viene generata un'eccezione. 

Si conclude qui la prima puntata del confronto tra Java e C++. Nella prossima finiremo di esaminare le differenze più "grossolane" tra i due linguaggi, quali l'assenza del preprocessore ed un nuovo modo di concepire il famigerato goto. Vedremo anche come Java tenta di risolvere il problema della documentazione dei sorgenti e che novità ci offre sul formato degli eseguibili. 

Fabrizio Giudici (giudici@mokabyte.it) sta svolgendo il Dottorato di Studi in Ingegneria Elettronica ed Informatica presso l’Università di Genova. Opera presso il Networking Competence Center (http://dibe.unige.it/department/ncc) ed il suo interesse di ricerca primario è la sicurezza nell’ambito delle reti di calcolatori. Si interessa anche di programmazione orientata agli oggetti (OOP), con particolare riferimento ai linguaggi C++ e Java.
 
 
 

MokaByte rivista web su Java

MokaByte ricerca nuovi collaboratori
Chi volesse mettersi in contatto con noi può farlo scrivendo a mokainfo@mokabyte.it