[ Suggerimenti
] [ Soluzioni
degli Esercizi] [ Volume
2 ] [ Newsletter
Gratuita ]
[ Seminari
] [ Seminari
su CD ROM ] [ Consulenza]
[ Capitolo Precedente ] [ Indice Generale ] [ Indice Analitico ] [ Prossimo Capitolo ]
traduzione italiana e adattamento a cura di Mauro Sorbo
Poichè il C++ è basato sul C, si deve conoscere la sintassi del C al fine di programmare in C++, come si deve essere ragionevolmente spedito in algebra al fine di affrontare calcoli.
Se non si è mai visto prima il C, questo capitolo darà un decente background dello stile del C nel C++. Se si conosce lo stile del C descritto nella prima edizione di Kernighan & Ritchie (spesso chiamato K&R C), si troveranno nuove e differenti caratteristiche nel C++ in aggiunta al C standard. Se si conosce il C standard, si dovrebbe sfogliare questo capitolo guardando le caratteristiche che sono peculiari del C++. Si noti che ci sono alcune fondamentali caratteristiche del C++ introdotte qui, le quali sono idee base che sono affini alle caratteristiche del C o spesso modifiche ai modi in cui il C fa le cose. Le caratteristiche più sofisticate del C++ saranno introdotte negli ultimi capitoli.
Questo capitolo è una veloce panoramica dei costrutti del C ed un'introduzione ad alcuni costrutti base del C++, presumendo che si è avuto qualche esperienza di programmazione in un altro linguaggio. Un'introduzione al C si trova nel CD ROM confezionato in fondo al libro, intitolato Thinking in C: Foundations for Java & C++ di Chuck Allison (pubblicata da MindView, Inc., e anche disponibile al sito www.MindView.net). Questo e' un seminario in CD ROM con lo scopo di dare accuratamente tutti i fondamenti del linguaggio C. Si focalizza sulla conoscenza necessaria per poter destreggiarsi nei linguaggi C++ o Java piuttosto che provare a formare esperto del C ( una delle ragioni dell'uso del linguaggio alto come il C++ o il Java è precisamente il fatto che possiamo evitare molti i loro angoli buii). Esso contiene inoltre esercizi e soluzioni guidate. Si tenga a mente ciò perchè va oltre il CD Thinking in C, il CD non e' una sostituzione di questo capitolo, ma dovrebbe essere usato invece come una preparazione per il libro.
Creare funzioni
Nel vecchio (pre-Standard) C, si poteva chiamare una funzione con qualsiasi numero o tipi di argomento ed il compilatore non avrebbe reclamato. Ogni cosa sembrava giusta fino a quando non si faceva girare il programma. Si ottenevano risultati misteriosi (o peggio, il programma si bloccava) con nessun accenno ad un perchè. La mancanza di aiuto con il passaggio dell'argomento e gli enigmatici bug risultavano essere probabilmente una ragione per cui il C fu nominato "linguaggio assemblante di alto livello ". I programmatori del C Pre-Standard si adattarono ad esso.
Il C Standard ed il C++ usano una caratteristica chiamata function prototyping (prototipazione della funzione). Con il function prototyping, si deve usare una descrizione dei tipi di argomenti quando si sta dichiarando e definendo una funzione. Questa descrizione è un "prototipo". Quando la funzione viene chiamata , il compilatore usa il prototipo per assicurarsi che gli opportuni argomenti siano passati e che il valore restituito sia elaborato correttamente. Se il programmatore fa un errore quando chiama la funzione, il compilatore lo trova.
Essenzialmente, si è appreso della function prototyping ( non è stata chiamata in tal modo) nel capitolo precedente, poichè la forma della dichiarazione di funzione nel C++ richiede opportuni prototipi. In un prototipo di funzione, la lista degli argomenti contiene i tipi degli argomenti che devono essere passati alla funzione e ( a scelta per la dichiarazione) identificatori per gli argomenti. L'ordine ed il tipo di argomenti devono corrispondere nella dichiarazione, definizione e nella chiamata alla funzione. Ecco qui un esempio di un prototipo di funzione in una dichiarazione:
int traduci(float x, float y, float z);
Non si usa la stessa forma quando si dicharano variabili nei prototipi delle funzioni come si fa nelle definizioni ordinarie di variabili. Ciòè, non si può dire: float x, y, z. Si deve indicare il tipo di ogni argomento. In una dichiazione di funzione, la forma seguente è anche accettabile:
int traduci(float, float, float);
Poichè il compilatore non fa altro che controllare i tipi quando la funzione viene chiamata, gli identificatori sono soltanto inclusi per chiarezza di chi legge il codice.
Nella definizione di funzione, i nomi sono richiesti perchè viene fatto riferimento agli argomenti dentro la funzione:
int traduci(float x, float y, float z) {
x = y = z;
// ...
}
Ne deriva che questa regola si applica solo nel C. Nel C++, un argomento può essere senza nome nella lista degli argomenti della definizione di funzione. Poiché è senza nome, non lo si può usare nel corpo della funzione naturalmente. Gli argomenti senza nome sono concessi per dare al programmatore un modo per “riservare spazio nella lista degli argomenti”. Chiunque usa la funzione deve chiamare ancora la funzione con i propri argomenti. Comunque, la persona che crea la funzione può dopo usare l’argomento in futuro senza forzare modifiche al codice che chiama la funzione. L’opzione di ignorare un argomento nella lista è anche possibile se si lascia il nome dentro, ma si avrà un irritante messaggio di allarme circa il valore che non è usato ogni volta che si compila la funzione. L’allarme viene eliminato se si rimuove il nome.
Il C ed il C++ hanno altri due modi di dichiarare una lista di argomenti. Se si ha una lista di argomenti vuota, si può dichiararla come func( ) in C++, la quale dice al compilatore che ci sono esattamente zero argomenti. Si dovrebbe essere consapevoli che ciò significa solamente una lista di argomenti vuota in C++. In C ciò significa " un indeterminato numero di argomenti" ( il quale è un " buco" nel C poichè disabilita il controllo del tipo in quel caso). Sia in C che C++, la dichiarazione func(void); intende una lista di argomenti vuota. La parola chiave void intende "nulla" in questo caso (e può anche significare "nessun tipo" nel caso dei puntatori, come si vedrà in seguito in questo capitolo).
L'altra opzione per le liste di argomenti si usa quando non si sa quanti argomenti o che tipo di argomenti si avranno; ciò è chiamata una lista di argomenti variabile. Questa " lista di argomenti inaccertata" è rappresentata da ellissi (...). Definere una funzione con una lista di argomenti variabile è significativamente più complicato che definire una funzione regolare. Si può usare una lista di argomenti variabile per una funzione che ha una serie fissata di argomenti se ( per alcune ragioni) si vuole disabilitare il controllo di errore del prototipo di funzione. A causa di ciò, si dovrebbe restringere il nostro uso della lista di controllo variabile al C ed evitarli nel C++ (nella quale, come si imparerà, ci sono alternative migliori). L'uso della lista di argomenti variabile è descritta nella sezione della libreria della nostra guida locale C.
Le funzioni restituiscono valori
Un prototipo di funzione in C++ deve specificare il tipo del valore di ritorno della funzione ( in C, se si omette il tipo del valore di ritorno esso è per default un int). La specificazione del tipo di ritorno precede il nome della funzione. Per specificare che nessun valore viene restituito, si usa la parola chiave void. Ciò genererà un errore se si prova far ritornare un valore dalla funzione. Ecco qui alcuni prototipi completi di funzioni :
int f1(void); // restituisce un int, non da argomenti
int f2(); // Come f1() in C++ ma non in Standard C!
float f3(float, int, char, double); // Restituisce un float
void f4(void); // Non da argomenti, non restituisce nulla
Per far restituire un valore da una funzione, si usa la dichiarazione return. return fa ritornare l'esecuzione del programma esattamente dopo la chiamata alla funzione. Se return ha un argomento, quel argomento diventa un valore di ritorno della funzione. Se la funzione dice che ritornerà un tipo particolare, dopo ogni dichiarazione return deve ritornare quel tipo. Si può avere più di una dichiarazione return in una definizione di funzione:
//: C03:Return.cpp
//: uso del "return"
#include <iostream>
using namespace std;
char cfunc(int i) {
if(i == 0)
return 'a';
if(i == 1)
return 'g';
if(i == 5)
return 'z';
return 'c';
}
int main() {
cout << "scrivi un intero: ";
int val;
cin >> val;
cout << cfunc(val) << endl;
} ///:~
In cfunc( ), la prima if che valuta il true esce dalla funzione tramite la dichiarazione return. Notare che la dichiarazione di funzione non è necessaria perché la definizione di funzione appare prima che essa sia usata nel main( ), quindi il compilatore conosce la funzione da quella dichiarazione di funzione.
Utilizzo della libreria di funzioni del C
Tutte le funzioni nella libreria locale di funzioni del C sono disponibili mentre si programma in C++ . Si dovrebbe guardare attentamente alla libreria di funzioni prima di definire la propria funzione – c’è una buona possibilità che qualcuno abbia già risolto il problema per noi e probabilmente abbia dato ad esso molti più pensieri e debug.
Tuttavia una parola di prudenza: molti compilatori includono molte funzioni extra che rendono la vita anche più facile e sono allettanti da usare, ma non sono parte della libreria Standard del C. Se si è certi che non si vorrà mai spostare le applicazioni su un'altra piattaforma (e chi è certo di ciò), si vada avanti e si usino queste funzioni che rendono la vita più facile. Se si vuole che la nostra applicazione sia portabile, si dovrebbe limitare alle funzioni della libreria standard. Se si devono realizzare attività specifiche della piattaforma, si provi ad isolare quel codice in un punto così che esso possa essere cambiato facilmente quando la si sposta su un’altra piattaforma. In C++ le attività specifiche della piattaforma sono spesso incapsulate in una classe, che è la soluzione ideale.
La formula per usare una libreria di funzioni è la seguente: primo, trovare la funzione nella propria guida di riferimento per la programmazione ( molte guide elencano nell’indice la funzione per categorie e per ordine alfabetico). La descrizione della funzione dovrebbe includere una sezione che dimostri la sintassi del codice. La cima di questa sezione di solito ha almeno un #include, mostrandoci il file principale contenente il prototipo della funzione. Si duplichi questa linea #include nel nostro file, in modo che la funzione è propriamente dichiarata. Adesso si può chiamare una funzione nello stesso modo in cui appare nella sezione della sintassi. Se si fa un errore, il compilatore lo scoprirà comparandolo la nostra chiamata alla funzione con il prototipo della funzione presente in cima e ci darà informazioni circa il nostro errore. Il linker cerca la libreria Standard per default, quindi tutto ciò che si ha bisogna fare è: includere il file principale e chiamare la funzione.
Creare le proprie librerie con il librarian.
Si possono mettere le proprie funzioni in una libreria. Ogni ambiente di sviluppo ha un librarian che amministra gruppi di moduli oggetto. Ogni librarian ha i propri comandi, ma l’idea generale è questa: se si vuole creare una libreria, bisogna fare un file principale contenente prototipi di funzione per tutte le funzioni nella nostra libreria. Si mette questo file principale da qualche parte nella libreria di ricerca del preprocessore, ciascun nella directory locale (così può essere trovata con #include "header") o in una directory include (così può essere trovata con #include <header>). Poi si prendono tutti i moduli oggetto e si passano al librarian insieme con un nome per la libreria completata ( la maggior parte dei librarian richiedono una comune estensione, come .lib o .a). Si pone la libreria completata dove risiedono le altre librerie così il linker può trovarle. Quando si usa una propria libreria, si dovrà aggiungere qualcosa alla riga di comando così il linker sa cercare la libreria per la funzione che si chiama. Si devono trovare tutti i dettagli nel proprio manuale locale, poiché variano da sitema a sistema.
Controllare l'esecuzione
Questa sezione copre le istruzioni per il controllo dell' esecuzione in C++. Si devono conoscere queste dichiarazioni prima di poter leggere e scrivere il codice in C e C++.
Il C++ usa tutte le istruzioni per il controllo dell' esecuzione del C. Esse includono if-else, while, do-while, for ed una selezione di dichiarazioni chiamati switch. Il C++ ammette anche l’infame goto, il quale sarà evitato in questo libro.
True e false
Tutte le dichiarazioni condizionali usano la verità o la falsità di una espressione condizionale per determinare la traiettoria di esecuzione. Un esempio di una espressione condizionale è A == B. Questo usa l’operatore condizionale == per vedere se la variabile A è equivalente alla variabile B. L’espressione produce un Booleano true o false( queste sono parole chiave solo in C++; nel C una espressione è “vera” se valuta un valuta che non vale zero ). Altri operatori condizionali sono >, <, >=, etc. Le dichiarazioni condizionali sono più pienamente trattate in seguito in questo capitolo.
if-else
La dichiarazione if-else può esistere in due forme: con o senza l’ else. Le due forme sono:
if(espressione)
istruzione
oppure
if(espressione)
istruzione
else
istruzione
La “espressione” valuta true o false. L' “istruzione” significa sia una semplice istruzioni terminate da un punto e virgola oppure un' istruzione composta, la quale è un gruppo di semplici istruzioni racchiuse in parentesi. In qualsiasi momento la parola “ istruzione“ sia usata, implica sempre che la dichiarazione è semplice o composta. Si noti che questa istruzione può essere un altro if.
//: C03:Ifthen.cpp
// dimostrazione delle condizioni if e if-else
#include <iostream>
using namespace std;
int main() {
int i;
cout << "scrivi un numero e 'Enter'" << endl;
cin >> i;
if(i > 5)
cout << "è maggiore di 5" << endl;
else
if(i < 5)
cout << "è minore di 5 " << endl;
else
cout << "è uguale a 5 " << endl;
cout << "scrivi un numero e 'Enter'" << endl;
cin >> i;
if(i < 10)
if(i > 5) // "if" è soltanto un’altra istruzione
cout << "5 < i < 10" << endl;
else
cout << "i <= 5" << endl;
else // "if(i < 10)"
cout << "i >= 10" << endl;
} ///:~
E’ convenzione indentare il corpo delle istruzioni del flusso di controllo così che il lettore può facilmente determinare dove esso inizia e dove finisce[30].
while
while, do-while e for servono per il controllo del ciclo. Una istruzione è ripetuta fino a quando l’espressione di controllo vale false. La forma del ciclo while è
while(espressione)
istruzione
L’espressione è valutata solamente una volta all’inizio del ciclo e ancora prima di ogni ulteriore iterazione dell' istruzione.
Questo esempio sta nel corpo del ciclo while fino a quando si scrive il numero segreto o si preme control-C.
//: C03:Guess.cpp
// indovina un numero ( dimostra "while")
#include <iostream>
using namespace std;
int main() {
int secret = 15;
int guess = 0;
// "!=" è il condizionale "non-uguale":
while(guess != secret) { // dichiarazione complessa
cout << "indovina il numero: ";
cin >> guess;
}
cout << "Hai indovinato !" << endl;
} ///:~
L’espressione condizionale di while non è ristretta ad un semplice test come nell’esempio su menzionato; può essere complicato quanto piaccia sempre che produce un risultato true o false. Si vedra anche il codice dove il ciclo non ha corpo, solamente un semplicemente punto e virgola:
while(/* Do a lot here */)
;
In questi casi, il programmatore ha scritto l' espressione condizionale non solo per effettuare il test ma anche per fare il lavoro.
do-while
La forma del do-while è
do
istruzione
while(espressione);
Il do-while è differente dal while perchè l'istruzione è eseguita sempre almeno una volta, anche se l’espressione vale false in primo luogo. In un nomrale while, se il condizionale è falso nella prima volta l'istruzione non viene mai eseguita.
Se il do-while è usato in Guess.cpp , la variabile guess non ha bisogno di un valore iniziale, poiché è inizializzato dall' istruzione cin prima che viene esaminata:
//: C03:Guess2.cpp
// il programma guess con do-while
#include <iostream>
using namespace std;
int main() {
int secret = 15;
int guess; // Qui non serve nessuna inizializzazione
do {
cout << "Indovina il numero: ";
cin >> guess; // Avviene l’inizializzazione
} while(guess != secret);
cout << "You got it!" << endl;
} ///:~
Per alcune ragioni, la maggior parte dei programmatori tendono a evitare do-while e a lavorare solo con while.
for
Un ciclo for effettua un' inizializzazione prima della prima ripetizione. Dopo effettua la prova condizionale e, alla fine di ogni ripetizione, alcune forme di “progresso”. La forma del ciclo for è:
for(inizializzazione; condizione; passo)
istruzione
Qualsiasi delle espressioni di inizializzazione,condizione e passo possono essere vuote. Il codice di inizializzazione è eseguito una volta soltanto all’inizio. La condizione è eseguita prima di ogni ripetizione ( se vale falso all’inizio, l'istruzione non è mai eseguita ). Alla fine di ogni ciclo, viene eseguito il passo.
I cicli for sono di solito usati per il compito di “conteggio”:
//: C03:Charlist.cpp
//Visualizza tutti i caratteri ASCII
// dimostra il "for"
#include <iostream>
using namespace std;
int main() {
for(int i = 0; i < 128; i = i + 1)
if (i != 26) // ANSI Terminal Clear screen
cout << " valore: " << i
<< " carattere: "
<< char(i) // tipo di conversione
<< endl;
} ///:~
Si può notare che la variabile i è definita nel punto in cui è usata, invece che all’inizio del blocco denotato dall’apertura della parentesi graffa ‘{’. Ciò è in contrasto con le procedure di linguaggio tradizionali( incluso C) i quali richiedono che tutte le variabili siano definite all’inizio del blocco. Ciò verrà discusso in seguito in questo capitolo.
Le parole chiave break e continue
Dentro il corpo di qualsiasi construtto dei cicli while, do-while, o for si può controllare il fluire del ciclo usando break e continue. break fa uscire dal ciclo senza eseguire il resto delle istruzioni nel ciclo. continue fa finire l’esecuzione della corrente iterazione e ritorna all’inizio del ciclo per iniziare una nuova iterazione.
Come esempio di break e continue, ecco questo programma che è un semplice menu di sistema:
//: C03:Menu.cpp
// dimostrazione di un semplice menu di programma
// l' uso di "break" e "continue"
#include <iostream>
using namespace std;
int main() {
char c; // per memorizzare il responso
while(true) {
cout << "MAIN MENU:" << endl;
cout << "l: sinistra, r: destra, q: quit -> ";
cin >> c;
if(c == 'q')
break; // fuori dal "while(1)"
if(c == 'l') {
cout << " MENU A SINISTRA:" << endl;
cout << "scegli a oppure b: ";
cin >> c;
if(c == 'a') {
cout << "hai scelto 'a'" << endl;
continue; // ritorna al menu principale
}
if(c == 'b') {
cout << "hai scelto 'b'" << endl;
continue; // ritorna al menu principale
}
else {
cout << "non hai scelto nè a nè b!"
<< endl;
continue; // ritorna al menu principale
}
}
if(c == 'r') {
cout << " MENU A DESTRA:" << endl;
cout << "scegli c oppure d: ";
cin >> c;
if(c == 'c') {
cout << "hai scelto 'c'" << endl;
continue; // ritorna al menu principale
}
if(c == 'd') {
cout << "hai scelto 'd'" << endl;
continue; // ritorna al menu principale
}
else {
cout << "non hai scelto nè c nè d!"
<< endl;
continue; // ritorna al menu principale
}
}
cout << "devi digitare l oppure r oppure q!" << endl;
}
cout << "uscita dal menu..." << endl;
} ///:~
Se l’utente seleziona ‘q’ nel menu principale, il break è usato per uscire, altrimenti il programma continua ad eseguire indefinitamente. Dopo ogni selezione del sub-menu, la parola chiave continue è usata per fare un salto indietro all’inizio del ciclo while.
L'istruzione while(true) è l’equivalente di “ fai questo ciclo per sempre “. La dichiarazione break ci permette di interrompere questo infinito ciclo while quando l’utente scrive una ‘q’.
switch
Un' istruzione switch seleziona tra pezzi di codice basati sul valore dell’espressione intero. La sua forma è:
switch(selezione) {
case valore-intero1 : istruzione; break;
case valore-intero2 : istruzione; break;
case valore-intero3 : istruzione; break;
case valore-intero4 : istruzione; break;
case valore-intero5 : istruzione; break;
(...)
default: istruzione;
}
selezione è un’espressione che produce un valore intero. Switch confronta il risultato di selezione con ogni valore intero. Se ne trova uno simile, la corrispondente dichiarazione ( semplice o composta) viene eseguita. Se non si presenta nessun simile, viene eseguita l'istruzione default.
Si noterà nelle definizioni summenzionate che ogni case si conclude con un break, il quale sposta l’esecuzione alla fine del corpo dello switch ( la chiusura della parentesi che completa lo switch). Questo è il modo convenzionale per costruire una dichiarazione switch, ma il break è facoltativo. Se manca vengono eseguite le istruzione del seguente case fino a che non si incontra il break. Sebbene non si vuole usualmente questo tipo di comportamento, può essere utile per un programmatore esperto.
L'istruzione switch è un modo pulito per attuare una selezione multipla ( es. selezionare differenti percorsi di esecuzione), ma richiede un selettore che valuta un valore intero a tempo di compilazione. Se si vuole usare, ad esempio, un oggetto string come selettore, non funziona in una dichiarazione di switch. Per un selettore string, si deve usare invece una serie di dichiarazioni if e confrontare la string dentro il condizionale.
L’esempio di menu mostrato sotto fornisce un buon particolare esempio di switch:
//: C03:Menu2.cpp
// un menu che usa uno switch
#include <iostream>
using namespace std;
int main() {
bool quit = false; // un flag per uscire
while(quit == false) {
cout << "Scegli a, b, c oppure q per terminare: ";
char response;
cin >> response;
switch(response) {
case 'a' : cout << "hai scelto 'a'" << endl;
break;
case 'b' : cout << "hai scelto 'b'" << endl;
break;
case 'c' : cout << "hai scelto 'c'" << endl;
break;
case 'q' : cout << "fine" << endl;
quit = true;
break;
default : cout << "Scegliere a,b,c oppure q!"
<< endl;
}
}
} ///:~
Il flag quit è un bool, abbreviazione di “Boolean”, che è un tipo che si troverà soltanto in C++. Esso può assumere solo i valori chiave true o false. Selezionare ‘q’ setta il flag quit su true. La prossima volta che il selettore sarà valutato, quit == false ritorna false così che il corpo del while non è eseguito.
Uso e abuso di goto
La parola chiave goto è supportata in C++, poichè esiste anche in C. L'uso di goto è spesso respinto perchè appartiene uno stile mediocre di programmazione e la maggior parte delle volte è vero. Ogni volta che si usa goto, si guardi il proprio codice per vedere se c’è un altro modo per scriverlo. In rare occasioni, si può scoprire che goto può risolvere un problema che non può essere risolto in altro modo, ma tuttavia, lo si consideri con cura. Ecco qui un esempio che ne fa forse un possibile candidato:
//: C03:gotoKeyword.cpp
// L’infame goto è supportato nel C++
#include <iostream>
using namespace std;
int main() {
long val = 0;
for(int i = 1; i < 1000; i++) {
for(int j = 1; j < 100; j += 10) {
val = i * j;
if(val > 47000)
goto bottom;
//Break andrebbe messo solamente esternamente al ‘for’
}
}
bottom: // un'etichetta
cout << val << endl;
} ///:~
L’alternativa sarebbe settare un Booleano che è testato nella parte più esterna del ciclo for, e dopo fare un break all’interno del ciclo. Comunque, se si hanno svariati livelli di for o while questo potrebbe essere scomodo.
Ricorsione
La ricorsione è un’interessante e qualche volta utile tecnica di programmazione con cui si può chiamare la funzione in cui si è. Naturalmente se questo è tutto quello che si fa, si continuerà a chiamare la funzione dove si è fino a quando non si esaurisce la memoria, quindi ci deve essere qualche modo di “raggiungere il livello più basso “ della chiamata ricorsiva. Nel esempio seguente, questo “raggiungimento del livello più basso “ è compiuto dicendo semplicente che la ricorsione andrà soltano fino a che il cat supera ‘Z’: :[31]
//: C03:CatsInHats.cpp
// semplice dimostrazione di ricorsione
#include <iostream>
using namespace std;
void removeHat(char cat) {
for(char c = 'A'; c < cat; c++)
cout << " ";
if(cat <= 'Z') {
cout << "cat " << cat << endl;
removeHat(cat + 1); // chiamata ricorsiva
} else
cout << "VOOM!!!" << endl;
}
int main() {
removeHat('A');
} ///:~
In removeHat( ), si può vedere che finchè cat è minore di ‘Z’, removeHat( ) verrà chiamata dentro removeHat( ), effettuando così la ricorsione. Ogni volta che removeHat( ) è chiamata, il proprio argomento è l'unico più grande del corrente cat quindi l’argomento continua ad incrementare.
La ricorsione è spesso usata quando si valuta qualche tipo di problema complesso, poichè non si è ristretti ad una particolare “taglia” per la soluzione – la funzione può richiamarsi fino a che non si è raggiunta la fine del problema.
Introduzione agli operatori
Si può pensare che gli operatori come tipi speciali di funzioni ( si apprenderà che il sovraccaricamento degli operatori del C++ tratta gli operatori precisamente in quel modo). Un operatore prende uno o più argomenti e produce un nuovo valore. Gli argomenti hanno differente forma rispetto alle ordinarie chiamate di funzioni, ma l’effetto è lo stesso.
Dalla propria precedente esperienza di programmazione, si dovrebbe aver abbastanza confidenza con gli operatori che sono stati usati. I concetti di addizione (+), sottrazione ed il segno meno (-), moltiplicazione (*), divisione (/), e assegnamento(=) hanno tutti essenzialmente lo stesso significato in ogni linguaggio di programmazione. L’intera serie di operatori è elencata in seguito in questo capitolo.
Precedenza
L’operatore precedenza definisce l’ordine nel quale è valutata un’espressione quando sono presenti svariati operatori. Il C ed il C++ hanno specifiche regole per determinare l’ordine di valutazione. Il più facile da ricordare è che la moltiplicazione e la divisione vengono prima dell’addizione e della sottrazione. Dopo ciò, se un’espressione non ci è trasparente probabilmente non lo sarà per tutti quelli che leggono il codice, quindi si dovrebbero usare le parentesi per rendere esplicito l'ordine di valutazione. Per esempio:
A = X + Y - 2/2 + Z;
ha un differente significato dalla stessa dichiarazione con un gruppo particolare di parentesi:
A = X + (Y - 2)/(2 + Z);
(si provi a valutare il risultato con X = 1, Y = 2, e Z = 3).
Auto incremento e decremento
Il C, e di conseguenza il C++, è pieno di scorciatoie. Le scorciatoie possono rendere il codice molto più facile da scrivere e qualche volta molto più diffice da leggere. Forse i creatori del linguaggio C pensarono che sarebbe stato più facile capire un pezzo difficile di codice se i nostri occhi non avevano da esaminare una larga area di testo.
Una della migliori scorciatoie sono gli operatori auto-incremento e auto-decremento. Si usano spesso per cambiare le variabili del ciclo, le quali controllano il numero di volte che un ciclo viene eseguito.
L’operatore auto-decremento è ‘--’ e significa “ decrementa di una unità ”. L’operatore auto-incremento è ‘++’ e significa “ incrementa di una unità ”. Se A è un int, ad esempio, l’espressione ++A è equivalente a (A = A + 1). Gli operatori auto-incremento e auto-decremento producono il valore della variabile come risultato. Se l’operatore appare prima della variabile (es. ++A), l’operazione è prima eseguita ed il valore risultante viene prodotto. Se l’operatore appare dopo la variabile (es. A++), il valore è prodotto corrente e l’operazione è eseguita successivamente. Ad esempio :
//: C03:AutoIncrement.cpp
// mostra l’uso degli operatori auto-incremento
//e auto-decremento
#include <iostream>
using namespace std;
int main() {
int i = 0;
int j = 0;
cout << ++i << endl; // Pre-incremento
cout << j++ << endl; // Post-incremento
cout << --i << endl; // Pre-decremento
cout << j-- << endl; // Post decremento
} ///:~
cout << ++i << endl; // Pre-increment
cout << j++ << endl; // Post-increment
cout << --i << endl; // Pre-decrement
cout << j-- << endl; // Post decrement
} ///:~
Se ci si è chiesto perchè il nome “C++”, ora si capisce cosa significa: “ un passo oltre il C “.
Introduzione ai tipi di dato
I Data types ( tipi di dato) definiscono il modo in cui si usa lo spazio ( memoria) nei programmi che si scrivono. Specificando un tipo di dato, si dice al compilatore come creare un particolare pezzo di memoria e anche come manipularla.
I tipi di dato possono essere predefiniti o astratti. Un tipo di dato predefinito è uno che il compilatore intrinsecamente conosce, uno che è fissato nel compilatore. I tipi di dato predefiniti sono quasi identici in C e C++. Un tipo di dato definito da un utente è uno che noi o un altro programmatore crea come una classe. Queste sono comunemente detti tipi di dato astratti. Il compilatore sa come gestire tipi predefiniti quandoparte; esso “impara” come gestire i tipi di dato astratti leggendo i file principali contenenti le dichiarazioni di classi ( si imparerà ciò nel prossimo capitolo).
Tipi base predefiniti
La specificazione dello standard C per i tipi predefiniti ( che il C++ eredita) non dice quanti bit ogni tipo predefinito deve contenere. Invece, esso stipula che il minimo ed il massimo valore che il tipo predefinito deve avere. Quando una macchina è basata su binari, questo valore massimo può essere direttamente tradotto in un numero minimo di bit necessari a rappresentare quel valore. Tuttavia, se una macchina usa, ad esempio, il codice binario decimale (BCD) per rappresentare numeri, allora l’ammontare dello spazio richiesto nella macchina per ottenere il numero massimo per ogni tipo di data sarà differente. I valori di minimo e massimo che possono essere immagazzinati in vari tipi di data sono definiti nei file del sistema principale limits.h e float.h (in C++ si scriverà invece generalmente #include <climits> e <cfloat>).
Il C ed il C++ hanno quattro data tipi base predefiniti, descritti qui per le macchine basate sul sistema binario. Una char memorizza caratteri ed usa un minimo di 8 bit( 1 byte ), sebbene può essere più grande. Un int immagazzina un numero intero ed usa un minimo di due byte. I tipi float e double memorizzano numeri a virgola mobile, di solito nel formato virgola mobile IEEE. Float è per singola precisione virgola-mobile e double per virgola mobile doppia precisione .
Come menzionato in precedenza, si possono definire variabili in qualsiasi parte in uno scope e le si possono definire ed inizzializzare allo stesso tempo. Ecco qui come definire variabili usano i quattro tipi di dato base:
//: C03:Basic.cpp
//Definizione dei quattro tipi base di dato
//in C e C++
int main() {
//definizione senza inizializzazione
char protein;
int carbohydrates;
float fiber;
double fat;
//definizione ed inizializzazione simultanea
char pizza = 'A', pop = 'Z';
int dongdings = 100, twinkles = 150,
heehos = 200;
float chocolate = 3.14159;
//Notazione esponenziale:
double fudge_ripple = 6e-4;
} ///:~
La prima parte del programma definisce variabili dei quattro tipi di dato base senza inizializzarli. Se non si inizializza una variabile, lo standard dice che essi sono indefiniti ( di solito, significa che contiene spazzatura). La seconda parte del programma definisce e inizializza variabili allo stesso tempo ( è sempre meglio, se possibile, procurare un valore di inizializzazione al punto di definizione). Si noti che l’uso della notazione esponenziale è nella costante 6e-4.
bool, true, & false
Prima che bool diventasse parte dello standard C++, ognuno tendeva ad usare differenti tecniche per simulare un Booleano. Si ottenevano problemi di portabilità e potevano introdurre subdoli errori.
Il tipo bool dello standard C++ può avere due stati espressi dalle costanti true ( che è convertita all'intero 1) e quella false (che è convertita all'intero zero). Tutti e tre i nomi sono parole chiave. In più, alcuni elementi del linguaggio sono stati adattati.
Elemento |
Uso con bool |
&& || ! |
prende argomenti bool e produce risultati bool. |
< > <= >= == != |
produce risultati bool. |
if, for, while, do |
Espressione convezionale che converte ai valori bool. |
? : |
Primo operando converte al valore bool. |
Poichè ci sono molti codici esistenti che usano un int per rappresentare un flag, il compilatore implicitamente convertirà un int in un bool ( il valore non zero produrrà true mentre quello zero produrrà false). Idealmente, il compilatore ci darà un allarme come suggerimento per correggere la situazione.
Un idioma che è classificato come: ” stile povero di programmazione” è l’uso del ++ per settare la bandiera a true. Ciò è ancora permesso, ma deprecato, cioè in futuro potrebbe non esserlo. Il problema è che se si sta facendo una conversione di tipo implicita da bool a int, incrementando il valore (forse oltre la portata del valori normali di bool zero e uno) , e dopo implicitamente lo si riconverte ancora.
I puntatori (i quali verranno introdotti in seguito in questo capitolo) saranno automaticamente convertiti in bool quando sarà necessario.
Specificatori
Gli specificatori modificano i significati dei tipi basi incorporati e li espandono ad una serie abbastanza più larga. Ci sono quattro specificatori: long, short, signed, e unsigned.
Long e short modificano i valori di massimo e minimo che il tipo di dato gestirà. Un semplice int deve essere come minimo la grandezza di uno short. La gerarchia della grandezza per un tipo intero è: short int, int, long int. Tutte le grandezze potrebbero essere le stesse, sempre che soddisfano le richieste dei valori di minimo e massimo. In una macchina con una word di 64-bit, per esempio, tutti i tipi di dato potrebbero essere a 64 bit.
La gerarchia di grandezza per i numeri virgola-mobile è : float, double, e long double. “long float” non è un tipo legale. Non ci sono numeri in virgola mobile short.
Gli specificatori signed e unsigned dicono al compilatore come usare il bit segno con i tipi interi ed i caratteri (i numeri in virgola mobile contengono un segno) un numero unsigned non tiene conto del segno e dunque ha un bit extra disponibile, quindi può salvare numeri positivi grandi due volte i numeri positivi che possono essere salvati in un numero signed. signed è per default ed è solo necessario con char; char può essere o non essere per default signed. Specificando signed char, si utilizza il bit di segno.
Il seguente esempio mostra come la grandezza del tipo di dato in bytes usando l’operatore sizeof, introdotto più avanti in questo capitolo:
//: C03:Specify.cpp
// Dimostra l’uso dei specificatori
#include <iostream>
using namespace std;
int main() {
char c;
unsigned char cu;
int i;
unsigned int iu;
short int is;
short iis; // come uno short int
unsigned short int isu;
unsigned short iisu;
long int il;
long iil; // come long int
unsigned long int ilu;
unsigned long iilu;
float f;
double d;
long double ld;
cout
<< "\n char= " << sizeof(c)
<< "\n unsigned char = " << sizeof(cu)
<< "\n int = " << sizeof(i)
<< "\n unsigned int = " << sizeof(iu)
<< "\n short = " << sizeof(is)
<< "\n unsigned short = " << sizeof(isu)
<< "\n long = " << sizeof(il)
<< "\n unsigned long = " << sizeof(ilu)
<< "\n float = " << sizeof(f)
<< "\n double = " << sizeof(d)
<< "\n long double = " << sizeof(ld)
<< endl;
} ///:~
il risultato che si ottiene facendo girare il programma probabilmente sarà differente da macchina/sistema operativo/ compilatore, poichè ( come menzionato precedentemente) l’unica cosa che deve essere consistente è che ogni differente tipo memorizza i valori minimi e massimi specificati nello standard.
Quando si modifica un int con short o long, la parola chiave int è facoltativa, come mostrato sopra.
Introduzione ai puntatori
Ogni volta che si fa girare un programma, viene prima caricato ( tipicamente dal disco) nella memoria del computer. Quindi, tutti gli elementi del nostro programma sono allocati da qualche parte nella memoria. La memoria è tipicamente disposta come una serie sequenziale di locazioni di memoria; ci riferiamo usualmente a queste locazioni come bytes di 8 bit ma realmente la grandezza di ogni spazio dipende dall’architettura della particolare macchina e di solito è chiamata grandezza della word della macchina. Ogni spazio può essere unicamente distinto dagli altri spazi dal proprio indirizzo. Per i propositi di questa discussione, si dirà solamente che tutte queste macchine usano byte che hanno indirizzi sequenziali che cominciano da zero e proseguono in su fino alla fine della memoria che si ha nel proprio computer.
Poichè il proprio programma vive in memoria mentre sta è eseguito, ogni elemento del programma ha un indirizzo. Supponiamo di iniziare con un semplice programma:
//: C03:YourPets1.cpp
#include <iostream>
using namespace std;
int cane, gatto, uccello, peshe;
void f(int animale) {
cout << "numero id animale: " << animale<< endl;
}
int main() {
int i, j, k;
} ///:~
Ciascuno degli elementi in questo programma ha una locazione in memoria quando il programma viene eseguito. Anche la funzione occupa memoria. Come si vedrà, l’area di memoria dove l’elemento è posto dipende dal modo in cui si definisce esso da e quale elemento è.
C’è un operatore in C e C++ che ci dirà l’indirizzo di un elemento. Questo è l’operatore ‘&’. Tutto quello che si fa è precedere il nome dell’identificatore con ‘&’ ed esso produrrà l’indirizzo di quell' identificatore. YourPets1.cpp può essere modificato per scrivere gli indirizzi di tutti questi elementi:
//: C03:YourPets2.cpp
#include <iostream>
using namespace std;
int cane, gatto, uccello, pesce;
void f(int animale) {
cout << "numero id animale: " << animale<< endl;
}
int main() {
int i, j, k;
cout << "f(): " << (long)&f << endl;
cout << "cane: " << (long)&cane<< endl;
cout << "gatto: " << (long)&gatto<< endl;
cout << "uccello: " << (long)&uccello<< endl;
cout << "pesce: " << (long)&pesce<< endl;
cout << "i: " << (long)&i << endl;
cout << "j: " << (long)&j << endl;
cout << "k: " << (long)&k << endl;
} ///:~
(long) è un cast. Significa: ” Non trattare questo come se fosse un tipo normale, invece trattalo come un long”. Il cast non è essenziale, ma se non vi fosse, gli indirizzi sarebbero scritti invece in esadecimale, così assegnare un long rende le cose un po’ più leggibili.
Il risultato di questo programma varierà dipendendo al tuo computer, OS, e tutte le sorti di altri fattori, ma darà sempre alcuni interessanti comprensioni. Per un singolo eseguibile sul nostro computer, il risultato assomiglia a questo:
The results of this program will vary depending on your computer, OS, and all sorts of other factors, but it will always give you some interesting insights. For a single run on my computer, the results looked like this:
f(): 4198736
dog: 4323632
cat: 4323636
bird: 4323640
fish: 4323644
i: 6684160
j: 6684156
k: 6684152
si può vedere come le variabili che sono definite dentro il main( ) sono in differenti aree delle variabili definite fuori dal main( ), si capirà il perchè qunado si imparerà di più il linguaggio. f() appare nella sua area; il codice è tipicamente separato dal dato in memoria.
Un’altra cosa interessante da notare è che le variabili definite una dopo l’altra sono poste in memoria in modo contiguo. Esse sono separate da un numero di byte che sono richiesti dai loro tipi di dato. Qui, l’unico tipo di dato usato è int, e cat è posto 4 byte da dog, e bird 4 byte da cat, ecc. Così apparirebbe che, in questa macchina, un int è lungo 4 byte.
Un’altra come questo interessante esperimento mostrante come la memoria è mappato, cosa si può fare con un indirizzo? La cosa principale importante da fare è salvarlo dentro un’altra variabile per un uso in seguito. C e C++ hanno un tipo speciale di variabile che detiene un indirizzo. Questa variabile è chiamata puntatore.
Other than this interesting experiment showing how memory is mapped out, what can you do with an address? The most important thing you can do is store it inside another variable for later use. C and C++ have a special type of variable that holds an address. This variable is called a pointer.
L’operatore che definisce un puntatore è lo stesso usato per la moltiplicazione ‘*’. Il compilatore sa che non è una moltiplicazione dal contesto in cui è usato, come si vedrà.
Quando si definisce un puntatore, si deve specificare il tipo di variabile a cui punta. Si inizia dando un nome di un tipo, dopo invece di immediatamente dare un identificatore per la variabile, si dice ”aspetta, è un puntatore “ inserendo una stella tra il tipo e l’identificatore. Così il puntatore verso un int somiglia come questo:
When you define a pointer, you must specify the type of variable it points to. You start out by giving the type name, then instead of immediately giving an identifier for the variable, you say “Wait, it’s a pointer” by inserting a star between the type and the identifier. So a pointer to an int looks like this:
int* ip; // ip punta ad una variabile int
int* ip; // ip points to an int variable
L’associazione di ‘*’ con un tipo appare sensata a si legge facilmente, ma può realmente essere un po’ ingannevole. La nostra inclinazione potrebbe essere dire “ puntatore int “ come se fosse un singolo discreto tipo. Comunque, con un int o un altro tipo data base, è possibile dire:
The association of the ‘*’ with the type looks sensible and reads easily, but it can actually be a bit deceiving. Your inclination might be to say “intpointer” as if it is a single discrete type. However, with an int or other basic data type, it’s possible to say:
int a, b, c;
mentre con un puntatore, ci piacerebbe dire:
whereas with a pointer, you’d like to say:
int* ipa, ipb, ipc;
la sintassi del C ( e in eredità, quella del C++) non permette espressioni così sensate. Nella definizione sopracitata, solo ipa è un puntatore, ma ipb e ipc sono ordinari int( si può dire che “* lega più saldamente all’ identificatore”). Conseguentemente, il miglior risultato può essere realizzato usando solo una definizione per riga; ancora si ottiene una sintassi sensata senza confusione:
C syntax (and by inheritance, C++ syntax) does not allow such sensible expressions. In the definitions above, only ipa is a pointer, but ipb and ipc are ordinary ints (you can say that “* binds more tightly to the identifier”). Consequently, the best results can be achieved by using only one definition per line; you still get the sensible syntax without the confusion:
int* ipa;
int* ipb;
int* ipc;
poichè una linea direttiva per la programmazione in C++ è che si dovrebbe sempre inizializzare una variabile al punto di definizione, questa forma effettivamente funziona meglio. Ad esempio, le variabili soprammenzionate non sono inizializzate ad alcun particolar valore; contengono spazzatura. E’ molto meglio dire qualcosa come:
Since a general guideline for C++ programming is that you should always initialize a variable at the point of definition, this form actually works better. For example, the variables above are not initialized to any particular value; they hold garbage. It’s much better to say something like:
int a = 47;
int* ipa = &a;
ora entrambi a e ipa sono stati inizializzati, e ipa detiene l’indirizzo di a.
Una volta che si ha inizializzato un puntatore, la cosa principale che si può fare è usarlo per modificare il valore a cui punta. Accedere ad una variabile attraverso un puntatore, si deferenzia il puntatore usando lo stesso operatore per definirlo, come questo:
Once you have an initialized pointer, the most basic thing you can do with it is to use it to modify the value it points to. To access a variable through a pointer, you dereference the pointer using the same operator that you used to define it, like this:
*ipa = 100;
ora a contiene il valore 100 invece di 47.
Queste sono le basi per i puntatori: si può detenere un indirizzo, e lo si può usare per modificare la variabile originale. Ma la domanda rimane ancora: perché non voler modificare una variabile usando un’altra variabile come una delega?
These are the basics of pointers: you can hold an address, and you can use that address to modify the original variable. But the question still remains: why do you want to modify one variable using another variable as a proxy?
Per questo introduttorio punto di vista, possiamo mettere la risposta in due larghe categorie:
1. cambiare “ gli oggetti di fuori “ dal di dentro di una funzione. Questo è forse la uso base principale dei puntatori, e sarà esaminato qui.
2. per realizzare molti altri intelligenti tecniche di programmazione, le quali ci impareremo un po’ alla volta nel resto del libro.
For this introductory view of pointers, we can put the answer into two broad categories:
1. To change “outside objects” from within a function. This is perhaps the most basic use of pointers, and it will be examined here.
2. To achieve many other clever programming techniques, which you’ll learn about in portions of the rest of the book.
Modificare l’oggetto esterno
Normalmente, quando si passa un argomento ad una funzione, una copia di quel argomento viene fatta dentro la funzione. Questo è detto pass-by-value (passaggio per valore). Si può vedere l’effetto del passaggio per valore nel seguente programma:
//: C03:PassByValue.cpp
#include <iostream>
using namespace std;
void f(int a) {
cout << "a = " << a << endl;
a = 5;
cout << "a = " << a << endl;
}
int main() {
int x = 47;
cout << "x = " << x << endl;
f(x);
cout << "x = " << x << endl;
} ///:~
In f(), a è una variabile locale, quindi esiste solo per la durata della chiamata di funzione a f(). Poiché esso è un argomento di funzione , il valore di a è inizializzato dagli argomenti che sono passati quando la funzione è chiamata, in main() l’argomento è x, il quale ha un valore di 47, quindi questo valore è copiato in a quando f() è chiamata.
Quando si esegue questo programma si vedrà:
x = 47
a = 47
a = 5
x = 47
Inizialmente, naturalmente, x vale 47. Quando f() viene chiamata, viene creato spazio temporaneo per tenere la variabile a per la durata della chiamata alla funzione ed a è inizializzata copiando il valore di x, che si verifica stampandola. Naturalmente, si può cambiare il valore di a e mostrare che questa è cambiata. Ma quando f() è termina, lo spazio temporaneo che è stato creato per a scompare e vediamo che l’unica connessione esistita tra a e x è avvienuta quando il valore di x è stato copiato in a.
Quando si è dentro f(), x è un outside object ( oggetto esterno, una mia termonologia ), e cambiare la variabile locale non influena l’oggetto esterno, naturalmente, poiché essi sono due separate locazioni di memoria. Ma cosa accade se si vuole modificare l’oggetto esterno? Ecco dove i puntatori tornano utili. In un certo senso, un puntatore è uno pseudonimo per un’altra variabile. Quindi se noi passiamo un puntatore in una funzione invece di un ordinario valore, stiamo realmente passando uno pseudonimo dell’oggetto esterno, permettendo alla funzione di modificare l’oggetto esterno:
//: C03:PassAddress.cpp
#include <iostream>
using namespace std;
void f(int* p) {
cout << "p = " << p << endl;
cout << "*p = " << *p << endl;
*p = 5;
cout << "p = " << p << endl;
}
int main() {
int x = 47;
cout << "x = " << x << endl;
cout << "&x = " << &x << endl;
f(&x);
cout << "x = " << x << endl;
} ///:~
ora f() prende un puntatore come argomento e lo dereferenzia durante l’assegnamento e ciò causa la modifica dell’oggetto esterno x.Il risultato è:
x = 47
&x = 0065FE00
p = 0065FE00
*p = 47
p = 0065FE00
x = 5
si noti che il valore contenuto in p è lo stesso dell’indirizzo di x – il puntatore p punta veramente a x. Se ciò non è convincente abbastanza, quando p è dereferenziato per assegnare il valore 5, si vede che il valore di x è ora pure cambiato a 5.
Quindi, passare un puntatore in una funzione permetterà alla funzione di modificare l’oggeto esterno. In seguito si farà un grande uso dei puntatori, ma questo è l'uso più comune e semplice.
Introduzione ai riferimenti in C++
Approssimativamente i puntatori funzionano allo stesso modo in C e in C++, ma il C++ ha un modo aggiuntivo per passare un indirizzo in una funzione. Ciò è il passaggio per riferimento ed esiste in vari altri linguaggi di programmazione quindi non è una invenzione del C++.
Come prima impressione sui riferimenti si può pensare che non sono necessari e che si potrebbe scrivere tutti i propri programmi senza i riferimenti. In generale, ciò è vero, con l’eccezione di pochi importanti posti in cui ne apprenderemo in seguito nel libro. Si apprenderà anche di più sui riferimenti in seguito, ma l’idea base è la stessa della dimostrazione dell’uso dei puntatori sopracitato: si può passare l’indirizzo un argomento usando un riferimento. La differenza tra i puntatori e i riferimenti è che chiamare una funzione che accetta i riferimenti è più pulito, sintatticamente, che chiamare una funzione che accetta puntator i( ed è questa differenza sintattica che rende essenziali i riferimenti in certe situazioni). Se PassAddress.cpp è modificato per usare i riferimenti, si può vedere la differenza nella chiamata alla funzione nel main( ):
//: C03:PassReference.cpp
#include <iostream>
using namespace std;
void f(int& r) {
cout << "r = " << r << endl;
cout << "&r = " << &r << endl;
r = 5;
cout << "r = " << r << endl;
}
int main() {
int x = 47;
cout << "x = " << x << endl;
cout << "&x = " << &x << endl;
f(x); // Assomiglia ad un passaggio di valore
// è in realtà passata per riferimento
cout << "x = " << x << endl;
} ///:~
nella lista di argomenti lista di f(), invece di dire int* per passare un puntatore, si dice int& per passare un riferimento. Dentro f(), se di dice solamente ‘r’( il quale produrrebbe l’indirizzo se r fosse un puntatore) si ottiene il valore della variabile a cui r si riferisce. Se si assegna r, si assegna realmente la variabile a cui r si riferisce. Infatti, l’unico modo per ottenere l’indirizzo che è stato tenuto in r è con l’operatore ‘&’.
In main( ), si può vedere l’effetto chiave del riferimento nella sintassi della chiamata a f(), la quale è proprio f(x). Anche se assomiglia ad un ordinario passaggio di valore, l’effetto del riferimento è che realmente prende l’indirizzo e lo passa dentro, piuttosto che farne una copia del valore. L’output è :
x = 47
&x = 0065FE00
r = 47
&r = 0065FE00
r = 5
x = 5
Quindi si può vedere che il passaggio per riferimento permette ad una funzione di modificare l’oggetto esterno, proprio come si fa passando un puntatore ( si può anche osservare che il riferimento oscura il fatto che un indirizzo sta per essere passato; ciò sarà esaminato in seguito nel libro ). Dunque, per questa semplice introduzione si può ritenere che i riferimenti sono soltanto un modo differente sintatticamente ( qualche volta detto “ zucchero sintattico “) per ottenere la stessa cosa che il puntatore fa: permette alle funzioni di cambiare gli oggetti esterni.
I puntatori ed i riferimenti come modificatori
Si
è visto che i tipi data base char, int, float, e double,
insieme agli specificatori signed,
unsigned, short, e long, i quali possono essere usati
con i tipi data base quasi in ogni combinazione. Ora si sono aggiunti i puntatori
ed i riferimenti che sono ortogonali ai tipi data base e agli specificatori,
quindi le possibili combinazioni si sono triplicate:
//: C03:AllDefinitions.cpp // Tutte le possibili combinazioni
dei tipi di data base, //specificatori, puntatori
e riferimenti #include <iostream> using namespace
std; void f1(char
c, int i, float f, double d); void f2(short
int si, long int li, long double ld); void f3(unsigned
char uc, unsigned int ui, unsigned short int usi, unsigned long int uli); void f4(char*
cp, int* ip, float* fp,
double* dp); void f5(short
int* sip, long int* lip, long double* ldp); void f6(unsigned
char* ucp, unsigned
int* uip, unsigned short int* usip, unsigned long int* ulip); void f7(char&
cr, int& ir, float&
fr, double& dr); void f8(short
int& sir, long int& lir, long double& ldr); void f9(unsigned
char& ucr, unsigned
int& uir, unsigned short int& usir, unsigned long int& ulir); int main() {} ///:~
I puntatori e i riferimenti funzionano anche quando si passano oggetti dentro
e fuori le funzioni; si apprenderà ciò in seguito nell’
ultimo capitolo.
C’è solo un altro tipo che funziona con i puntatori: void. Se si dichiara che il puntatore è un void*, significa che qualsiasi tipo di indirizzo può essere assegnato al puntatore ( laddove se si ha un int*, si può assegnare solo l’indirizzo di una variabile int a quel puntatore). Ad esempio:
//: C03:VoidPointer.cpp
int main() {
void* vp;
char c;
int i;
float f;
double d;
//L’indirizzo di QUALSIASI tipo può essere
//assegnato ad un puntatore void
vp = &c;
vp = &i;
vp = &f;
vp = &d;
} ///:~
ogni volta che si assegna ad un void*, si perde ogni informazione sul tipo. Ciò significa che prima che si usa il puntatore, lo si può castare nel tipo corretto:
//: C03:CastFromVoidPointer.cpp
int main() {
int i = 99;
void* vp = &i;
// Non si può dereferenziare un puntatore void:
// *vp = 3; // errore a Compile-time
// Si deve fare un cast ad un int prima di dereferenziare:
*((int*)vp) = 3;
} ///:~
il tipo (int*)vp prende il void*e dice al compilatore di trattarlo come un int*, così può essere dereferenziato con successo. Si può osservare che questa sintassi è brutta, ed è, ma il peggio è che il void* introduce un buco nel sistema del tipo di linguaggio. Cioè, permette, o anche incoraggia, il trattamento di un tipo come un altro tipo. Nell’esempio di sopra, si è trattato un int come un int assegnando vp ad un int*, ma non c’è nulla che dice che noi non possiamo assegnarlo ad un char* o double*,che modificherebbe un differente ammontare di memoria che è stata allocata per l’int, possibilmente interrompendo un programma. In generale, i puntatori void dovrebbero essere evitati, e usati solo in rari e speciali casi, i quali saremo pronti a considere significativamente in seguito nel libro.
Non si può avere un riferimento void, per ragioni che saranno spiegate nel capitolo 11.
Scoping ( Visibilità )
Le regole di scope ci dicono dove una variabile è valida, dove viene creata e dove viene distrutta( es. va fuori dallo scope). Lo scope della variabile si estende dal punto dove è definita fino alla prima parentesi chiusa che confronta la più vicina parentesi aperta prima che la variabile fu definita. Cioè, uno scope è definito dalla propria serie di parentesi più “vicine”. Per illustrarle:
//: C03:Scope.cpp
// quante variabili sono esaminate
// How variables are scoped
int main() {
int scp1;
// scp1 visibile qui
{
// scp1 visibile qui ancora
//.....
int scp2;
// scp2 visibile qui
//.....
{
// scp1 & scp2 visibili ancora qui
//..
int scp3;
//scp1 ,scp2 & scp3 visibili qui
// ...
}
// <-- scp3 distrutto qui
// scp3 non disponibile qui
// scp1 & scp2 visibili ancora qui
// ...
} // <-- scp2 distrutto qui
// spc2 & scp3 non disponibili qui
// ..
} // <-- scp1 distrutto qui
///:~
l’esempio di sopra mostra quando le variabili sono visibili e quando non sono disponibili (ciòè, quando vanno fuori dallo scope). Una variabile può essere usata solo quando è dentro il proprio scope. Gli scope possono essere nidificati. Nidificare significa che si può accedere ad una variabile in uno scope che racchiude lo scope in cui si è. Nell’esempio di sopra, la variabile scp1 è disponibile dentro tutti gli altri scope, mentre scp3 è disponibile solo nello scope più interno.
Definire le variabili al volo
Come visto poco fa in questo capitolo, c’è una significativa differenza tra C e C++ quando si definiscono le variabili. Entrambi i linguaggi richiedono che le variabili siano definite prima di essere usate, ma il C ( e molti altri linguaggi tradizionali procedurali) obbligano a definire tutte le variabili all’inizio dello scope, cosicchè quando il compilatore crea un blocco può allocare spazio per queste variabili.
Mentre si legge il codice C, un blocco di definizioni di variabili è di solito la prima cosa da vedere quando si entra in uno scope. Dichiarare tutte le variabili all’inizio del blocco richiede che il programmatore scriva in un particolare modo a causa del compimento di dettagli del linguaggio. La maggior parte della gente non sa che tutte le variabili sono state usate prima che loro scrivono il codice, quindi devono cominciare a saltare indietro all’inizio del blocco per inserire nuove variabili, che è scomodo e causa errori. Queste definizioni di variabili di solito non significano molto per il lettore e realmente tendono a confondere perché appaiono fuori dal contesto nelle quali sono usati.
C++ (non il C) ci permette di definire variabili da qualsiasi pari in uno scope, quindi si può definire una variabile giusto prima di usarla.In più, si può inizializzarla una variabile al punto in cui la si definisce, che impedisce di compiere varie classi di errori. Definire variabili in questo modo rende il codice più facile da scrivere e riduce gli errori che si ottengono dal dover saltare avanti e dietro in uno scope. Rende il codice più facile da capire perché si vede una variabile definita nel contesto del proprio uso. Questo è molto importante quando si sta definendo e inizializzando una variabile allo stesso tempo – si può vedere il significato dell’inizializzazione del valore dal modo in cui la variabile è usata.
Si possono anche definire variabili dentro le espressioni di controllo di cicli for e while, dentro la condizione di una istruzione if, e dentro l' istruzione di selezione di uno switch. Ecco qui un esempio mostrante le definizioni di variabili al volo:
//: C03:OnTheFly.cpp
// definizione di variabili al volo
#include <iostream>
using namespace std;
int main() {
//..
{
// Inizia un nuovo scope
int q = 0; // il C richiede una definizione qui
//..
// definizione nel punto di uso:
for ( int i = 0; i < 100; i++) {
q++; // q proviene da un grande scope
// definizione alla fine dello scope:
int p = 12;
}
int p = 1; // un p differente
} // fine scope contenente q & p
cout << "Digita caratteri:" << endl;
while ( char c = cin.get() != 'q') {
cout << c << " non era lui!" << endl;
if ( char x = c == 'a' || c == 'b')
cout << "hai digitato a oppure b" << endl;
else
cout << "hai digitato" << x << endl;
}
cout << "Digita A, B, oppure C" << endl;
switch ( int i = cin.get()) {
case 'A': cout << "Snap" << endl; break ;
case 'B': cout << "Crackle" << endl; break ;
case 'C': cout << "Pop" << endl; break ;
default : cout << "Non A, B o C!" << endl;
}
} ///:~
nello scopo più interno, p è definito proprio prima che lo scope finisca, quindi è realmente un gesto inutile ( ma mostra che si puo' definire una variabile da qualche parte). Il p nello scope esterno è nella stessa situazione.
La definizione di i nell' espressione di controllo del ciclo for è un esempio di come si possa definire una variabile esattamente nel punto in cui si ha bisogno ( lo si puo' fare solo col C++). Lo scope di i è lo scope dell' espressione controllata del ciclo for , quindi si puo' girare intorno e riusare i per il prossimo ciclo for . Questo idioma è semplice e comunemente usato in C++; i è il classico nome per un contatore di ciclo e non si devono inventarne di nuovi.
Benchè l' esempio mostra anche variabili definite senza le dichiarazioni while , if, e switch, questo tipo di definizione è abbastanza meno comune di queste nelle espressioni for , forse perchè la sintassi è molto vincolata. Ad esempio, non si possono usare parentesi. Cioè, non si puo' dire:
while (( char c = cin.get()) != 'q')
L' aggiunta di parentesi extra sembrerebbe una cosa innocente e utile da fare, e poichè non si puo' usarli, il risultato non è quello che potrebbe piacere. Il problema si presenta perchè' !=' ha una precedenza maggiore rispetto a' =' , quindi char c finisce contenendo un bool convertito in char . Quando è stampato, su molti terminali si vedrà una faccia sorridente come carattere.
In generale, si puo' considerare l' abilità del definire variabili senza le dichiarazioni while , if, e switch per completezza, ma l' unico posto dove si userà questo tipo di definizione di variabile è dentro un ciclo for ( dove lo si userà abbastanza spesso).
Quando si crea una variabile, si hanno diversi modi per specificare il tempo di vita di una variabile, come la memoria viene allocata per quella variabile e come la variabile è trattata dal compilatore.
Le variabili globali sono definite fuori da tutti i corpi delle funzioni e sono disponibili in tutte le parti del programma ( anche codificati in altri file). Le variabili globali sono semplici da esaminare è sempre disponibili ( es. il tempo di vita di una variabile globale finisce quando il programma termina). Se l' esistenza della variabile globale in un file è dichiarata usando la parola chiave extern in un altro file, il dato è disponibile per l' uso dal secondo file. Ecco qui un esempio di uso di variabili globali:
//: C03:Global.cpp
//{L} Global2
// dimostrazioni delle variabili globali
#include <iostream>
using namespace std;
int globale;
void func();
int main() {
globale = 12;
cout << globale << endl;
func(); // Modifica globale
cout << globale << endl;
} ///:~
ecco qui un file che accede a globale come un extern :
//: C03:Global2.cpp {O}
// accesso alle variabili globali esterne
extern int globale;
// ( il linker risolve il riferimento )
void func() {
globale = 47;
} ///:~
la memoria per la variabile globale è creata dalla definizione di Global.cpp e quella stessa variabile è acceduta dal codice in Global2.cpp. Poichè il codice in Global2.cpp è compilato separatamente dal codice nel Global.cpp, il compilatore deve essere informato che la variabile esiste altrove dalla dichiarazione.
extern int globale;
quando si esegue il programma, si vedrà che la chiamata a func() veramente influisce sulla singola globale istanza di globale .
In Global.cpp si puo' vedere uno speciale commento ( i quali sono un mio progetto):
//{L} Global2
cio' significa che per creare il programma finale, il file oggetto col nome Global2 deve essere linkato ( non c' è nessuna estenzione perchè i nomi di estenzioni dei file oggetto differiscono da un sistema all' altro). In Global2.cpp la prima riga ha un altro speciale commento in una serie di caratteri {O}, il quale dice: " non provare a creare un eseguibile da questo file, esso va compilato in modo che possa essere linkato a qualche altro eseguibile". Il programma ExtractCode.cpp nel Volume 2 di questo libro ( scaricabile dal sito www.BruceEckel.com ) legge questa serie di caratteri e crea l'appropriato makefile e ogni cosa è compilata correttamente ( si apprenderanno i makefile alla fine di questo capitolo).
Le variabili locali vivono dentro uno scope, sono " locali " alla funzione. Esse spesso sono chiamate variabili automatiche perchè vengono automaticamente create quando si entra nello scope e automaticamente muiono quando lo scope finisce. La parola chiave auto la rende esplicita, ma le variabili locali per default sono' auto e quindi non è mai necessario dichiarare qualcosa come un auto .
Una variabile register è un tipo di variabile locale. La parola chiave register dice al compilatore: " rendi l'accesso a questa variabile il più veloce possibile " . Incrementare la velocità di accesso è un compito che dipende dall'implementazione, ma come, il nome suggerisce, è spesso fatto piazzando la variabile in un registro. Non c' è garanzia che la variabile sarà posta in un registro o anche che la velocità di accesso sarà incrementata. Esso è un suggerimento al compilatore.
Ci sono delle restrizioni per l' uso delle variabili register . Non si puo' prendere o calcolare l' indirizzo della variabile register . Una variabile register puo' essere dichiarata solo dentro un blocco ( non si hanno variabili globali o static register ). Si puo', comunque, usare un register come un argomento formale in una funzione ( es. nella lista degli argomenti ).
In generale, le ottimizzazioni del compilatore saranno migliori delle nostre, quindi è meglio evitare la parola chiave register .
La parola chiave static ha diversi significati distinti. Normalmente, le variabili definite locali nella funzione scompaiono alla fine dello scope della funzione. Quando si chiama una funzione di novo, la memoria per le variabili è creata di nuovo e i valori sono rinizializzati. Se si vuole che un valore sia esistente in tutta la vita del programma, si puo' definire un variabile locale della funzione per essere static e darle un valore iniziale. L' inizializzazione è prodotta solo la prima volta che la funzione è chiamata ed il dato conserva il proprio valore tra le chiamate alla funzione. In questo modo, una funzione puo' " ricordarsi " alcuni pezzi di informazioni tra le chiamate alla funzione.
Si puo' restare forse meravigliati del perchè una variabile globale invece non è usata. La bellezza di una variabile static è che non è disponibile fuori lo scope della funzione, quindi non puo' essere inavvertitamente cambiata. Essa localizza l' errore.
Ecco qui un esempio di uso di variabili static :
//: C03:Static.cpp
// usare una variabile statica in una funzione
#include <iostream>
using namespace std;
void func() {
static int i = 0;
cout << "i = " << ++i << endl;
}
int main() {
for ( int x = 0; x < 10; x++)
func();
} ///:~
ogni volta che func() vienechiamata nel ciclo for , esso stampa un valore differente. Se la parola chiave static non è usata, il valore stampato sarà sempre ' 1' .
Il secondo significato di static è riferito al primo nel senso " non disponibile fuori un certo scope " . Quando static è applicato ad un nome di funzione o ad una variabile che è fuori da tutte le funzioni, significa " questo nome non è disponibile fuori da questo file. " Il nome di funzione o la variabile è locale al file, si dice che ha un file scope. Come dimostrazione, compilare e collegare i seguenti due file causerà un errore di collegamento:
//: C03:FileStatic.cpp
// dimostrazione dello scope del file. Compilando e
// linkando questo file con FileStatic2.cpp
// si avrà un errore dal linker
// Scope di file significa che è solo disponibile in questo file:
static int fs;
int main() {
fs = 1;
} ///:~
anche se la variabile fs esiste come un extern nel seguente file, il linker non la troverà perchè è stata dichiarata static in FileStatic.cpp .
//: C03:FileStatic2.cpp {O}
provando a riferisi fs
// Trying to reference fs
extern int fs;
void func() {
fs = 100;
} ///:~
un specificatore static puo' essere usato anche dentro una class . Questa spiegazione sarà rimandata fino a quando non si imparerà a creare classi, più avanti nel libro.
la parola chiave extern è già stata descritta e dimostrata in breve. Essa dice al compilatore che una variabile o una funzione esiste, anche se il compilatore non l' ha ancora vista nel file corrente che sta per essere compilato. Questa variabile o funzione puo' essere definita in un altro file o più avanti nel file corrente. Come esempio dell' ultimo:
//: C03:Forward.cpp
// Forward function & dichiarazione di dati
// Forward function & data declarations
#include <iostream>
using namespace std;
// questo non è realmente esterno, ma bisogna dire al compilatore
//che esiste da qualche parte:
extern int i;
extern void func();
int main() {
i = 0;
func();
}
int i; // definizione di dato
void func() {
i++;
cout << i;
} ///:~
quando il compilatore incontra la dichiarazione ' extern int i' , sa che la definizione per i deve esistere da qualche parte come una variabile locale. Quando il compilatore raggiunge la definizione di i , nessun altra definizione è visibile, quindi esso sa che ha trovato la stessa i dichiarata poco fa nel file. Se si avesse definito i come static , si sarebbe detto al compilatore che la i è definita globalmente ( passando per extern ), ma che ha anche un file scope( passando per static ), quindi il compilatore genererà un errore.
Per capire il comportamento dei programmi in C e C++, si ha bisogno di conoscere il linkage (il concatenamento ). In un programma eseguibile, un identificatore è rappresentato dallo spazio in memoria che detiene una variabile o un corpo di funzione compilato. Il collegamento descrive questa memoria come se fosse vista dal linker. Ci sono due tipi di collegamento: internal linkage e external linkage.
Concatenamento interno ( internal linkage ) significa che la memoria è creata per rappresentare l' identificatore solo per il file che sta per essere compilato, gli altri file possono usare gli stessi nomi di identificatori con il concatenamento interno, o per la variabile globale, e nessun controversia sarà trovata dal linker, viene creata memoria per ogni identificatore. Il concatenamento interno è specificato dalla parola chiave static in C e C++.
concatenamento esterno ( external linkage) significa che un singolo pezzo di memoria è creata per rappresentare l' identificatore per tutti i file che stanno per essere compilati. La memoria è creata una volta ed il linker deve risolvere tutti gli altri riferimenti a quella memoria. Le variabili globali ed i nomi di funzione hanno un collegamento esterno. Queste sono accessibili dagli altri file dichiarandole con la parola chiave extern . Le variabili definite fuori da tutte le funzioni ( con l' eccezione di const in C++) e le definizioni di funzione sono per default concatenamento esterno. Si puo' specificamente obbligarli ad avere un concatenamento interno usando la parola chiave static . Si puo' esplicitamente dichiarare che un identificatore ha un concatenamento esterno definendolo con la parola chiave extern. Definire una variabile o una funzione con extern non è necessario in C, ma lo è qualche volta per const in C++.
Le variabili automatiche (locali) esistono solo temporaneamente, nello stack, mentre una funzione sta per essere chiamata. Il linker non conosce le variabili automatiche e quindi queste non hanno linkage .
Nel vecchio (pre-Standard) C, se si voleva una costante, si doveva usare il preprocessore:
#define PI 3.14159
dovunque si usava PI , il valore 3.14159 veniva sostituito dal preprocessore( si puo' ancora usare questo metodo in C ed in C++).
quando si usa il preprocessore per creare costanti, si piazza il controllo di queste costanti, fuori dalla portata del compilatore. Nessun controllo del tipo è prodotto sul nome di PI e non si puo' prendere l' indirizzo di PI (così non si puo' passare il puntatore o un riferimento a PI ). PI non puo' essere una variabile di un tipo definito dall' utente. Il significato di PI dura dal punto in cui è definito fino alla fine del file; il preprocessore non riconosce lo scoping.
Il C++ introduce il concetto di costanti con nome che sono proprio come le variabili, eccetto che il loro valore non puo' essere cambiato. Il modificatore const dice al compilatore che il nome rappresenta una costante. Ogni tipo di dato, predefinito o definito dall' utente, puo' essere definito come const . Se si definisce qualcosa come const e dopo si cerca di modificarlo, il compilatore genererà un errore.
Si deve specificare il tipo di const :
const int x = 10;
nello standard C e C++, si puo' usare una costante con nome in un lista di argomenti, anche se l' argomento è un puntatore o un riferimento (es. si puo' prendere l' indirizzo di un const ) un const ha uno scope, proprio come una variabile locale, quindi si puo' " nascondere " unconst dentro una funzione ed essere sicuri che il nome non influenzerà il resto del programma.
Il const fu preso dal C++ e incorporato nello standard C, sebbene abbastanza diffentemente. In C, il compilatore tratta un const proprio come una variabile che ha una speciale etichetta che dice " non cambiarmi " . Quando si definisce una const in C, il compilatore crea memoria per essa, così se si definisce più di una const con lo stesso nome in due differenti file ( o si pone la definizione in un file principale ), il linker genererà un messaggio di errore circa la controversia. L' uso inteso del const in C è abbastanza differente di quello inC++ ( in breve, è migliore in C++) .
In C++, una const deve sempre avere un valore di inizializzazione ( in C ciò non è vero). I valori costanti per i tipi predefiniti come decimali, ottali, esadecimali, o numeri in virgola mobile (tristemente, i numeri binari non sono considerati importanti), o come caratteri.
In assenza di ogni altro indizio, il compilatore assume un valore costante come numero decimale. Il numero 47, 0, e 1101 sono tutti trattati come numeri decimali.
Un valore costante con un primo numero 0 è trattato come un numero ottale (base 8). I numeri a base 8 possono contenere solo digitazioni 0-7; il compilatore richiama le digitazioni oltre il 7 come errore. Un leggittimo numero ottale è 017 (15 in base 10).
Un valore costante con un primo numero 0x è trattato come un numero esadecimale (base 16). I numeri in base 16 contengono digitazioni 0-9 e a-f o A-F. Un legittimo numero esadecimale è 0x1fe (510 in base 10).
I numeri in virgola mobile possono contenere punti decimali ed potenze esponenziali (rappresentata da e, che significa " 10 alla potenza di " ): il punto decimale e la e sono opzionali. Se si assegna una costante da una variabile in virgola mobile, il compilatore prenderà il valore costante e lo convertirà in un numero in virgola mobile (questo processo è una forma di ciò che è chiamato conversione di tipo implicito). Comunque è una buona idea usare il punto deciamale o una e per ricordare al lettore che si sta usando un numero in virgola mobile; anche alcuni vecchi compilatori hanno bisogno del suggerimento.
Leggittimi valori costanti in virgola mobile sono: 1e4, 1.0001, 47.0, 0.0, e -1.159e-77. Si puo' aggiungere il suffisso per forzare il tipo di numero in virgola mobile. f o F forza un float , L o l forza un long double ; altrimenti il numero sarà un double .
Le costanti carattere sono caratteri racchiuse tra singole apicette come: ' A' ,' 0' ,'' . Si noti che c' è una grossa differenza tra il carattere' 0' (ASCII 96) e il valore 0. i caratteri speciali sono rappresentati con il " backslash escape " :' \n' ( nuova riga),' \t' (tab),' \\' (backslash),' \r' (carriage return),' \"' (doppia apicetta),' \'' (singola apicetta), etc. si puo' snche esprimere le costanti char in ottale:' \17' o in esadecimale:' \xff' .
laddove il qualificatore const dice al compilatore che " questo non cambia mai " ( il quale permette al compilatore di produrre una ottimizzazione extra ), il qualificatore volatile dice al compilatore " non si sa mai quando questo cambierà " , e previene il compilatore dal produrre qualsiasi ottimizzazione basata sulla stabilità di quella variabile. Si usi questa parola chiave quando si leggono valori fuori dal controllo del proprio codice, come un registro in un pezzo di hardware per comunicazioni. Una variabile volatile è sempre letta ogni volta che è richiesta, anche se è già stata letta la riga prima.
Un caso speciale di un pò di memoria che è " fuori dal controllo del nostro codice " è un programma multithreaded. Se si sta osservando un particolare flag che è modificato da un' altra thread o processo, quel flag dovrebbe essere volatile così il compilatore non crea la assunzione che puo' ottimizzare letture multiple del flag.
Si noti che volatile puo' non avere effetto quando un compilatore non è ottimizzato, ma puo' prevenire bug critici quando si inizia ad ottimizzare il codice(che accade quando il compilatore comincerà a guardare le letture ridondanti).
Le parole chiave const e volatile saranno illustrate nel capitolo in seguito.
Questa sezione illutstra tutti gli operatori del C e del C++.
Tutti gli operatori producono un valore dai loro operandi. Questo valore è prodotto senza modificare gli operandi, eccetto con gli operatori assegnamento, incremento e decremento. Modificare un operando è chiamato un side effect(effetto secondario). Il più comune uso per gli operatori che modificano i loro operandi è generare il side effect, ma si dovrebbe tenere a mente che il valore prodotto è disponibile solo per il nostro uso proprio come nell' operatore senza gli effetti secondari .
L' assegnamento è prodotto con l' operatore =. Esso significa " prendi la parte di destra ( spesso chiamata rvalue ) e copiala dentro la parte di sinistra (spesso chiamato lvalue ). " Un rvalue è una qualsiasi costante, variabile o una espressione che produce un valore, maun lvalue deve essere una distinta, variabile chiamata( cioè, deve essere uno spazio fisico nella quale salvare il dato). Ad esempio, si puo' assegnare un valore costante alla variabile ( A = 4; ), ma non si puo' assegnare qualcosa al valore costante- non puo' essere un lvalue ( non si puo' dire 4 = A; ).
Gli operatori basici matematici sono gli stessi di quelli disponibili nella maggior parte dei linguaggi di programmazione: addizione ( + ), sottrazione ( - ), divisione ( / ), moltiplicazione ( * ), e modulo ( % ; ciò produce il resto delle divisioni con interi). Le divisioni con interi troncano il risultato ( non arrotonda ). L' operatore modulo non puo' essere usato con un numero a virgola mobile.
Il C ed il C++ usano anche una notazione stenografica per produrre una operazione ed un assegnamento allo stesso tempo. Questo è denotato da un operatore seguito da un segno uguale, ed è consistente con tutti gli operatori nel linguaggio ( ogni volta che ha senso). Ad esempio, aggiungere 4 alla variabile x e assegnare x al risultato, si dice: x += 4; .
Questo esempio mostra l' uso degli operatori matematici:
//: C03:Mathops.cpp
// operatori matematici
#include <iostream>
using namespace std;
// una macro per visualizzare una stringa ed un valore.
#define PRINT(STR, VAR) \
cout << STR " = " << VAR << endl
int main() {
int i, j, k;
float u, v, w; // si applica anche ai double
cout << "inserisci un intero: " ;
cin >> j;
cout << "inserisci un altro intero: " ;
cin >> k;
PRINT( "j" ,j); PRINT( "k" ,k);
i = j + k; PRINT( "j + k" ,i);
i = j - k; PRINT( "j - k" ,i);
i = k / j; PRINT( "k / j" ,i);
i = k * j; PRINT( "k * j" ,i);
i = k % j; PRINT( "k % j" ,i);
// il seguente funziona solo con gli interi:
j %= k; PRINT( "j %= k" , j);
cout << "Inserisci un numero a virgola mobile: " ;
cin >> v;
cout << "Inserisci un altro numero a virgola mobile:" ;
cin >> w;
PRINT( "v" ,v); PRINT( "w" ,w);
u = v + w; PRINT( "v + w" , u);
u = v - w; PRINT( "v - w" , u);
u = v * w; PRINT( "v * w" , u);
u = v / w; PRINT( "v / w" , u);
// il seguente funziona con int,char
// e anche i double:
PRINT( "u" , u); PRINT( "v" , v);
u += v; PRINT( "u += v" , u);
u -= v; PRINT( "u -= v" , u);
u *= v; PRINT( "u *= v" , u);
u /= v; PRINT( "u /= v" , u);
} ///:~
gli rvalue di tutti gli assegnamenti possono, naturalmente, essere ben più complessi.
Si noti che l' uso della macro PRINT( ) per salvare il testo ( ed i suoi errori!). Le macro del preprocessore sono tradizionalmente chiamati con tutte lettere maiuscole quindi risaltano, si apprenderà in seguito chele macro possono velocemente diventare pericolose ( e possono essere anche molto utili).
Gli argomenti nella lista nella parentesi successiva al nome della macro sono sostituiti in tutto il codice che segue la parentesi chiusa. Il preprocessore rimuove il nome PRINT e sostituisce il codice dovunque la macro è chiamata, quindi il compilatore non puo' generare nessun messaggio di errore usando il nome della macro, e non esegue alcun tipo di controllo del tipo sugli argomenti ( l' ultimo puo' essere giovevole, come mostrato nei comandi di debug alla fine del capitolo).
Gli operatori relazionali stabilicono una relazione tra il valori degli operandi. Essi producono un booleano ( specificato con la parola chiave bool in C++) true se la relazione è vera e false se la relazione è falsa. Gli operatori relazionali sono: più piccolo di (<), più grande di (>), più piccolo o uguale di (<=), più grande o uguale di (>=), equivalente (= =), e diverso da (!=). Essi possono essere usati con tutti i tipi di dato predefiniti in C e C++. In C++ possono dare ad essi speciali definizioni per i tipi data definiti dagli utenti( li si apprenderà nel capitolo12, il quale riguarda l' operatore sovraccaricato).
Gli operatori logici and ( && ) e or ( || ) producono un true o false basato sulla relazione logica dei suoi argomenti. Si ricordi che in C e in C++, una dichiarazione è vera se essa ha il valore non-zero, è falsa se ha un valore di zero. Se si stampa un bool , tipicamente si vedrà un ' 1' per true e ' 0' per false .
Questo esempio usa gli operatori relazionali e logici:
//: C03:Boolean.cpp
// Operatori relazionali e logici.
#include <iostream>
using namespace \std;
int main() {
int i,j;
cout << "Inserisci un intero: " ;
cin >> i;
cout << "Inserisci un altro intero: " ;
cin >> j;
cout << "i > j is " << (i > j) << endl;
cout << "i < j is " << (i < j) << endl;
cout << "i >= j is " << (i >= j) << endl;
cout << "i <= j is " << (i <= j) << endl;
cout << "i == j is " << (i == j) << endl;
cout << "i != j is " << (i != j) << endl;
cout << "i && j is " << (i && j) << endl;
cout << "i || j is " << (i || j) << endl;
cout << " (i < 10) && (j < 10) is "
<< ((i < 10) && (j < 10)) << endl;
} ///:~
si puo' rimpiazzare la definizione per int con float o double nel programma sopracitato. Bisogna stare attenti, comunque, che il confronto di un numero a virgola mobile con il valore zero è preciso; un numero che è di frazione differente da un altro numero è ancora " non-uguale " . Un numero a virgola mobile che il più piccolo bit sopra zero è ancora vero.
Gli operatori su bit permettono di manipolare bit individuali in un numero ( poichè i valori a virgola mobile usano un formato interno, gli operatori bit a bit funzionano solo con tipi integrali : char , int e long ). Gli operatori su bit effettuano l' algebra Booleana nei corrispondenti bit negli argomenti per produrre il risultato.
L' operatore bit a bit and (&) produce un uno nel bit dell' output se entrambi i bit dell' input sono alzati; altrimenti esso produce zero. Il bitwise or oppure l' operatore ( | ) produce un uno nel bit dell' output se uno dei due bit in input è uno e produce uno zero se entrambi i bit in input sono zero. Il bitwise exclusive or, o xor ( ^ ) produce di primo ordine nel bit dell' output se il primo o l' altro bit del input, ma non entrambi, è alzato. Il bitwise not ( ~, chiamato anche l'operatore complemento ad uno) è un operatore unario, prende solo un argomento ( tutti gli altri operatori bit a bit sono operatori binari ). I bitwise non producono l' opposto del bit di input, un uno se il bit del input è zero, uno zero se il bit del input è uno.
Gli operatori su bit possono essere combinati col segno = per unire l' operazione e l' assegnamento: &= , |=, e ^= sono tutte operazioni leggittimw ( poichè ~ è un operatore unario che non puo' essere combinato col segno = ).
Anche gli operatori shift manipolano bit. L' operatore left-shift (<<) shifta a sinistra l' operando a sinistra dell' operatore del numero di bit specificati dopo l' operatore. L' operatore right-shift ( >> ) shifta l' operando a sinistra dell' operatore a destra del numero di bit specificati dopo l' operatore. Se il valore dopo l' operatore shift è più grande del numero di bit nell' operatore di sinistra, il risultato è indefinito. Se l' operatore di sinistra è senza segno, il right shift è un logico shift quindi il bit alto sarà riempito con zero. Se l' operatore di sinistra ha segno, il right shift puo' o non essere un logico shift ( cioè, il comportamento è indefinito).
Shift puo' essere combinato con un segno uguale ( <<= e >>= ). Il lvalue è rimpiazzato dal lvalue shiftato di rvalue.
Quello che segue è un esempio che dimostra l' uso di tutti gli operatori riguardanti i bit. Per primo, ecco una funzione di scopo generale che stampa un byte in formato binario, creato separatamente così che puo' essere facilmente riusato. L'header file dichiara la funzione:
//: C03:printBinary.h
// visualizzare un byte in binario
void printBinary( const unsigned char val);
///:~
ecco qui un'implementazione della funzione:
//: C03:printBinary.cpp {O}
#include <iostream>
void printBinary( const unsigned char val) {
for ( int i = 7; i >= 0; i--)
if (val & (1 << i))
std::cout << "1" ;
else
std::cout << "0" ;
} ///:~
la funzione printBinary( ) prende un singolo byte e lo visualizza bit per bit. L' espressione
(1 << i)
produce un uno in ogni successiva posizione del bit; in binario: 00000001, 00000010, etc. se questo bit è un bitwise and con val e il valore non è 0, significa che c' era uno in quella posizione in val .
Finalmente, la funzione usata nell' esempio che mostra gli operatori che manipola il bit:
//: C03:Bitwise.cpp
//{L} printBinary
// dimostrazione della manipolazione dei bit
#include "printBinary.h"
#include <iostream>
using namespace std;
// una macro per scrivere meno:
#define PR(STR, EXPR) \
cout << STR; printBinary(EXPR); cout << endl;
int main() {
unsigned int getval;
unsigned char a, b;
cout << "digita un numero compreso tra 0 e 255: " ;
cin >> getval; a = getval;
PR( "a in binario: " , a);
cout << "digita un numero compreso tra 0 e 255: " ;
cin >> getval; b = getval;
PR( "b in binario: " , a);
PR( "a | b = " , a | b);
PR( "a & b = " , a & b);
PR( "a ^ b = " , a ^ b);
PR( "~a = " , ~a);
PR( "~b = " , ~b);
// un interessante pattern di bit :
unsigned char c = 0x5A;
PR( "c in binario: " , c);
a |= c;
PR( "a |= c; a = " , a);
b &= c;
PR( "b &= c; b = " , b);
b ^= a;
PR( "b ^= a; b = " , b);
} ///:~
ancora una volta, una macro del preprocessore è utilizzato per scrivere di meno. Essa stampa la stringa della nostra scelta, poi la rappresentazione binaria di un' espressione, dopo un newline.
In main( ) le variabili sono unsigned . Questo perchè, in generale, non si vuole il segno quando si sta lavorando con i byte. Un int deve essere usato invece di una char per getval perchè la dichiarazione " cin >> " alla prima digitazione sarà trattata altrimenti come carattere. Assegnando getval ad a e b , il valore è convertito in un singolo byte ( troncandolo).
Il << ed il >> esegue lo shift dei bit, ma quando i bit vengono shiftati alla fine del numero, questi bit sono persi ( è comune dire che loro cadono dentro il mitico bit bucket, un posto dove i bit scartati terminano, presumibilmente così possono essere riusati ...). Quando si manipolano i bit si puo' anche produrre la rotazione ( rotation) , che significa che i bit che arrivano alla fine ricompaiono all' altro capo, come se stessero ruotando in giro. Anche se la maggior parte dei processori dei computer forniscono un comando di rotazione a livello di macchina ( lo si vedrà nel linguaggio assembler per quel processore ), non c' è un supporto diretto per la "rotazione" in C o in C++. Presumibilmente i creatori del C si sentirono giusificati nel tralasciare la " ruotazione" ( aspirando, come essi dissero, ad linguaggio minimale ) perchè si puo' costruire il proprio comando di rotazione. Ad esempio, ecco qui funzioni per eseguire rotazioni a destra e a sinistra:
//: C03:Rotation.cpp {O}
// produce rotazioni a destra e a sinistra
unsigned char rol( unsigned char val) {
int highbit;
if (val & 0x80) // 0x80 è il bit alto solo
highbit = 1;
else
highbit = 0;
// left shift( l'ultimo bit diventa 0) :
val <<= 1;
// ruota il bit alto all'ultimo:
val |= highbit;
return val;
}
unsigned char ror( unsigned char val) {
int lowbit;
if (val & 1) // controlla il bit basso
lowbit = 1;
else
lowbit = 0;
val >>= 1; //spostamento a destra di una posizione
// ruota il bit basso all'inizio:
val |= (lowbit << 7);
return val;
} ///:~
si provi ad usare queste funzioni in Bitwise.cpp . Si noti che le definizioni ( o almeno le dichiarazioni) di rol() e ror() devono essere viste dal compilatore in Bitwise.cpp . prima che le funzioni vengano usate.
Le funzioni bit a bit sono estremamente efficienti da usare perchè si traducono direttamente in dichiarazioni del linguaggio assembler. Qualche volta una singola dichiarazione in C o C++ genererà una singola riga del codice assembler.
Non ci sono solo operatori che prendono un singolo argomento. Il suo compagno, il not logico (!), prenderà il valore true e produrrà un valore false . L' unario meno (-) e l' unario più (+) sono gli stessi operatori del binario meno e più; il compilatore capisce a quale uso è diretto dal modo in cui si scrive l' espressione. Per esempio, l'istruzione
x = -a;
ha un significato ovvio. Il compilatore puo' capire:
x = a * -b;
ma il lettore puo' confondersi, quindi è più sicuro dire:
x = a * (-b);
L' unario meno produce il negativo del valore. L' unario più fornisce ad una simmetria con l' unario meno, sebbene non fa realmente niente.
Gli operatori decremento e incremento ( ++ e -- ) sono stati introdotti poco fa in questo capitolo. Queste sono gli unici operatori oltre quelli che riguardanti l' assegnamento che hanno effetti secondari. Questi operatori incrementano o decrementano la variabile di una unità , possono avere differenti significati secondo il tipo di dato, questo è vero specialmente con i puntatori.
Gli ultimi operatori unari sono l' indirizzo di (&), dereferenza ( * and -> ), e gli operatori di cast in C e in C++, e new e delete in C++. L' indirizzo e la dereferenza sono usati con i puntatori, descritti in questo capitolo. Il castingè descritto in seguito in questo capitolo, e new e delete sono introdotti nel capitolo 4.
Il ternario if-else è unusuale perchè ha tre operandi. Esso è veramente un operatore perchè produce un valore, dissimile dall' ordinaria dichiarazione if-else . Consiste in tre espressioni: se la prima espressione ( seguita da un ?) vale il true , l' espressione successiva a ? è valutata ed il risultato diventa il valore prodotto dall' operatore. Se la prima espressione è false , la terza (successiva a : ) è eseguita ed il suo risultato diventa il valore prodotto dall' operatore.
L' operatore condizionale puo' essere usato per i suoi effetti secondari o per il valore che produce. Ecco qui un frammento di codice che li dimostra entrambi:
a = --b ? b : (b = -99);
qui, il condizionale produce un rvalue. a è assegnato al valore di b se il risultato del decremento di b non è zero. Se b diventa zero, a e b sono entrambi assegnati a -99. b è sempre decrementato, ma è assegnato a -99 solo se il decremento fa diventare b 0. Una dichiarazione simile puo' essere usata senza la " a = " solo per i suoi effetti secondari :
--b ? b : (b = -99);
qui la seconda b è superflua, poichè il valore prodotto dall' operatore è inutilizzato. Una espressione è richiesta tra ? e :. In questo caso, l' espressione puo' essere semplicemente una costante che puo' rendere l' esecuzione del codice un po' più veloce.
La virgola non è limitata a separare i nomi delle variabili in definizioni multiple, tipo:
int i, j, k;
naturalmente, esso è usato anche nelle liste degli argomenti di funzione. Comunque esso puo' essere usata per separare espressioni, in questo caso produce solo il valore dell' ultima espressione. Tutto il resto dell' espressione nella lista separata dalla virgola è valutata solo per i loro effetti secondari. Questo esempio incrementa una lista di variabili e usa l' ultimo come rvalue:
//: C03:CommaOperator.cpp
#include <iostream>
using namespace std;
int main() {
int a = 0, b = 1, c = 2, d = 3, e = 4;
a = (b++, c++, d++, e++);
cout << "a = " << a << endl;
// le parentesi sono cruciali qui. Senza di
// loro, la dichiarazione valuterà :
(a = b++), c++, d++, e++;
cout << "a = " << a << endl;
} ///:~
in generale, è meglio evitare l' uso della virgola come nient'altro che un separatore, poichè non si è abituati a verderla come un operatore.
Come illustrato sopra, uno dei tranelli quando si usano gli operatori è provare ad evitare di usare le parentesi quando si è incerti su come una espressione verrà valutata (si consulti il proprio manuale locale C per l' ordine della valutazione dell' espressione ).
Ecco un altro errore estremamente comune che assomiglia a questo:
//: C03:Pitfall.cpp
// errori di operatore
int main() {
int a = 1, b = 1;
while (a = b) {
// ....
}
} ///:~
la dichiarazione a = b varra sempre vero quando b non è zero. La variabile a è assegnata al valore di b ed il valore di b è anche prodotto dall' operatore =. In generale, si usa l' operatore equivalenza = = dentro una dichiarazione condizionale, non nell' assegnamento. Questo confonde molti programmatori ( comunque, alcuni compilatori richiameranno all' attenzione il problema, che è di aiuto).
Un problema affine è usare il bitwise and o or invece delle loro controparti logiche. I bitwise and o or usano uno dei caratteri ( & o| ), mentre il logico and o or ne usano due ( && e || ). Esattamente come con = e = =, è facile scrivere un carattere invece di due. Un utile stratagemma mnemonico è osservare che " i bit sono più piccoli, quindi non hanno bisogno di molti caratteri nei loro operatori " .
La parola cast è usata nel senso di " fondere in uno stampo" . Il compilatore cambierà automaticamente un tipo di dato in un altro se ciò è sensato. Per esempio, se si assegna un valore intero ad una variabile in virgola mobile, il compilatore chiamerà segretamente una funzione ( o più probabilmente, inserirà un codice) per convertire un int in un float . Il casting permette di fare questo tipo di conversione esplicita, o di forzarla quando non accadrebbe normalmente.
Per eseguire un cast, mettere il tipo di dato desiderato ( includendo tutti i modificatori) dentro le parentesi a sinistra del valore. Questo valore puo' essere una variabile, una costante, un valore prodotto da una espressione o il valor di ritorno di una funzione. Ecco qui un esempio:
//: C03:SimpleCast.cpp
int main() {
int b = 200;
unsigned long a = ( unsigned long int )b;
} ///:~
il casting è potente, ma puo' causare mal di testa perchè in alcune situazioni forza il compilatore a trattare il dato come se fosse ( ad esempio) più grande di quanto realmente è, quindi esso occuperà più spazio in memoria, questo puo' calpestare altri dati. Ciò usualmente accade quando si fa il casting di puntatori, non quando si fanno semplici cast come quello mostrato sopra.
Il C++ ha una sintassi di cast addizionale, la quale segue la sintassi della chiamata a funzione. Questa sintassi mette le parentesi intorno all' argomento, come una chiamata a funzione, piuttosto che intorno al tipo di dato :
//: C03:FunctionCallCast.cpp
int main() {
float a = float (200);
// questo è l'equivalente di :
float b = ( float )200;
} ///:~
naturalmente nel caso di sopra non si avrebbe bisogno di un cast, si potrebbe solo dire 200 . f oppure 200.0f ( in effetti, ciò è tipicamente cosa il compilatore farà nella espressione di sopra). I cast sono usualmente usati con le variabili, piuttosto che con le costanti.
I cambi dovrebbero essere usati con cautela, poichè quello che realmente si sta facendo è dire al compilatore " dimentica il controllo del tipo , trattalo invece come qualche altro tipo. " . Cioè, si sta introducendo un buco nel sistem del tipo del C++ ed impedendo al compilatore di dire che si sta facendo qualcosa di sbagliato con un tipo. Cosa peggiore, il compilatore crede a noi implicitamente e non esegue alcun altro controllo per controllare gli errori. Una volta iniziato il casting , ci apriremo a tutti i tipi di problemi. Infatti, qualsiasi programma che usa molti cast dovrebbe essere visto con sospetto, non importa quante volte viene detto che "deve" essere fatto in quel modo. In generale, i cast dovrebbero essere pochi e isolati alla soluzione di specifici problemi.
Una volta capito ciò e viene presentato un programma bacato, la prima tendenza potrebbe essere quella di cercare i cast come colpevoli, ma come si localizzano i cast dello stile C? Ci sono semplici nomi dentro le parentesi e se si inizia a cercare tali cose si scoprirà che è spesso difficile distinguerli dal resto del codice.
Lo standard C++ include un' esplicita sintassi di cast che puo' essere usata per riporre completamente il vechio stile di cast in C (naturalmente, lo stile del cast in C non puo' essere bandito senza un codice di interruzione, ma gli inventori del compilatore potrebbero facilmente richiamare l' attenzione sul vecchio stile di cast). L' esplicita sintassi del cast in C è tale che si puo' facimente trovarlo, come si puo' vedere dai loro nomi :
static_cast | Per i cast " ben funzionanti" e " ragionevolmente ben funzionanti" , incluse le cose che si possono fare senza un cast( come una conversione automatica di tipo). |
const_cast | Per cast con const e/o volatile . |
reinterpret_cast |
casting in un altro significato completamente differente . Il punto chiave è che si avrà bisogno di fare un cast all' originale per usarlo con sicurezza. Il tipo a cui si fa il casting è tipicamente usato solo rigirare i bit o per altri misteriosi propositi. Questo è il più pericoloso tra i cast. |
dynamic_cast | Per un downcasting sicuro (si veda il Capitolo15). |
Uno static_cast (cambio static) è usato per tutte le conversioni che sono ben definite. Queste includono conversioni " sicure " che il compilatore permetterebbe a noi di programmare senza un cast e convesioni meno sicure che sono tuttavia ben definite. I tipi di conversione inclusi in static_cast includono tipiche convesioni senza cast , conversioni con narrowing(perdita di informazioni ), forzando una conversione da un void* , conversioni del tipo implicite, ed una navigazione statica di gerarchie di classi ( poichè non si sono viste ancora le classi e l' ereditarietà , quest' ultimo argomento sarà rimandato fino al capitolo 15 ) :
//: C03:static_cast.cpp
void func( int ) {}
int main() {
int i = 0x7fff; // valore della posizione max = 32767
long l;
float f;
// (1) tipica conversione priva di cast:
l = i;
f = i;
// funziona anche:
l = static_cast < long >(i);
f = static_cast < float >(i);
//(2) conversione con narrowing:
i = l; // può perdere digit
i = f; // può perdere informazioni
// dice "capisco," elimina i warnings :
i = static_cast < int >(l);
i = static_cast < int >(f);
char c = static_cast < char >(i);
// (3) forza una conversione dal void* :
void * vp = &i;
// un vecchio modo produce una conversione pericolosa :
float * fp = ( float *)vp;
// il nuovo modo è ugualmente pericoloso :
fp = static_cast < float *>(vp);
// (4) conversione implicita del tipo normalmente
// eseguita dal compilatore :
double d = 0.0;
int x = d; // conversione del tipo automatica
x = static_cast < int >(d); // più esplicita
func(d); // conversione del tipo automatica
func( static_cast < int >(d)); // più esplicita
} ///:~
nella sezione (1), si vedono i tipi di conversioni che utilizzati programmando in C, con o senza il cast. Promuovere da un int a un long o float non è un problema perchè l' ultimo puo' sempre contenere ogni valore che un int puo' contenere. Sebbene non è necessario, si puo' usare lostatic_cast( cambio static) per mettere in rilievo queste promozioni.
L' altro modo di riconversione è mostrato in (2). Qui, si puo' perdere dati perchè un int non è grande come un long o un float ; non conterrebbe numeri della stessa grandezza. Perciò queste sono chiamate conversioni con narrowing. Il compilatore le eseguirà lo stesso, ma dimenticherà spesso di darci un warning. Si puo' eliminare questo warning ed indicare che realmente si intendeva usare un cast.
Assegnare da un void* non è permesso senza un cast in C++ (non come in C), come visto in (3). Questo è pericoloso e richiede che i programmatori sappiano ciò che stanno facendo. Lo static_cast, almeno, è più facile da localizzare che il vecchio cast standard quando si stanno cercando i bachi.
La sezione (4) del programma mostra i tipi di conversione di tipo implicito che sono normalmente eseguiti automaticamente dal compilatore. Questi sono automatici e non richiedono il casting, ma ancora lo static_cast mette in rilievo l' azione nel caso che si voglia rendere chiaro cosa succede o cercala in seguito.
Se si vuole convertire da una const a una non const o da una volatile a una non volatile , si usa const_cast . Questo è l' unica conversione permessa con const_cast; se ogni altra conversione è utilizzara, essa deve essere fatto usando una espressione separata o si avrà un errore al tempo di compilazione.
//: C03:const_cast.cpp
int main() {
const int i = 0;
int * j = ( int *)&i; // forma deprecata
j = const_cast < int *>(&i); // Preferita
// non si puo' fare un cast simultaneo addizionale :
//! long* l = const_cast<long*>(&i); // Errore
volatile int k = 0;
int * u = const_cast < int *>(&k);
} ///:~
se si prende l' indirizzo di un oggetto const , si produce un puntatore ad un const , e ciò non puo' essere assegnato ad un puntatore di una non const senza un cast. Il vecchio stile di cast eseguirà ciò, ma il const_cast e l' unico appropriato da usare. Lo stesso vale per volatile .
Questo è il meno sicuro dei meccanismi di casting, e l' unico che produce con più probabilità i bachi. Un reinterpret_cast pretende che un oggetto è un pattern di bit che puo' essere trattato ( per alcuni propositi oscuri) come se fosse un tipo interamente differente. Questa è la manipolazione a basso livello dei bit per cui il C è noto. Virtualmente si avrà sempre bisogno di reinterpret_cast per tornare al tipo originale ( o altrimenti si tratta la variabile come il tipo originale ) prima di fare qualsiasi cosa con esso.
//: C03:reinterpret_cast.cpp
#include <iostream>
using namespace std;
const int sz = 100;
struct X { int a[sz]; };
void print(X* x) {
for ( int i = 0; i < sz; i++)
cout << x->a[i] << ' ';
cout << endl << "--------------------" << endl;
}
int main() {
X x;
print(&x);
int * xp = reinterpret_cast < int *>(&x);
for ( int * i = xp; i < xp + sz; i++)
*i = 0;
// non si puo' usare xp come un X* a questo punto
// a meno che non si fa il cast indietro:
print( reinterpret_cast <X*>(xp));
// in questo esempio, si puo' anche solo usare
// l'identificatore originale:
print(&x);
} ///:~
in questo semplice esempio, struct X contiene un array di int , ma quando se ne crea uno nello stack come in X x , il valore di ogni int è spazzatura ( ciò è mostrato usando la funzione print( ) per visualizzare i contenuti di struct ). Per inizializzarli, l' indirizzo di X è preso e cambiato in un puntatore int , il quale è poi agisce lungo l' array per settare ogni int a zero. Si noti come il più alto designato ad arrivare ad i è calcolato " aggiungendo " sz a xp ; il compilatore sa che si vuole realmente le locazioni di sz più grandi di xp e esso produce il corretto puntatore aritmetico per noi.
In this simple example, struct X just contains an array of int , but when you create one on the stack as in X x , the values of each of the int s are garbage (this is shown using the print( ) function to display the contents of the struct ). To initialize them, the address of the X is taken and cast to an int pointer, which is then walked through the array to set each int to zero. Notice how the upper bound for i is calculated by " adding " sz to xp ; the compiler knows that you actually want sz pointer locations greater than xp and it does the correct pointer arithmetic for you.
L' idea del reinterpret_cast è che quando lo si usa, quello che si ottiene non ha così relazione che non puo' essere usato per il proposito originale del tipo a emno che non si lo si cambi indietro. Qui, si vede il cambio in X* nella chiamata a stampare, ma naturalmente poichè si ha ancora l' originale identificatore si puo' anche usarlo. Ma l' xp è solo utile come un int* , il quale è realmente una " rinterpretazione " dell' originale X .
The idea of reinterpret_cast is that when you use it, what you get is so foreign that it cannot be used for the type' s original purpose unless you cast it back. Here, we see the cast back to an X* in the call to print, but of course since you still have the original identifier you can also use that. But the xp is only useful as an int* , which is truly a " reinterpretation " of the original X .
Un reinterpret_cast spesso indica sconsigliato e non trasportabile per la programmazione, ma è disponibile quando si decide di dover usarla.
A reinterpret_cast often indicates inadvisable and/or nonportable programming, but it' s available when you decide you have to use it.
sizeof - an operator by itself
l' operatore sizeof sta da solo perchè soddisfa un bisogno unusuale. Sizeof ci da l' informazione circa l' ammontare della memoria allocata per gli oggetti data. Cme descritto poco fa in questo capitolo, sizeof dice a noi il numero di byte usati da ogni particolare variabile. Esso puo' anche dare la grandezza di un tipo data ( con nessun nome di variabile ):
The sizeof operator stands alone because it satisfies an unusual need. sizeof gives you information about the amount of memory allocated for data items. As described earlier in this chapter, sizeof tells you the number of bytes used by any particular variable. It can also give the size of a data type (with no variable name):
//: C03:sizeof.cpp
#include <iostream>
using namespace std;
int main() {
cout << "sizeof(double) = " << sizeof ( double );
cout << ", sizeof(char) = " << sizeof ( char );
} ///:~
per definizione, il sizeof di ogni tipo di char ( signed , unsigned o pieno) è sempre uno, anche se la memoria utilizzata per un char non è realmente un byte. Per tutti gli altri tipi, il risultato è la grandezza in byte.
Si noti che sizeof è un operatore, non una funzione. Se lo si applica ad un tipo, deve essere usato con le parentesi nella forma mostrata sopra, ma se si applica ad una variabile si puo' usarla senza parentesi :
//: C03:sizeofOperator.cpp
int main() {
int x;
int i = sizeof x;
} ///:~
sizeof puo' anche darci la grandezza per i tipi di dato definiti dagli utenti. Ciò è usato in seguito nel libro.
Questo è un meccanismo di fuga che ci permette di scrivere il codice assembler per il nostro hardware in un programma C++. Spesso si può far riferimento a variabili in C++ senza un codice assembly, che significa che si puo' comunicare facilmente con il proprio codice in C++ e limitare il codice assembly al necessario per l' efficienza o per l' uso di istruzioni speciali del processore. L' esatta sintassi che si deve usare quando si scrive in linguaggio assembly è compilatore-dipendente e puo' essere scoperto nella propria documentazione del compilatore.
Queste sono parole chiave per gli operatori logici o su bit. I programmatori non americani senza caratteri della tastiera come & , | , ^ , e così via, sono obbligati a usare l' orribile trigraphs (trigrafo) , il quale non solo è noiso da scrivere, ma oscura quando lo si legge. In C++ si rimedia con le parole chiave addizionali :
Parola chiave | Significato |
and | && (and logico) |
or | || (or logico) |
not | ! ( NOT logico) |
not_eq | != (logico non-equivalente) |
Bitand | & (bitwise and ) |
And_eq | &= (bitwise and -assegnamento) |
Bitor | | (bitwise or ) |
Or_eq |
|= (bitwise or-assegnamento) |
Xor |
^ (bitwise esclusivo-or) |
xor_eq | ^= (bitwise esclusivo-or-assegnamento) |
Compl | ~ (complemento) |
Or
Not
not_eq
Bitand
& (bitwise and )
And_eq
&= (bitwise and -assegnamento)
Bitor
| (bitwise or )
Or_eq
|= (bitwise or-assegnamento)
Xor
^ (bitwise esclusivo-or)
xor_eq
^= (bitwise esclusivo-or-assegnamento)
Compl
Se il proprio compilatore è conforme allo Standard C++, supporta queste parole chiave.
I tipi di dato fondamentali e le loro variazioni sono essenziali, ma piuttosto primitive. Il C ed il C++ forniscono di strumenti che ci permettono di comporre tipi di dato più sofisticati dai tipi di dato fondamentali. Come si vedrà , la più importante di queste è struct , che è la base per class in C++. Comunque, il modo più semplice per creare tipi più sofisticati è semplicemente dare uno pseudonimo ad un altro attraverso typedef .
Pseudonimi con typedef
Questa parola chiave promette più di quanto offre: typedef suggerisce " definizione di tipo " quando " pseudonimo" probabilmente sarebbe stata una descrizione più accurata , poichè questo è ciò che realmente fa. La sintassi è:
Typedef esistente-tipo-descrizione dello pseudonimo
si usa spesso typedef quando il tipo di dato diventa leggermente complicato, proprio per scrivere di meno. Ecco un typedef comunemente usato :
typedef unsigned long ulong;
ora se si dice ulong il compilatore sa che si intende unsigned long . Si puo' pensare che ciò potrebbe essere compiuto più facilmente usando la sostituzione del preprocessore, ma ci sono situazioni chiave nelle quali il compilatore deve essere consapevole che si sta trattando un nome come se fosse un tipo, quindi typedef è essenziale.
Un posto dove typedef torna utile è per i tipi di puntatori. Come precedentemente menzionato, se si dice :
int * x, y;
ciò realmente produce un int* il quale è x e un int ( non un int* ) il quale è y. Cioè, il ' *' si riferisce a destra non a sinistra. Comunque se si usa un typedef :
typedef int * IntPtr;
IntPtr x, y;
Dopo entrambi x e y sono tipi di int* .
Si puo' dire che è più esplicito, perciò più leggibile, evitare typedef per i tipi primitivi, ed effettivamente i programmi diventano rapidamente difficili da leggere quando vengono usati molti typedef . Tuttavia, i typedef diventano specialmente importanti in C quando sono usati con struct .
Combinare le variabili con struct
Una struct è un modo per raccogliere un gruppo di variabili in una struttura. Una volta creata una struct , dopo si possono fare molti esempi di questo " nuovo " tipo di variabile che si è inventati. Ad esempio:
//: C03:SimpleStruct.cpp
struct Structure1 {
char c;
int i;
float f;
double d;
};
int main() {
struct Structure1 s1, s2;
s1.c = 'a'; // Selezionare un elemento usando un '.'
s1.i = 1;
s1.f = 3.14;
s1.d = 0.00093;
s2.c = 'a';
s2.i = 1;
s2.f = 3.14;
s2.d = 0.00093;
} ///:~
la dichiarazione struct deve terminare con un punto e virgola. In main( ) , le due istanze di Structure1 sono creati : s1 e s2. ognuna di queste ha la loro propria versione separata di c, i , f , e d . Quindi s1 e s2 rappresenta un gruppo di variabili completamente indipendenti. Per selezionare uno degli elementi senza s1 o s2 , si usa un ' .' , sintassi che si è vista nel capitolo precedente quando si usano gli oggetti class in C++ , poichè class evolve dalle struct , questa è da dove quella sintassi è venuta fuori.
Una cosa che si noterà è l' incapacità di uso di Structure1 ( a cui è rivolto, questo è solamente richiesto dal C, non in C++). In C non si puo' dire solo Structure1 quando si sta definendo le variabili, si deve dire struct Structure1 . Questo è dove typedef diventa specialmente pratico in C:
//: C03:SimpleStruct2.cpp
// usare typedef con struct
typedef struct {
char c;
int i;
float f;
double d;
} Structure2;
int main() {
Structure2 s1, s2;
s1.c = 'a';
s1.i = 1;
s1.f = 3.14;
s1.d = 0.00093;
s2.c = 'a';
s2.i = 1;
s2.f = 3.14;
s2.d = 0.00093;
} ///:~
usando typedef in questo modo, si puo' fingere ( in C; si provi a rimuovere il typedef per il C++) che Structure2 sia un tipo predefinito, come int o float , quando si definiscono s1 e s2 ( ma si noti che esso ha solo la caratteristica del dato non il comportamento, che è quello che otteniamo con gli oggetti veri in C++). Si noterà che l' identificatore struct è stata persa via all' inizio, perchè lo scopo è creare il typedef . Comunque, ci sono delle volte in cui quando si puo' aver bisogno di riferirsi alla struct durante la sua definizione. In questi casi, si puo' realmente ripetere il nome del struct come il nome del struct e come typedef :
//: C03:SelfReferential.cpp
// permettere ad una struct di riferirsi a se stessa
typedef struct SelfReferential {
int i;
SelfReferential* sr; // un giramento di testa ancora?
} SelfReferential;
int main() {
SelfReferential sr1, sr2;
sr1.sr = &sr2;
sr2.sr = &sr1;
sr1.i = 47;
sr2.i = 1024;
} ///:~
se si osserva questo per un pò, si vedrà che sr1 e sr2 si puntano l'un l'altro ed ognuno ha un pezzo di dato.
Realmente, il nome struct non deve essere lo stesso del nome di typedef , ma è usualmente fatto in questo modo perchè tende a rendere le cose più semplici.
Puntatori e struct
Nell' esempio di sopra, tutte le struct sono manipolate come oggetti. Tuttavia, come qualsiasi pezzo di memoria, si puo' prendere l' indirizzo di un oggetto struct ( come visto sopra in SelfReferential.cpp ). Per scegliere gli elementi di un particolare oggetto struct, si usa un ' .' , come visto sopra. Tuttavia, se si ha un puntatore ad un oggetto struct , si deve scegliere un elemento di quell' oggetto usando un differente operatore : il ' ->' . Ecco qui un esempio :
//: C03:SimpleStruct3.cpp
//usare i puntatori alle struct
typedef struct Structure3 {
char c;
int i;
float f;
double d;
} Structure3;
int main() {
Structure3 s1, s2;
Structure3* sp = &s1;
sp->c = 'a';
sp->i = 1;
sp->f = 3.14;
sp->d = 0.00093;
sp = &s2; // punta ad un differente oggetto struct
sp->c = 'a';
sp->i = 1;
sp->f = 3.14;
sp->d = 0.00093;
} ///:~
in main( ) , il puntatore alla struct sp sta inizialmente puntando a s1 ed i membri di s1 sono inizializzati selezionandoli con ' ->' ( si puo' usare lo stesso operatore per leggere questi membri ). Ma poi sp punta a s2 e queste variabili sono inizializzate allo stesso modo. Quindi si puo' vedere che un altro beneficio dei puntatori è che possono essere dinamicamente deviati a puntare a oggetti differenti; questo provoca più flessibilità nel proprio programma, come si apprenderà .
Per adesso, questo è tutto ciò che di cui si ha bisogno sulle struct , ma ci sentiremo maggiormente a nostro agio con loro ( e specialmente i loro più potenti successori, le classi) più in avanti nel libro.
Chiarificare i programmi con enum
Un tipo data numerato è un modo di assegnare i nomi ai numeri, quindi dando più significato a chiunque legge il codice. La parola chiave enum (dal C ) automaticamente enumera qualsiasi lista di identificatori che si danno assegnando i valori di 0,1,2. ecc.. Si possono dichiarare le variabili enum ( le quali sono sempre rappresentate come valori inteir ). La dichiarazione di un enum assomiglia ad una dichiarazione di struct .
Un tipo di dato enumerato è utile quando si vuole tenere conto di alcuni tipi di caratteristiche :
//: C03:Enum.cpp
// tenere traccia delle forme
enum ShapeType {
circle,
square,
rectangle
};
// deve finire con un punto e virgola come una struct
int main() {
ShapeType shape = circle;
// attività qui....
// adesso facciamo qualcosa basata su quale forma è :
switch (shape) {
case circle: /* cosa circolare */ break ;
case square: /* cosa quadrata */ break ;
case rectangle: /* cosa rettangolare */ break
}
} ///:~
shape è una variabile di tipo dato enumerato ShapeType ed il suo valore è confrontato col valore nella enumerazione. Poichè shape è realmente solo un int , comunque, puo' essere qualsiasi valore che si può assegnare ad un int ( incluso un numero negativo ). Si puo' anche confrontare una variabile int col valore nella enumerazione.
Si deve essere consapevoli che l'esempio precedente il tipo muta ad essere un modo problematico al programma. C++ ha un modo migliore per codificare questa sorta di cosa,
la
spiegazione del quale deve essere rimandata in seguito nel libro.
Se
non piace il modo in cui il compilatore assegna valori, si può fare da sè:
enum ShapeType {
circle = 10, square = 20, rectangle = 50
};
se si dà il valore ad alcuni nomi e
non ad altri, il compilatore userà il prossimo valore intero. Ad esempio,
enum snap { crackle = 25, pop };
il compilatore da a pop il valore 26.
Si
può vedere come sia più leggibile il codice quando si usa un tipo di dato
enumerato. Comunque, in una certa misura ciò è ancora un tentativo ( in C
) per compiere le cose che noi facciamo con class in C++, quindi si
vedrà poco enum in C++.
Controllo del tipo per le enumerazioni
Le
enumerazioni sono abbastanza primitive, semplicemente associano valori interi con nomi, ma essi non provvedono
ad un controllo del tipo. In C++ il concetto del tipo è fondamentale e ciò
è vero con le enumerazioni. Quando si crea un' enumerazione con nome, effettivamente
si crea un nuovo tipo proprio come si fa con una classe: il nome della nostra
enumerazione diventa una parola riservata per la durata dell’unità di traduzione.
In
più, c’è un controllo di tipo per le enumerazioni in C++ più preciso
del C. Si noterà ciò in particolare se si hanno esempi di enumerazione
color chiamati a. In C si può dire a++, ma in C++ non
si può. Cioè poichè incrementare una enumerazione
è produrre due conversione di tipo, uno di loro legale in C++ e l’altro illegale. Primo, il valore dell’ enumerazione è implicitamente
cambiata da un color ad un int, dopo il valore è incrementato,
poi l' int è cambiato in un color. In C++ ciò non è permesso,
perché color è un tipo distinto e non equivalente ad int. Ciò
ha senso, perché come si fa a sapere se l’incremento di blue sarà nella lista dei colori
? Se si vuole incrementare un color, esso dovrebbe essere una classe
( con un’operazione di incremento
) e non un enum, poiché la classe può essereabbastanza sicura. Ogni
volta che si scrive codice che assume una conversione implicita in un tipo
enum, il compilatore richiamerà all’attenzione su questa pericolosa attività.
Le
union (descritte in seguito ) hanno controlli sul tipo addizionali simili
in C++.
Risparmiare la memoria con union
Qualche
volta un programma gestirà differenti tipi di dato usando la stessa
variabile. In questa situazione, si hanno due scelte : si può creare
una struct contenente tutte i possibili tipi che potrebbero servire
o si può usare un union. Un union accatasta tutti i tipi di
dato in un singolo spazio, ne segue che la dimensione della union è
data dal l’oggetto più grande che si è messo in essa. Si usi union per risparmiare la memoria.
Ogni
volta che si piazza un valore in una
union, il valore comincia sempre allo stesso posto all’inizio dell’union, ma usa solo lo spazio
necessario. Quindi si crea una “super
variabile “ capace di detenere ogni variabile in union. Tutte gli indirizzi delle variabili
di union sono gli stessi (in una class o struct, gli
indirizzi sono differenti ).
Ecco
un semplice uso di union. Si provi a rimuovere vari elementi e si veda
che effetto esso ha sulla grandezza dell’union.
Si noti che non ha senso dichiarare più di un esempio di un singolo tipo data
in un union ( tranne quando si sta usando un nome differente).
//: C03:Union.cpp
// la dimensione ed il semplice uso di
una unione
#include
<iostream>
using namespace std;
union Packed { // dichiarazione
simile a class
char i;
short j;
int k;
long l;
float f;
double d;
// l’unione avrà la dimensione
di un
//double, poiché quello è l’elemento più grande
}; // il punto e virgola termina
una union, come una struct
int main() {
cout << "sizeof(Packed)
= "
<< sizeof(Packed)
<< endl;
Packed x;
x.i = 'c';
cout << x.i << endl;
x.d = 3.14159;
cout << x.d << endl;
} ///:~
il compilatore esegue la giusta assegnazione
secondo il membro union che si è scelto.
Ua
volta si esegue un'assegnazione, il
compilatore non si preoccupa di ciò che si fa con l’union. Nell’esempio
sopracitato, si potrebbe assegnare un valore in virgola mobile a x:
x.f = 2.222;
e dopo mandarlo all’output come se fosse un int :
cout << x.i;
ciò produrrebbe spazzatura.
Gli array
I
vettori sono un tipo composto perchè permettono di racchiudere molte variabili
insieme, una dopo l’altra, sotto
un singolo nome identificatore. Se si dice :
int a[10];
si crea memoria per 10 variabili int
accatastate una su l’altra, ma senza
un unico nome identificatore per ogni variabile. Invece, esse sono tutte raggruppate
sotto il nome a.
Per accedere ad una di questi elementi del vettore (array elements ), si usa la stessa sintassi di con parentesi quadre che si usa per definire un array:
a[5] = 47;
comunque, si deve ricordare che anche se la
grandezza di a è 10, si scelgono gli elementi del vettore cominciando
da 0 ( questo è chiamato qualche volta zero indexing), quindi si possono
selezionare solo elementi del vettore 0-9 :
//: C03:Arrays.cpp
#include
<iostream>
using namespace std;
int main() {
int a[10];
for(int i = 0; i < 10; i++) {
a[i] = i * 10;
cout <<
"a[" << i << "] = " << a[i] << endl;
}
} ///:~
l'accesso ad un array è estremamente veloce.
Tuttavia, se l’indice supera la fine
di un array, non c’è nessuna rete
di sicurezza – si andrà su altre
variabili. L’altro svantaggio è che si deve definire la grandezza
di un array al tempo di compilazione; se si vuole cambiare la grandezza al
tempo di esecuzione non si può usare la sintassi sopracitata ( il C ha un
modo per creare un array dinamicamente, ma è significamente il più disordinato
). Il vector del C++, introdotto nel capitolo precedente, produce un
array simile ad un oggetto che automaticamente cambia da sè le dimensioni,
quindi è solitamente la migliore soluzione
se non si conosce la dimensione dell' array al tempo
di compilazione.
Si
possono programmare array di ogni tipo, anche di struct :
//: C03:StructArray.cpp
// un array di struct
typedef struct {
int i, j, k;
} ThreeDpoint;
int main() {
ThreeDpoint p[10];
for(int i = 0; i < 10; i++) {
p[i].i = i + 1;
p[i].j =
i + 2;
p[i].k =
i + 3;
}
} ///:~
si noti come l’identificatore i di struct sia indipendente
da i del ciclo for.
Per
vedere che ogni elemento di un array è vicino al prossimo, si possono stampare
gli indirizzi :
//: C03:ArrayAddresses.cpp
#include
<iostream>
using namespace std;
int main() {
int a[10];
cout << "sizeof(int)
= "<< sizeof(int) << endl;
for(int i = 0; i < 10; i++)
cout << "&a["
<< i << "] = "
<< (long)&a[i]
<< endl;
} ///:~
quando si fa girare questo programma, si vedrà
che ogni elemento è distante int da quello precedente. Cioè, loro sono
ammassati l’uno su l’altro.
Puntatori ed array
L’identificatore di un array è diverso da quello per
le variabili ordinarie. Per una cosa, un identificatore di array non è un
lvalue; non si può assegnare ad esso. E’
realmente solo un gancio nella sintassi con le parentesi quadre, quando si
da' un nome di un array, senza parentesi quadre, quello che si ottiene è l’indirizzo iniziale di un array :
//: C03:ArrayIdentifier.cpp
#include
<iostream>
using namespace std;
int main() {
int a[10];
cout <<
"a = " << a << endl;
cout <<
"&a[0] =" << &a[0]
<< endl;
} ///:~
quando si fa partire il programma si vedrà
che i due indirizzi ( i quali saranno stampati in esadecimale, poichè non
c’è il cast a long) sono gli stessi.
Quindi
un modo per vedere l’identificatore
dell’array è come un puntatore all’inizio dell’array
di sola lettura. E sebbene non possiamo cambiare un identificatore dell’array per puntare da qualche altra parte, ma possiamo
creare un altro puntatore e usarlo per muoverci nell’array. Infatti, la sintassi con le parentesi quadre
funziona anche con puntatori regolari :
//: C03:PointersAndBrackets.cpp
int main() {
int a[10];
int* ip = a;
for(int i = 0; i < 10; i++)
ip[i] = i * 10;
} ///:~
il fatto di dare un nome ad un array produce
che il suo iniziale indirizzo tende ad essere abbastanza importante quando
si vuole passare un array ad una funzione. Se si dichiara un array come un
argomento della funzione, quello che realmente si dichiarerà è un puntatore.
Quindi nell’esempio seguente, func1( ) e func2( )
effettivamente hanno le stesse liste di argomento :
//: C03:ArrayArguments.cpp
#include
<iostream>
#include
<string>
using namespace std;
void func1(int a[], int size) {
for(int i = 0; i < size; i++)
a[i] = i * i - i;
}
void func2(int* a, int size) {
for(int i = 0; i < size; i++)
a[i] = i * i + i;
}
void print(int a[], string
name, int size) {
for(int i = 0; i < size; i++)
cout << name << "[" << i << "] = "
<< a[i] << endl;
}
int main() {
int a[5], b[5];
// probabilmente valori spazzatura :
print(a, "a",
5);
print(b, "b", 5);
// inizializzazione degli array :
func1(a, 5);
func1(b, 5);
print(a, "a",
5);
print(b, "b", 5);
// si noti che gli array sono sempre modificati
:
func2(a, 5);
func2(b, 5);
print(a, "a",
5);
print(b, "b", 5);
} ///:~
anche se func1( ) e func2( ) dichiarano
i loro argomenti differentemente, l’uso
è lo stesso dentro la funzione. Ci sono alcuni altri problemi che questo esempio
rivela : gli array non possono essere passati per valore[32], cioè, non
si ottiene mai automaticamente una copia locale di un array che si passa in
una funzione. Quindi, quando si modifica un array, si sta modificando sempre
l’oggetto esterno. Questo può essere un po’ confusionario all’inizio, se ci si aspetta un passaggio per valore fornito di argomenti
ordinari.
Si
noti che print( ) usa
la sintassi con le parentesi quadre per gli argomenti dell’array. Anche se la sintassi del puntatore e la sintassi
con le parentesi quadre sono effettivamente le stesse quando si passano gli
array come argomenti, la sintassi con le parentesi quadre lo rende più chiaro
al lettore che si intende questo argomento essere un array.
Si
noti anche che l’argomento size
è passato in ogni caso. Passare solamente l’indirizzo di un array non è un'informazione sufficiente; si deve sempre
essere in grado di sapere quanto grande è un array dentro la nostra funzione,
così non si corre alla fine dell’array
per saperlo.
Gli
array possono essere di ogni tipo, incluso array di puntatori. Infatti, quando
si vuole passare argomenti della riga di comando nel proprio programma, il
C ed il C++ hanno una speciale lista di
argomenti per main( ), che assomiglia a questa:
int main(int argc, char* argv[]) { // ...
il primo argomento è il numero di elementi
nell’array, il quale è il secondo
argomento. Il secondo argomento è sempre un array di char*, perché gli argomenti sono passati da una riga di comando
come array di caratteri ( e si ricordi, un array può essere passato solo come
puntatore ). Ogni cluster di caratteri delimitati da spazi bianchi
nella riga di comando è trasformata in un argomento dell’array separato. Il seguente programma stampa tutti
gli argomenti della riga di comando procedendo lungo l’array :
//: C03:CommandLineArgs.cpp
#include
<iostream>
using namespace std;
int main(int argc, char* argv[]) {
cout << "argc
= " << argc << endl;
for(int i = 0; i < argc; i++)
cout << "argv["
<< i << "] = "
<< argv[i] << endl;
} ///:~
si noterà che argv[0] è il path ed il nome stesso del programma. Questo permette
al programma di scoprire informazioni su di esso. Esso aggiunge anche all’array
gli argomenti del programma, quindiun comune errore quando si va a prendere
gli argomentti della riga di comando è afferrare argv[0] quando si
vuole argv[1].
Non
si è obbligati ad usare argc e argv come identificatori nel
main( ), questi identificatori sono solo convenzioni ( ma confonderanno
la gente se non li si usano). Ancora, c’è
un modo alternativo per dichiarare argv:
int main(int argc, char** argv) { // ...
entrambi le forme sono equivalenti, ma io
trovo che la versione usata in questo libro è la più intuitiva quando
si legge il codice, poiché essa dice, direttamente , “questo è un array di puntatori di caratteri.”
Tutto
quello che si ottiene dalla riga di comando sono array di carattere; se si
vuole trattare un argomento come un altro tipo, si è responsabili della conversione
dentro il proprio programma. Per facilitare la conversione in numeri, ci sono
alcune funzioni utili nella libreria dello Standard C, dichiarata in <cstdlib>.
Le più semplici da usare sono atoi( ), atol( ), e
atof( ) per convertire un array di carattere ASCII
in un int, long, ed
un valore in virgola mobile double, rispettivamente. Ecco qui un esempio
che usa atoi( ) ( le altre due funzioni sono chiamate allo stesso
modo) :
//: C03:ArgsToInts.cpp
// convertire gli argomenti della riga
di comando in int
#include
<iostream>
#include
<cstdlib>
using namespace std;
int main(int argc, char* argv[]) {
for(int i = 1; i < argc; i++)
cout << atoi(argv[i])
<< endl;
} ///:~
in questo programma, si può mettere qualsiasi
numero di argomenti nella riga di comando. Si noterà che il ciclo for
comincia al valore 1 per saltare il nome del programma in argv[0].
Ancora, se si pone un numero in virgola mobile contenente la virgola nella
riga di comando, atoi( ) prende solo le digitazioni fino alla virgola.
Se si mettono non-numeri nella riga di comando questi ritornano dietro da
atoi( ) come
zero.
Esplorare il formato in virgola mobile
La
funzione printBinary( ) introdotta prima in questo
capitolo è pratica per fare ricerche dentro la struttura interna di vari tipi di dato. La più interessante
tra queste è il formato in virgola mobile che permette al C e al C++ di salvare
numeri rappresentati valori molto grandi e molto piccoli in un limitato ammontare
di spazio. Sebbene i dettagli non possono essere completamente esposti qui,
i bit dentro i float e i double sono divisi dentro tre regioni:
l’esponente, la mantissa ed il bit
di segno;quindi esso memorizza i valori che usano una notazione scientifica.
Il programma seguente permette di giocare qua e là stampando i pattern
binari di vari numeri in virgola mobile in modo che si possa dedurre lo schema
usato nel formato in virgola mobile del propriocompilatore ( di solito questo
è lo standard IEEE per i numeri in virgola mobile, ma il proprio compilatore
potrebbe non seguirlo) :
//: C03:FloatingAsBinary.cpp
//{L} printBinary
//{T} 3.14159
#include
"printBinary.h"
#include
<cstdlib>
#include
<iostream>
using namespace std;
int main(int argc, char* argv[]) {
if(argc !=
2) {
cout <<
"si deve fornire un numero" <<
endl;
exit(1);
}
double d =
atof(argv[1]);
unsigned char* cp =
reinterpret_cast<unsigned char*>(&d);
for(int i = sizeof(double)-1; i >= 0 ; i -= 2) {
printBinary(cp[i-1]);
printBinary(cp[i]);
}
} ///:~
primo, il programma garantisce che si è dato
un argomento controllando il valore di argc, il quale vale due se c’è un singolo argomento ( vale uno se non ci sono argomenti,
poiché il nome del programma è sempre il primo elemento di argv). Se
fallisce, un messaggio vienestampato e la funzione exit( ) della
libreria dello standard C viene chiamata per terminare il programma.
Il
programma prende l’argomento dalla
riga di comando e converte i caratteri in double usando atof( ).
Dopo il double è trattato come un array di byte prendendo l’indirizzo e cambiandolo in un unsigned char*. Ognuno di questi byte
è passato a printBinary( ) per la visualizzazione.
Questo
esempio è stato impostato per stampare i byte in un ordine tale che il bit
del segno appare per primo – sulla
mia macchina. Il proprio potrebbe essere differente, quindi si potrebbe ri-arrangiare
il modo in cui le cose vengono stampate. Si dovrebbe essere consapevoli che
i formati in virgola mobile sono non facili da comprendere; ad esempio, l’esponente
e la mantissa non sono generalmente arrangiati sul byte, ma invece un numero
di bit è riservato per ognuno e sono immagazzinati nella minor memoria possibile.
Per vedere veramente cosa succede, si avrebbe bisogno di trovare la grandezza
di ogni parte del numero ( i bit di segno sono sempre un bit, ma gli esponenti
e le mantisse sono di differenti grandezze ) e stampare i bit in ogni parte
separatamente.
Il puntatore aritmetico
Se
tutto quello che si potesse fare con un puntatore che punta ad un array è
trattarlo come se fosse uno pseudonimo dell’array, i puntatori ad array non sarebbero molto interessanti. Tuttavia,
i puntatori sono molto più flessibili, poiché essi possono essere modificati
per puntare altrove ( ma si ricordi, l’identificatore dell’array non può essere modificato per puntare da qualche altra parte).
I puntatori aritmetici si
riferiscono all’applicazione di alcuni
degli operatori aritmetici ai puntatori. La ragione per cui il puntatore aritmetico
è un soggetto separato da un ordinario aritmetico è che i puntatori devono
conformarsi a speciali costrizioni in modo da funzionare correttamente Ad
esempio, un comune operatore che si usa con i puntatori è ++, il quale siginifica
“aggiungi uno al puntatore “. Quello che questo realmente significa è che il puntatore
è cambiato per puntare“al prossimo
valore “, qualunque cosa significhi.
Ecco un esempio :
//: C03:PointerIncrement.cpp
#include
<iostream>
using namespace std;
int main() {
int i[10];
double d[10];
int* ip = i;
double* dp = d;
cout << "ip = " << (long)ip << endl;
ip++;
cout << "ip
= " << (long)ip <<
endl;
cout << "dp
= " << (long)dp <<
endl;
dp++;
cout << "dp
= " << (long)dp <<
endl;
} ///:~
per una esecuzione sulla mia macchina, l’output è :
ip
= 6684124
ip
= 6684128
dp
= 6684044
dp = 6684052
quello che è interessante qui è che anche
se l’operazione ++ appare essere
la stessa operazione per gli entrambi int* e double*, si può vedere che il
puntatore era cambiato solo di 4 byte per int* ma 8 byte per double*. Non allo stesso tempo, queste
sono le grandezze int e double sulla mia macchina. E
questoè il trucco del puntatore aritmetico : il compilatore calcola il corretto
ammontare per cambiare il puntatore così esso punta al prossimo elemento
nell’array( il puntatore aritmetico è solo significativo
dentro gli array). Ciò funziona anche con array di struct :
//: C03:PointerIncrement2.cpp
#include
<iostream>
using namespace std;
typedef struct {
char c;
short s;
int i;
long l;
float f;
double d;
long double ld;
} Primitives;
int main() {
Primitives p[10];
Primitives* pp = p;
cout << "sizeof(Primitives) = "
<< sizeof(Primitives)
<< endl;
cout << "pp
= " << (long)pp <<
endl;
pp++;
cout << "pp
= " << (long)pp <<
endl;
} ///:~
L’output
per una esecuzione sulla mia macchina era:
sizeof(Primitives) = 40
pp
= 6683764
pp = 6683804
così si può vedere che il compilatore fa ancora
la giusta cosa per puntatori a struct ( e class e union
).
Il
puntatore aritmetico funziona anche con l’operatore
--, +, e -, ma gli ultimi due operatori sono limitati
: non si possono addizionare due puntatori e se si sottraggono i puntatori
il risultato è il numero di elementi tra i due puntatori. Comunque, si può
aggiungere o sottrarre un valore intero e un puntatore. Ecco un esempio per
dimostrare l’uso dei puntatori aritmetici
:
//: C03:PointerArithmetic.cpp
#include
<iostream>
using namespace std;
#define P(EX) cout <<
#EX << ": " << EX
<< endl;
int main() {
int a[10];
for(int i = 0; i < 10; i++)
a[i] = i; // Dare i valori
indice
int* ip = a;
P(*ip);
P(*++ip);
P(*(ip + 5));
int* ip2 =
ip + 5;
P(*ip2);
P(*(ip2 - 4));
P(*--ip2);
P(ip2 - ip); // Produce
un numero di elementi
} ///:~
esso comincia con un altra macro, ma questa
usa una caratteristica del preprocessore chiamata stringizing ( implementata
con il segno ‘#’ prima di una espressione ) che prende qualsiasi espressione
e la cambia in un array di caratteri. Ciò è abbastanza utile, poiché permette
all’espressione di essere stampata,
seguita da due punti, seguita dal valore dell’espressione. In main( ) si
può vedere l’utile stenografia che
è stata prodotta.
Sebbene
le versioni di prefisso e di suffisso di ++ e - sono valide con i puntatori,
solo le versioni di prefisso sono usati in questo esempio perchè sono applicate
prima che i puntatori siano dereferenziati nell’espressione di sopra, così ci permettono di vedere gli effetti delle
operazioni. Si noti che solo i valori interi erano stati aggiunti o sottratti;
se due puntatori fossero combinati il compilatore non l’avrebbe permesso.
Ecco
l’output per il programma di sopra
:
*ip:
0
*++ip:
1
*(ip
+ 5): 6
*ip2:
6
*(ip2
- 4): 2
*--ip2:
5
in tutti i casi, i puntatori arimetici risultano
nel puntatore che è adattato a puntare al “posto giusto “,
basato sulla grandezza degli elementi a cui sta puntando.
Se
il puntatore aritmetico sembra un pò opprimente all’inizio, non ci si deve preoccupare. La maggior parte
delle volte che si avrà bisogno di creare array ed indici dentro essi con
[ ], di solito si avrà bisogno al massimo di ++ e --.
Il puntatore aritmetico è generalmente riservato per programmi più intelligenti
e complessi, e molti dei contenitori nella libreria standard C++ nascondono
la mggioranza di questi dettagli intelligenti quindi non ci si deve preoccupare
di essi.
Suggerimenti per debugging
In
uno ambiente ideale, si ha un eccellente debugger disponibile che rende facilmente
trasparente il comportamento del proprio programma così si possonopresto scoprire
gli errori. Tuttavia, molti debugger hanno punti deboli, e questi richiederanno di includere frammenti di codice
nel proprio programma per aiutarci a capire cosa succede. In più, può
capitare di sviluppare in un ambiente ( come un sistema embedded, dove io
ho speso i miei anni formativi ) che non ha debugger disponibili e forse un
feedback molto limitato (es. un display LED ad una riga ). In questi casi
si diventa creativi nei modi in cui si scoprono e si visualizzano informazioni
sull’evoluzione del proprio programma. Questa sezione suggerisce alcune tecniche
per fare ciò.
I flag di debugging
Se
si cabla il codice di debugging nel
programma, si può incorrere in alcuni problemi. Si inizia ad ottenere troppe
informazioni, che rendono difficile isolare i bug. Quando si pensa di aver
trovato il bug si inizia a strappare via il codice di debugging, per capire poi che c'è bisogno di rimetterlo di
nuovo. Si possono risolvere questi problemi con due tipi di flag : i flag
di debug del preprocessore e i flag di debug a runtime.
I flag di debugging del preprocessore
Usando
le #define per defiire uno o più flag di debugging ( preferibilmente
in un header file ) si può provare a testare un flag usando una dichiarazione
#ifdef ed includere il codice di debugging condizionalmente. Quando
si pensa che il nostro debugging è finito, si può semplicemente usare #undef
per il/i flag ed il codice automaticamente sarà rimosso ( e si
ridurrà la grandezza e il tempo di esecuzione del proprio eseguibile).
E’ meglio decidere sui nomi per le flag di debug prima
che si inizi a costruire il proprio progetto così i nomi saranno consistenti.
I flag del preprocessore sono tradizionalmente distinguibili dalle variabili
scrivendoli con sole lettere maiuscole. Una comune nome di flag è semplicemente
DEBUG (ma sia faccia attenzione a non usare NDEBUG, il quale
è riservato in C). La sequenza di istruzioni potrebbe essere:
#define DEBUG // Probabilmente
in un header file
//...
#ifdef DEBUG // controllare
per vedere se il flag è definito
/* qui il codice di debugging */
#endif // DEBUG
La maggior parte delle implementazioni del
C e C++ permetteranno di utilizzare #define e #undef dalla riga
di comando del compilatore, quindi si può ricompilare il codice ed inserire
l’informazione di debugging con un singolo comando ( preferibilmente con
il makefile, uno strumento che sarà descritto tra breve). Si controlli la
propria documentazione per dettagli.
Le flag di debugging a tempo di esecuzione
In
alcune situazioni è più conveniente accendere o spegnere i flag del debug
durante l’esecuzione del programma,
settandoli quando il programma inizia usando la riga di comando. I programmi
grandi sono noiosi da ricompilare solo per inserire il codice di debug.
Per
accendere e spegnere il codice di debug dinamicamente, creare le flag bool
:
//: C03:DynamicDebugFlags.cpp
#include
<iostream>
#include
<string>
using namespace std;
// i flag di debug non sono necessariamente
globali :
bool debug = false;
int main(int argc, char* argv[]) {
for(int i = 0; i < argc; i++)
if(string(argv[i])
== "--debug=on")
debug = true;
bool go = true;
while(go) {
if(debug)
{
// codice di debug qui
cout << "Debugger
è on!" << endl;
} else {
cout << "Debugger
è off." << endl;
}
cout << "Stato
debugger [on/off/quit]: ";
string reply;
cin >> reply;
if(reply ==
"on") debug = true; // accendere
if(reply
== "off") debug = false; // spegnere
if(reply
== "quit") break; // Fuori dal 'while'
}
} ///:~
questo programma permette di accendere e spegnere
i flag di debugging fino a che si scrive “quit” (“esci”) per
dire che se si vuole uscire. Si noti che esso richiede che siano scritte dentro
le parole intere, non solo lettere ( si può accorciarle ad una lettera se
lo si desidera ). Ancora, l’argomento
della riga di comando può opzionalmente essere usata per accendere il debugging
all’inizializzazione – questo argomento può apparire in ogni posto della riga di comando, poiché
il codice di inizializzazione in main( ) guarda
tutti gli argomenti. La dimostrazione è abbastanza semplice :
string(argv[i])
questo prende l’array di carattere
argv[i] e crea una string, la quale dopo può essere facilmente
confrontata con la parte di destra
di ==. Il programma di sopra cerca l’intera stringha --debug=on . Così si può anche cercare --debug=
e dopo vedere cosa c'è dopo, per fornire più opzioni. Il Volume 2 (disponibile
da www.BruceEckel.com) dedica un capitolo alla classe string
dello Standard C++.
Sebbene
un flag di debugging è una delle poche aree dove ha molto senso usare una variabile globale,
non c’è niente che dice che esso
deve essere in quel modo. Si noti che la variabile è scritta in lettere minuscole
per ricordare al lettore che non è un flag del preprocessore.
Convertire le variabili e le espressioni in stringhe
Turning variables
and expressions into strings
Quando
si scrive il codice di debugging è noioso scrivere le espressioni di
stampa consistenti in array di caratteri contenenti il nome della variabile
seguita dalla variabile. Fortunatamente, lo Standard C include l’operatore stringize ‘#’, usato
inizialmente in questo capitolo. Quando si pone una # prima di un argomento
in una macro del preprocessore, il preprocessore cambia quel argomento in
un array di caratteri. Questo, combinato con il fatto che gli array di caratteri
con nessun intervento di punteggiatura, permette di rendere una macro più
utile al fine di stampare i valori di variabili durante il debugging :
#define PR(x) cout <<
#x " = " << x <<
"\n";
se si stampa la variabile a chiamando
la macro PR(a), si avrà lo stesso effetto del codice :
cout << "a =
" << a << "\n";
questo stesso processo funziona con intere
espressioni. Il programma seguente usa una macro per creare una stenografia
che stampa l’espressione stringized
e dopo valuta l’espressione e ne
stampa il risultato :
//: C03:StringizingExpressions.cpp
#include
<iostream>
using namespace std;
#define
P(A) cout << #A << ": "
<< (A) << endl;
int main() {
int a = 1,
b = 2, c = 3;
P(a); P(b); P(c);
P(a + b);
P((c - a)/b);
} ///:~
si può vedere come una tecnica come questa
diventa presto indispensabile, specialmente se si non hanno debugger ( o si
devono usare ambienti di sviluppomultipli): si può anche inserire un #ifdef per definire P(A) come “nulla” quando si vuole rimuovere il debugging.
La macro assert( )
Nello
standard file principale <cassert> si troverà assert( ), il
quale è una utile macro di debugging. Quando si usa assert( ), si
dà un argomento che è una espressione che si “asserisce essere vera “. Il
preprocessore genera il codice che proverà l’asserzione. Se l’asserzione
non è vera, il programma si fermerà dopo aver prodotto un messaggio di errore
evidenziano quale asserzione è fallita. Ecco qui un esempio:
//: C03:Assert.cpp
// uso della macro di debugging assert()
#include <cassert> // Contiene la macro
using namespace std;
int main() {
int i = 100;
assert(i != 100); // Fallisce
} ///:~
il comando originato nello standard C, è così
disponibile nel header file assert.h.
Quando
è finito il debugging, si può rimuovere il codice generato dalla macro ponendo
la riga :
#define NDEBUG
nel programma prima dell’inclusione di <cassert>, o definendo
NDEBUG nella riga di comando del compilatore. NDEBUG è un flag usato in <cassert>
per cambiare il modo in cui il codice è generato dalle macro.
In seguito in questo libro, si vedranno alcune alternative più sofisticate di assert( ).
Indirizzi della funzione
Una
volta che una funzione viene compilata e caricata nel computer per essere
eseguita, esso occupa un pezzo di
memoria. Questa memoria, e quindi la funzione, ha un indirizzo.
Il
C non è mai stato un linguaggio per sbarrare l’entrata dove gli altri avevano paura di leggere. Si possono usare gli
indirizzi della funzione con i puntatori proprio come si possono usare gli
indirizzi delle variabili. La dichiarazione e l’uso dei puntatori a funzione appaiono un pò opachi inizialmente, ma seguono
il formato del resto del linguaggio.
Definire un puntatore a funzione
Per
definire un puntatore ad una funzione che non ha nessun argomento e nessun
valore di ritorno, si scrive:
void (*funcPtr)();
quando si cerca una definizione complessa
come questa, il modo migliore per attaccarla è iniziare nel mezzo e trovare
l'uscita. “Cominciando dal mezzo
“ significa iniziare dal nome della variabile, che
è funcPtr . “Trovare l'uscita“
significa cercare a destra per l’oggetto
più vicino ( nessuno in questo caso; la parentesi destra ci ferma bruscamente
), poi guardare a sinistra ( un puntatore denotato con l’asterisco), poi guardare a destra( una lista di argomenti
vuota indicante una funzione che non prende argomenti), poi guardare a sinistra
( void, che indica la funzione che non ha valore di ritorno). Questo
movimento a destra e a sinistra funziona con la maggio parte delle dichiarazioni.
Per
esaminare, “cominciare in mezzo” (“funcPtr è un ...”), si vada a destra (niente la - ci si fermerà dalla
parentesi destra), andare a sinistra e trovare "*" (“ … punta
a … “), andare a destra e trovare lista
di argomenti vuota (“ … la funzione che non prende argomenti …”), andare a sinistra e trovare il void (“funcPtr
è un puntatore ad una funzione che non prende argomenti e restituisce un void
“).
Ci
si può chiedere perché *funcPtr richiede le parentesi. Se non
le si usava, il compilatore avrebbe visto :
void *funcPtr();
si potrebbe dichiarare una funzione ( che restituisce un void*) piuttosto che definire una variabile.
Il compilatore esegue lo stesso processo che facciamo noi quando capisce quale
dichiarazione o definizione è supposta essere. Esso ha bisogno di queste parentesi
per “sbatterci contro “ così esso torna a sinistra
e trova la ‘*’, invece di continuare a destra e trovare una lista
di argomenti vuota
Definizioni e dichiarazioni complesse
Come
digressione, una volta che si capito come funzionano le sintassi di dichiarazione
in C e C++ si possono creare oggetti molto più complicati. Ad esempio :
//: C03:ComplicatedDefinitions.cpp
/* 1. */ void
* (*(*fp1)(int))[10];
/* 2. */ float
(*(*fp2)(int,int,float))(int);
/* 3. */ typedef double (*(*(*fp3)())[10])();
fp3
a;
/* 4. */ int (*(*f4())[10])();
int main() {} ///:~
si cammini attraverso ognuno e si usi la linea
direttiva destra-sinistra. Il numero 1 dice “fp1 è
un puntatore ad una funzione che prende un argomento intero e restituisce
un puntatore ad un array di 10 puntatori void .“
Il
numero 2 dice “fp2 è un puntatore ad una funzione che prende tre argomenti
(int, int, e float) e restituisce un puntatore a funzione
che prende un argomento intero e restituisce un float .”
Se
si stanno creando molte definizioni complicate, si potrebbe voler usare un
typedef. Il numero 3 mostra come un typedef risparmia di scrivere
ndo la descrizione complicata ogni volta. Esso dice “fp3 è un puntatore alla funzione che non prende argomenti
e restituisce un puntatore ad un array di 10 puntatori a funzione che non
prendono argomenti e restituisce double.”
Esso dopo dice “a è uno
di questi tipi fp3”. Typedef
è generalmente utile per costruire descrizioni complicate da quelle semplici.
Il
numero 4 è una dichiarazione di funzione invece di una definizione di variabile.
Esso dice “f4 è una funzione che restituisce un puntatore ad un
array di 10 puntatori a funzione che restituisce interi.”
Raramente
si avrà bisogno di dichiarazioni e definizioni complicate come queste. Comunque,
se con l’esercizio
non si sarà disturbati da espressione complicate che si potrebbero
incontrare nella vita reale.
Usare un puntatore a funzione
Una
volta definito un puntatore a funzione, si deve assegnarlo ad un indirizzo
di una funzione prima di poterlo usare. Proprio come l'indirizzo di un array
arr[10] è prodotto dal nome dell’array
senza le parentesi (arr), l’indirizzo
di una funzione func() è prodotto dal nome della funzione senza l’argomento lista (func). Si può anche usare
una sintassi più esplicita &func(). Per chiamare una funzione,
si dereferenzia il puntatore allo stesso modo in cui lo si è dichiarato (
si ricordi che il C ed il C++ provano sempre a rendere l’aspetto
delle definizioni identico al modo in cui sono usate ). Il seguente esempio
mostra come un puntatore ad una funzione è definita ed usata :
//: C03:PointerToFunction.cpp
// definire e usare un puntatore ad una
funzione
#include
<iostream>
using namespace std;
void func() {
cout << "func()
chiamata..." << endl;
}
int main() {
void (*fp)(); //
definire un puntatore a funzione
fp = func;
// Inizializzarlo
(*fp)();
// dereferenziare chiama la funzione
void (*fp2)() = func; // Definire ed inizializzare
(*fp2)();
} ///:~
dopo che il puntatore alla funzione fp
è chiamato, è assegnato all’indirizzo
della funzione func() usando fp = func (si noti che la lista
degli argomenti manca nel nome della funzione ). Il secondo caso mostra simultaneamente
la definizione e l’inizializzazione.
Array di puntatori a funzioni
Uno
dei più interessanti costrutti che si possono creare è un array di puntatori
a funzioni. Per selezionare una funzione, si indicizza l’array e si dereferenzia il puntatore. Questo supporta
il concetto di codice a tabella (table-driven code); invece
di usare i condizionali e le dichiarazioni del caso, si selezionano le funzioni
da eseguire basata su un variabile di stato ( o una combinazione di variabili
di stato). Questo tipo di approccio può essere utile se spesso si aggiungono
o si eliminano funzioni dalla tabella ( o se si vuole di creare o cambiare
così una tabella dinamicamente).
Il
seguente esempio crea alcune funzioni finte usando una macro del preprocessore,
che crea un array di puntatori a queste funzioni che usano l’inizializzazione aggregata automatica. Come si può
vedere, è facile aggiungere o rimuovere funzioni dalla tabella ( e così, funzionalmente
dal programma) cambiando poco del codice :
//: C03:FunctionTable.cpp
// usare un array di puntatori a funzioni
#include
<iostream>
using namespace std;
// una macro per definire funzioni dummy
:
#define DF(N) void
N() { \
cout << "funzione
" #N " chiamata..."
<< endl; }
DF(a);
DF(b); DF(c); DF(d); DF(e); DF(f); DF(g);
void (*func_table[])() = { a, b, c, d, e, f, g };
int main() {
while(1) {
cout << "premi un tasto da 'a' fino
a 'g' "
"oppure
q per uscire" << endl;
char c, cr;
cin.get(c);
cin.get(cr); // un secondo per CR
if ( c ==
'q' )
break;
// ... fuori dal while(1)
if ( c <
'a' || c > 'g' )
continue;
(*func_table[c
- 'a'])();
}
} ///:~
a questo punto, si potrebbe immaginare come
questa tecnica possa essere utile quando si crea qualche tipo di interprete
o programmi per processare liste.
Make: gestire compilazioni separate
Quando
si usa una compilazione separata ( spezzando il codice in un numero
di unità di traduzione), si ha bisogno in qualche modo di compilare automaticamente
ogni file e dire al linker di costruire tutti i pezzi- insieme con le librerie
appropriate ed il codice di inizio in un file eseguibile. La maggior parte
dei compilatori permettono di fare ciò con una singola instruzione della riga
di comando. Per il compilatore C++ GNU, ad esempio, si potrebbe scrivere :
g++ SourceFile1.cpp SourceFile2.cpp
il problema con questo approccio è che il
compilatore compilerà per primo ogni file individuale, nonostante che quel
file ha bisogno di essere ricostruito o no. Con molti file in un progetto,
può diventare proibitivo ricompilare tutto se è cambiato solo un singolo
file.
La
soluzione a questo problema, sviluppato in Unix disponibile dovunque in alcune
forme, è un programma chiamato make. L’utilità di make è che amministra tutti i file individuali in un
progetto seguendo le istruzioni in un file testo chiamato makefile.
Quando si editano alcuni dei file nel progetto e si scrive make, il
programma make segue le linee direttive nel makefile per confrontare
le data nei file del codice sorgente con le date dei file del target corrispondente,
e se un file del codice sorgente è più recente del suo file target,
make invoca il compilatore sul file del codice sorgente. Make
ricompila solo i file del codice sorgente che erano cambiati, ed ogni altro
file di codice sorgente che sono modificati. Usando make, non si deve
ricompilare tutti i file nel proprio progetto ogni volta che si fa un cambiamento,
ne si deve controllare che ogni cosa siacostruita propriamente. Il makefile
contiene tutti i comandi per mettere il proprio progetto insieme. Imparare
ad usare make farà risparmieremolto tempo e frustazioni. Si
scoprirà anche che make è un tipico modo in cui si installa un nuovo
software sulla macchina Linux/Unix ( sebbene questi makefile tendono
ad essere molto più complicati da quelli presenti in questo libro, e spesso
automaticamente si genererà un makefile per la propria particolare
macchina come parte del processo di istallazione ).
Poichè
make è disponibile in qualche forma virtualmente per tutti i compilatori
C++ ( anche se non lo è, si possono usare i make liberamente disponibili
con qualsiasi compilatore), sarà lo strumento usato dappertutto in questo
libro. Comunque, i venditori del compilatore hanno anche creato il loro tool
per costruire progetti. Questi tool richiedono quali file sono nel proprio
progetto e determinano tutte le relazioni tra gli stessi. Questi tool usano
qualcosa di simile ad un makefile, generalmente chiamato file progetto
(project file ) , ma l’ambiente
di programmazione mantiene questo file quindinon c’è da preoccuparsi di esso. La configurazione e l’uso dei file progetto variano da un ambiente di sviluppo
all’altro,
quindi si deve trovare una documentazione appropriata su come usarli ( sebbene
questi tool sono di solito così semplici da usare che si può apprendere giocandoci
qua e la – la mia favorita forma di studio).
I
makefile usati in questo libro dovrebbero funzionare anche se si sta
usando un tool specifico.
Make activities
Quando
si scrive make ( o qualsiasi nome abbia il proprio programma “make” ), il programma make cerca nella corrente
directory un file chiamato makefile, il quale si è creato se questo
è il proprio progetto. Questo file elenca le dipendenze tra i file del codice
sorgente. make guarda le date dei file. Se un file dipendente ha una
data più vecchia del file da cui dipende, make esegue la regola data
dopo la dipendenza.
Tutti i commenti nel makefile
iniziano con # e continuano fino alla fine della riga.
Come semplice esempio, il
makefile per un programma chiamato “hello” potrebbe contenere:
# un
commento
hello.exe:
hello.cpp
mycompiler hello.cpp
ciò dice che hello.exe(il target) dipende da hello.cpp .
Quando hello.cpp ha una data più nuova di hello.exe , make
esegue la “regola” mycompiler hello.cpp . Potrebbero esserci dipendenze e regole multiple.
Molti programmi make richiedono che tutte le regole comincino con una
etichetta. Oltre ciò, lo spazio bianco è generalmente ignorato quindi si può
formattare per la leggibilità.
Le
regole non sono ristrette ad essere chiamate al compilatore; si può chiamare
qualsiasi programma dentro make. Creando gruppi di insiemi di regole-dipendenze
interdipendenti, si possono modificare i file del proprio codice sorgente,
scrivere make ed essere certi che tutti i file interessati saranno
ricostruiti correttamente.
Le macro
Un
makefile contiene le macro ( si noti che queste sono completamente
differenti dalle macro del processore C/C++). Le macro permettono il rimpiazzo
delle stringhe. I makefile in questo libro usano una macro per chiamare
il compilatore del C++. Ad esempio :
CPP
= mycompiler
hello.exe:
hello.cpp
$(CPP) hello.cpp
= è usato per identificare CPP come
una macro, e $ e le parentesi espandono la macro. In questo caso, l’espansione significa che la chiamata della macro $(CPP) sarà sostituita con la stringa
mycompiler . Con la macro sopracitata, se si vuole cambiare in una differente
chiamata al compilatore cpp, si cambia solo la macro in :
CPP = cpp
Si possono aggiungere alla macro le flag del
compilatore, o usare le macro per aggiungere i flag del compilatore.
Le regole di suffisso
Diventa
noioso dire a make di invocare il compilatore per ogni singolo file cpp
del proprio progetto, quando si sa che è lo stesso progetto ogni volta. Poiché
make è progettato per far risparmiare tempo, esso ha anche un modo
per abbreviare le azioni, sempre che esse dipendano dai suffissi del nome
del file. Queste abbreviazioni sono chiamate regole del suffisso. Una regola
del suffisso è un modo per insegnare al make come convertire un file
con un tipo di estensione (.cpp ad esempio) in un file con un altro
tipo di estensione (.obj o
.exe). Una volta che si insegna a make le regole per produrre
un tipo di file da un altro, tutto quello che si deve fare è dire a make
quali sono le dipendenze tra i file. Quando make trova un file con
una data più recente di un file da cui dipende, esso usa una regola per creare
un nuovo file.
La regola di suffisso dice a make che non
ha bisogno di esplicitare le regole per costruire ogni cosa, ma invece può
capire come costruire le cose basate sulla estensione del file. In questo
caso esso dice “per costruire un
file che termina in exe da uno che termina con cpp, invoca il
seguente comando“. Ecco qui a cosa
assomiglia l’esempio sopracitato
:
CPP = mycompiler
.SUFFIXES: .exe .cpp
.cpp.exe:
$(CPP) $<
La direttiva
.SUFFIXES dice a make che esso dovrebbe stare attento a qualsiasi
delle seguenti estensioni del nome del file
perché essi hanno un significato speciale per questo particolare makefile.
In seguito si vede la regola di suffisso .cpp.exe, che dice “qui
è come si converte qualsiasi file con una estensione cpp come in uno
con un estensione exe “ (
quando il file cpp è più recente di quello exe). Come prima,
la macro $(CPP) è usata, ma poi si vede qualcosa di nuovo. $<. Poichè
inizia con una ‘$’ , esso è una macro, ma questa è una delle macro speciali
predefinite di make. La $< può essere usata solo nelle regole
di suffisso, e significa “ qualunque
prerequisito innesca la regola “
( qualche volta chiamata dipendente) ,che in questo caso traduce “ il file cpp che ha bisogno di essere compilato.
“
Una
volta che le regole di suffisso sono state impostate, si può semplicemente
dire, ad esempio, “make Union.exe,” e la regola di suffisso sarà immessa, anche
se non c‘è nessuna menzione di “Union” da
qualsiasi parte nel makefile.
Target di Default
Dopo
le macro e le regole di suffisso, make cerca il primo “target” in
un file, e lo costruisce, a meno che si specifica differentemente. Quindi
il seguente makefile :
CPP = mycompiler
.SUFFIXES: .exe .cpp
.cpp.exe:
$(CPP) $<
target1.exe:
target2.exe:
se si scrive solo "make", allora
target1.exe sarà costruito ( usando la regola del suffisso di default
) perché questo è il primo target che make incontra. Per costruire
target2.exe si dovrebbe dire esplicitamente ‘make
target2.exe’. Ciò diventa
noioso, così normalmente si crea un target “finto” di default che dipende da tutto il resto dei target,
come questo :
CPP = mycompiler
.SUFFIXES: .exe .cpp
.cpp.exe:
$(CPP) $<
all:
target1.exe target2.exe
qui, ‘all’
non esiste e non c’è alcun file chiamato‘all’, quindi ogni volta che si scrive make, il
programma vede ‘all’ come primo target nella lista ( e
quindi il
target di default), dopo esso vede che ‘all’
non esiste quindi deve controllare tutte le dipendenze. Quindi esso guarda
al target1.exe e (usando la regola di suffisso) guarda sia che (1)
target1.exe esiste e se (2) target1.cpp è più recente di target1.exe,
e così esegue la regola di suffisso ( se si fornisce una regola esplicita
per un obiettivo particolare, quella regola è invece usata). Dopo esso passa
al prossimo file nella lista dei target di default ( tipicamente chiamata ‘all’
per convenzione, ma si può chiamarla con qualunque nome) si può costruire
ogni eseguibile del proprio progetto semplicemente scrivendo ‘make’.
In più , si possono avere altre liste
dei target non di default che fanno altre cose - ad esempio,
si può settarla così scrivendo ‘make debug’ che ricostruisce tutti
i file che hanno all’interno il debugging.
I makefile di questo libro
Usando
il programma ExtractCode.cpp del Volume 2 di questo libro, tutti i
codici elencati in questo libro sono automaticamente estratti da lla versione
di testo ASCII di questo libro e posti in subdirectory secondo i loro capitoli.
In più, ExtractCode.cpp crea diversi makefile in ogni subdirectory
( con differenti nomi) così ci si può semplicemente spostare dentro questa
subdirectory e scrivere make -f mycompiler.makefile ( sostituendo ‘mycompiler’
con il nome
del proprio compilatore, la flag ‘-f’ dice “usa quello che segue come un makefile”). Infine, ExtractCode.cpp crea un makefile
”master” nella directory principale dove i file del libro
sono stati espansi, e questo makefile ha origine in ogni subdirectory
chiama make con l’appropriato makefile.
Questo modo in cui si può compilare tutti i codici invocando un singolo comando
make, e il processo si fermerà ogni volta che il nostro compilatore
è incapace di gestire un file particolare ( si noti che un compilatore conforme
al C++ dovrebbe essere capace di compilatore tutti i file in questo libro).
Poiché gli esempi di make variano da sistema a sistema, solo le caratteristiche
base e più comuni sono usate nei makefile generati.
Un esempio di makefile
Come
menzionato, lo strumento di estrazione del codice ExtractCode.cpp automaticamente
genera i makefile per ogni capitolo. A causa di ciò, i makefile per
ogni capitolo non saranno posti nel libro ( tutti i makefile sono confezionati
con il codice sorgente, il quale si può scaricare da www.BruceEckel.com
). Comunque, è utile vedere un esempio di un makefile. Quello che segue
è una versione ridotta di uno che era automaticamente generato per questo
capitolo dallo strumento di estrazione del libro. Si troverà più di un makefile
in ogni sotto-directory (essi hanno nomi differenti; si invoca un nome specifico
con ‘make-f’). Questo è per il C++ GNU :
CPP = g++
OFLAG = -o
.SUFFIXES : .o .cpp .c
.cpp.o :
$(CPP) $(CPPFLAGS)
-c $<
.c.o :
$(CPP) $(CPPFLAGS)
-c $<
all:
\
Return \
Declare \
Ifthen \
Guess \
Guess2
# il resto dei file per questo
capitolo non sono mostrati
Return:
Return.o
$(CPP) $(OFLAG)Return Return.o
Declare:
Declare.o
$(CPP) $(OFLAG)Declare Declare.o
Ifthen:
Ifthen.o
$(CPP) $(OFLAG)Ifthen Ifthen.o
Guess:
Guess.o
$(CPP) $(OFLAG)Guess Guess.o
Guess2:
Guess2.o
$(CPP) $(OFLAG)Guess2 Guess2.o
Return.o:
Return.cpp
Declare.o:
Declare.cpp
Ifthen.o:
Ifthen.cpp
Guess.o:
Guess.cpp
Guess2.o:
Guess2.cpp
La macro CPP è settata al nome del compilatore.
Per usare un differente compilatore, si può editare il makefile o cambiare
il valore della macro nella riga di comando, come segue :
make CPP=cpp
si noti, comunque che ExtractCode.cpp ha
uno schema automatico per costruire automaticamente i makefile per
i compilatori addizionali.
La
seconda macro OFLAG è un flag che è usato per indicare il nome del
file di output. Sebbene molti compilatori
assumono automaticamente come file
di output quello che ha lo stesso
nome di base del file in input, per altri questo non vale( come i compilatori
in Linux/Unix, che per default crea un file chiamato a.out).
Si
può vedere che ci sono due regole di suffisso qui, uno per i file cpp
e uno per i file .c ( nel caso qualsiasi codice sorgente di C abbia
bisogno di essere compilato). Il target
di default è all ed ogni riga per questo target è “continuato”
usando il segno \, fino a Guess2, il quale è l’ultimo nella lista
e quindi non ha il segno\. Ci sono molti più file in questo capitolo, ma solo
questi sono mostrati qui per ragione di brevità.
Le
regole di suffisso si prendono cura di creare i file oggetti ( con una estensione
.o) dai file cpp, ma in generale si ha bisogno di specificare
esplicitamente le regole per creare l’eseguibile, perché normalmente l’eseguibile è creato dal collegamento di molti file
oggetti ed il make non può indovinare quali questi sono. Ancora, in
questo caso (Linux/Unix) non c’è
nessuna estensione standard per gli eseguibili quindiuna regola di suffisso
non funziona per queste semplici situazioni. Dunque, si vedono tutte le regole
per la costruzione dell’eseguibile
esplicitamente specificate.
Questo
makefile prende la via assolutamente più sicura di usare le caratteristiche
minime di make ; esso usa i concettibase di make e i target
e le dipendenze, come pure le macro. Questo modo virtualmente assicura il
funzionamento come quanti più programmi make possibili. Esso
tende a produrre un makefile più grande, ma ciò non è un male poiché
è automaticamente generato da ExtractCode.cpp.
Ci
sono molte caratteristiche di make che questo libro non userà, come
pure le più nuove e più intelligenti versioni e variazioni di make
con le scorciatoie avanzate che possono far risparmiare molto tempo. La propria
documentazione locale forse descrive le ulteriori del proprio particolare
make, e si può imparare di più sul make da
Managing Projects with Make di Oram e Talbott (O’Reilly, 1993). Ancora, se il proprio fornitore di
compilatore non fornisce un make o usa un make non standard,
si può trovare il make di GNU per virtualmente ogni piattaforma esistente
cercando su Internet gli archivi di GNU
( dei quali ce ne sono molti).
Sommario
Questo
capitolo è un viaggio abbastanza intenso attraverso tutti le fondamentali
caratteristiche della sintassi del C++, la maggior parte dei quali sono ereditate
dal C e sono in comune con esso( e risultano il vanto della retro compatibilità
del C++ con il C ). Sebbene alcune caratteristiche del C++ sono state qui
introdotte, questo viaggio è per primo inteso per persone pratiche nella programmazione,
e semplicemente che hanno bisogno di un' introduzione alla sintassi base del
C e del C++. Se si è già un programmatore C, forse si è anche visto
una o due cose sul C qui che non erano familiari, oltre alle caratteristiche
del C++ che erano per la maggior parte nuove. Comunque, se questo capitolo
sembra ancora un po’ opprimente,
si dovrebbe andare al corso su CD ROM Thinking in C: Foundations for C++
and Java ( il quale contiene letture, esercizi, e soluzioni guidate ),
che è allegato al libro, e anche disponibile al sito www.BruceEckel.com
Esercizi
Soluzioni degli esercizi scelti possono essere trovati nel
documento elettronico The Thinking in C++ Annotated Solution Guide, disponibile
per una piccola retta da www.BruceEckel.com
[30]
si noti che tutte le convenzioni sembrano terminare dopo l'accordo che qualche
tipo di indentazione prende posto. La contesa
tra stili di formattazione è infinita. Vedere l’appendice A per la
descrizione dello stile del codice di questo libro.
[31]
grazie a Kris C. Matson per aver suggerito
questo esercizio sull’argomento
[32]
a meno che si prenda l’approccio veramente severo che “in C/C++ tutti gli
argomento passati sono per valore, e il ‘valore’ di un array è quello che
è prodotto da un identificatore di array: è un indirizzo.” Ciò può essere
visto come vero dal punto di vista del linguaggio assemblatore, ma io non
credo che aiuti quando si prova a lavorare con concetti di alto livello. L’addizione
dei riferimenti in C++ rende gli argomenti “
tutti i passaggi per valore” più confusionari, al punto dove io credo
è più di aiuto pensare in termini di “ passaggio di valore” contro “ passaggio
di indirizzi “.