[ Viewing Hints
] [ Exercise Solutions
] [ Volume 2 ] [ Free Newsletter
]
[ Seminars ] [ Seminars on CD ROM
] [ Consulting
]
[ Capitolo Precedente ] [ Indice Generale ] [ Indice Analitico ] [ Prossimo Capitolo ]
Il
concetto di costante (espresso dalla parola chiave const) è stato
introdotto per dare la possibilità ai programmatori di tracciare una linea di
demarcazione tra ciò che può cambiare e ciò che deve rimanere costante. Questo
strumento aumenta il grado di sicurezza e controllo in un progetto C++.
Fin dalle origini, la parola chiave const
è stata utilizzata per scopi differenti. Con il passare degli anni, il
particolare utilizzo di const in C++ ha portato ad un cambiamento di significato della parola
chiave anche per il linguaggio C. Inizialmente, tutto questo potrebbe
confondere, ma in questo capitolo si imparerà quando, come e perché utilizzare const.
Alla fine del capitolo verrà introdotta la parola chiave volatile, che è
una parente stretta di const (entrambe riguardano la modalità con la
quale un oggetto “varia”) con la quale condivide la stessa sintassi.
La prima ragione che ha portato
all’introduzione di const è stata la necessità di eliminare l’uso della
direttiva #define per la sostituzione di valori. Il suo impiego, in seguito, si è esteso
ai puntatori, agli argomenti e a tipi restituiti delle funzioni, agli oggetti e
alle funzioni membro. Ognuno di questi utilizzi differisce leggermente dagli
altri anche se, concettualmente, hanno tutti significati compatibili che
verranno trattati nelle varie sezioni di questo capitolo.
Quando si programma in C, il preprocessore
viene spesso utilizzato per creare macro e per sostituire valori. Dato che il
preprocessore effettua semplicemente una sostituzione testuale e non possiede
strumenti per controllare ciò che è stato digitato, la sostituzione di valori
effettuata in questo modo può introdurre comportamenti errati, errori che
possono essere facilmente evitati in C++ mediante l’utilizzo di valori const.
In C, l’utilizzo tipico del preprocessore
per sostituire dei valori al posto di nomi è di questo tipo:
#define BUFSIZE 100
BUFSIZE
è un nome che esiste solo prima dell’intervento del preprocessore, perciò non
occupa memoria e può essere messo in un file di header per poter fornire un
unico valore a tutti gli altri sorgenti che ne fanno utilizzo. Ai fini della
manutenzione del software, è molto importante utilizzare questo tipo di
sostituzione al post dei cosiddetti “numeri magici”. Se si utilizzano numeri
magici nel codice, chi leggerà il codice non avrà idea della provenienza di
questi numeri e di ciò che essi cosa rappresentano; inoltre, qualora si
decidesse di cambiare un valore, bisognerà modificare “a mano” tutti i sorgenti,
e non esiste nessun metodo per assicurare che nessun valore sia stato
dimenticato (o che sia stato cambiato accidentalmente un valore che doveva
rimanere invariato).
Per la maggior parte del tempo, BUFSIZE si
comporterà come una variabile ordinaria, ma non per sempre. In aggiunta, in
questo caso non si hanno informazioni sul tipo. Questa caratteristica può
introdurre errori molto difficili da trovare. Il C++ usa const per
eliminare questo tipo di problemi portando sotto l’egida del compilatore la
sostituzione dei valori. In questo caso si potrà utilizzare
const int bufsize = 100;
Si può usare bufsize ovunque il compilatore
conosca il suo valore a tempo di compilazione. Il compilatore può utilizzare bufsize
per fare quello che viene chiamato constant folding, cioè per
ridurre una espressione costante complicata in una semplificata effettuando i
conti necessari a tempo di compilazione. Questo è importante specialmente nelle
definizioni degli array:
char buf[bufsize];
Si può utilizzare const per tutti i tipi built-in
(char, int, float, e double) e le loro varianti
(così come per gli oggetti, come si vedrà più avanti in questo capitolo). A
causa degli errori subdoli che il preprocessore potrebbe introdurre, si
dovrebbe usare sempre const piuttosto che #define per la
sostituzione di valori.
Per utilizzare const al posto di #define,
si devono mettere le definizioni dei const all’interno dei file di
header, così come viene fatto per i #define. In questo modo, si può inserire
la definizione di un const in una sola posizione e utilizzarla negli
altri sorgenti includendo l’header. Il comportamento standard di const in
C++ è quello conosciuto come internal linkage, cioè un const è
visibile solo all’interno del file dove viene definito e non può essere “visto”
a tempo di link da altri file. È obbligatorio assegnare un valore ad un const
quando questo viene definito, tranne nel caso in cui venga fatta una
dichiarazione esplicita utilizzando extern:
extern const int bufsize;
Normalmente, il
compilatore C++ cerca di evitare l’allocazione di memoria per un const,
e ne conserva la definizione nella tabella dei simboli. Quando si utilizzano extern
e const insieme, però, l’allocazione viene forzata (questo accade
anche in altri casi, per esempio quando si utilizza l’indirizzo di un const).
In questo caso deve essere allocato spazio in memoria, perché utilizzare extern
è come comunicare al compilatore “usa il link esterno”, vale a dire che più
sorgenti possono far riferimento a questa grandezza, che, quindi, dovrà essere
allocata in memoria.
Nel caso ordinario, quando extern non fa
parte della definizione, l’allocazione di memoria non viene fatta. Quando const
viene utilizzato, il suo valore viene semplicemente sostituito a tempo di compilazione.
L’obiettivo di non allocare memoria per un const
non viene raggiunto anche nel caso di strutture complicate. Ogni qual volta
il compilatore deve allocare memoria, la sostituzione del valore costante a
tempo di compilazione non è possibile (visto che non v’è modo per il
compilatore di conoscere con sicurezza il valore da memorizzare – perché se
così fosse la memorizzazione stessa non sarebbe necessaria).
Proprio perché il compilatore non può impedire
l’allocazione di memoria in ogni situazione, le definizioni dei const devono
utilizzare l’internal link come default, che implica il link solo all’interno
del file in cui è presente la definizione. Se fosse altrimenti, si
otterrebbero degli error di link quando si utilizzano const complicato,
perché questo implicherebbe l’allocazione di memoria per file cpp diversi.
Il linker troverebbe la stessa definizione in diversi file oggetto, e
genererebbe errore. Grazie all’internal linkage di default, il linker non prova
a linkare le definizione dei const tra file diversi, e quindi non si
hanno collisioni. Con i tipi built-in, che sono quelli maggiormente utilizzati
nelle espressioni costanti, il compilatore può sempre effettuare il constant
folding.
L’uso di const non è limitato alla sostituzione
del #define nelle espressioni costanti. Se si inizializza una variabile
con un valore prodotto a runtime e si è sicuri che questo valore non cambierà
durante tutta la vita della variabile, è una buona abitudine dichiarare questa
grandezza come const, in maniera da ricevere un errore dal compilatore
qualora si provi accidentalmente a cambiarne il valore. Ecco un esempio:
//: C08:Safecons.cpp
// Utilizzo di const per sicurezza
#include <iostream>
using namespace std;
const int i = 100; // Costante tipica
const int j = i + 10; // Valore derivante da un’espressione costante
long address = (long)&j; // Forza l’allocazione di memoria
char buf[j + 10]; // Ancora un’espressione costante
int main() {
cout << "type a character & CR:";
const char c = cin.get(); // Non può cambiare
const char c2 = c + 'a';
cout << c2;
// ...
} ///:~
Si può notare che i è una costante a tempo
di compilazione, mentre j viene calcolata a partire da i.
Ciononostante, siccome i è una costante, il valore calcolato per j
proviene da un’espressione costante e quindi è anch’esso una costante a tempo
di compilazione. La riga successiva utilizza l’indirizzo di j e quindi
forza l’allocazione di memoria da parte del compilatore. Questo, però, non
impedisce l’utilizzo di j per determinare la dimensione di buf,
perché il compilatore sa che j è const e quindi il suo valore è
valido anche se, ad un certo punto del programma, è stata allocata memoria per
contenerlo.
Nel main(), si può osservare un altro
utilizzo di const con l’identificatore c, in questo caso il
valore non può essere conosciuto a tempo di compilazione. Questo significa che
ne verrà richiesta la memorizzazione e il compilatore non manterrà nessun
riferimento nella tabella dei simboli (questo comportamento è lo stesso che si
ha nel linguaggio C). L’inizializzazione deve essere fatta nel punto in cui
viene fatta la definizione, ed una volta fatta il valore non può essere più
cambiato. Si può vedere come c2 viene calcolato a partire da c e
come const, in questo caso, funzioni come in quelli precedenti –
un vantaggio in più rispetto all’uso di #define.
Come suggerimento pratico, se si pensa che un
valore non dovrebbe cambiare, lo si può dichiarare const. Questo non solo
protegge da variazioni inavvertite, ma permette al compilatore di generare
codice più efficiente eliminando allocazione di memoria e letture dalla memoria
stessa.
È possibile utilizzare const con gli
aggregati; in questo caso è molto probabile che il compilatore non sia
abbastanza raffinato da poter mettere un aggregato nella sua tabella dei
simboli, e quindi verrà allocata memoria. In questi casi, const ha il
significato di “porzione di memoria che non può cambiare.” Il suo valore, però,
non può essere utilizzato a tempo di compilazione, perché il compilatore non
conosce il contenuto della memoria in questa fase. Nel codice che segue, si
possono vedere delle istruzioni illegali:
//: C08:Constag.cpp
// Costanti e aggregati
const int i[] = { 1, 2, 3, 4 };
//! float f[i[3]]; // Illegale
struct S { int i, j; };
const S s[] = { { 1, 2 }, { 3, 4 } };
//! double d[s[1].j]; // Illegale
int main() {} ///:~
In una definizione di array, il compilatore deve
essere in grado di generare il codice che muove il puntatore allo stack per
inserire l’array. In entrambe le definizioni illegali proposte sopra, il
compilatore segnala errore, perché non può trovare un’espressione costante
nella definizione degli array.
Le costanti vennero introdotte nelle prime versioni
di C++ quando le specifiche del C Standard erano state appena completate.
Ciononostante il C commitee decise di includere const nel C, prendendo
il significato di “variabile ordinaria che non può cambiare”. In C, un const,
occupa sempre memoria ed il suo nome è globale. Il compilatore C non può
trattare un const come una costante a tempo di compilazione. In C, se si
scrive:
const int bufsize = 100;
char buf[bufsize];
verrà restituito un errore, anche se sembra una
cosa ragionevole da fare. Questo avviene perché siccome bufsize occupa
memoria da qualche parte, il compilatore C non può conoscerne il valore a tempo
di compilazione. In C si può anche scrivere
const int bufsize;
mentre in C++ questo non è consentito, e il compilatore
C accetta questa come una dichiarazione che indica che c’è della memoria
allocata da qualche parte. Siccome il C utilizza come default l’external
linkage per i const, questa dichiarazione ha senso. Il default C++ è
quello di utilizzare l’internal linkage per i const, quindi se si vuole
ottenere lo stesso comportamento, bisogna esplicitamente passare all’external
linkage utilizzando extern.
extern const int bufsize; // Solo dichiarazione senza inizializzazione
Questa dichiarazione è corretta anche in C.
In C++, un const non alloca necessariamente
memoria. In C un const alloca sempre memoria. L’allocazione o meno di
memoria per un const in C++ dipende dall’uso che ne viene fatto. In
generale, se un const viene utilizzato semplicemente per sostituire un nome con
un valore (come se si volesse utilizzare un #define), allora non viene
allocata memoria. Se non viene allocata memoria (questo dipende dalla
complessità del tipo di dato e dalla raffinatezza del compilatore), i valori
possono essere inseriti all’interno del codice per aumentarne l’efficienza dopo
il controllo di tipo, non prima, come avviene con #define. Se, invece,
si utilizza, un indirizzo di un const (anche non intenzionalmente,
passando il const ad una funzione come argomento passato per riferimento)
oppure si definisce come extern, la memoria verrà allocata.
In C++, un const che si trova all’esterno di
tutte le funzioni ha una visibilità estesa a tutto il file (i.e., non è
visibile fuori dal file). Il comportamento di default, quindi, comporta
l’utilizzo dell’internal linkage. Questo comportamento è molto diverso rispetto
a quanto avviene in C++ per tutti gli altri identificatori (e a ciò che avviene
per un const in C!) per i
quali il default è l’external linkage. Se, in due file differenti, si
dichiarano due const con lo stesso nome ma non extern, e non se
ne utilizza mai l’indirizzo, il compilatore C++ ideale non allocherà memoria
per i const e sostituirà semplicemente i valori nel codice. Dato che const
è implicitamente visibile a livello di file, può essere inserito nei file
di header in C++, senza conflitti durante il link.
Per forzare l’external linakage di un const,
in maniera tale da poterlo utilizzare in un altro file, si deve esplicitamente
definire come extern:
extern const int x = 1;
Si noti che, avendo fatto l’inizializzazione e
avendo utilizzato extern, si forza l’allocazione di memoria per il const
(anche se il compilatore potrebbe avere l’opzione di sostituirne il
valore). L’inizializzazione caratterizza l’istruzione precedente come una
definizione piuttosto che una dichiarazione. La dichiarazione:
extern const int x;
in C++ indica che la definizione esiste da un’altra
parte (anche in questo caso, questo non è necessariamente vero in C). Si può
capire, adesso, perché il C++ richiede che la definizione di un const ne
inizializzi anche il valore; l’inizializzazione distingue una dichiarazione da
una definizione (in C è sempre una definizione, quindi l’inizializzazione non è
necessaria). Con la extern const, il compilatore non conosce il valore
della costante e quindi non può farne la sostituzione all’interno del codice.
L’approccio del C alle costanti non è molto utile,
e se si vuole usare un valore simbolico all’interno di un’espressione costante
(valore che deve essere valutato durante la compilazione), in C si è quasi
obbligati all’utilizzo della direttiva #define.
Anche i puntatori possono essere const. Il
compilatore cercherà di prevenire l’allocazione di memoria e di fare la
sostituzione del valore anche per i puntatori const, ma, in questo caso,
questa proprietà sembra essere meno utile. La cosa importante è che il
compilatore segnalerà ogni tentativo di cambiare il valore di un puntatore const,
la qual cosa aumento il grado di sicurezza del codice.
Quando si usa const con i puntatori, si
hanno due opzioni: const può essere applicato a quello che viene puntato
dal puntatore, oppure può essere applicato all’indirizzo contenuto nel
puntatore stesso. La sintassi per questi due casi può sembrare poco chiara in
principio, ma diventa semplice dopo aver fatto un po’ di pratica.
Il trucco per comprendere la definizione di un
puntatore, valido per ogni definizione complicata, è di leggerla partendo
dall’identificatore procedendo verso l’”esterno”. Lo specificatore const viene
messo vicino alla cosa alla quale si fa riferimento. Quindi, se si vuole
impedire che l’elemento al quale si sta puntando venga cambiato, bisogna
scrivere una definizione di questo tipo:
const int* u;
Partendo dall’identificatore, si legge “u è
un puntatore, che punta ad un const int”. In questo caso non è richiesta
alcuna inizializzazione, perché u può puntare ovunque (infatti non è un const),
ed è la cosa alla quale punta che non può essere cambiata.
Ed ora arriva la parte che porta a confondersi. Si
potrebbe pensare che per creare un puntatore costante, cioè per impedire ogni
cambiamento dell’indirizzo contenuto in u, si debba semplicemente mettere const
alla destra di int, in questo modo:
int const* v;
Non è del tutto assurdo pensare che l’istruzione
precedente vada letta come “v è un puntatore const ad un int”.
Ciononostante, il modo in cui va letta è “v è un puntatore ordinario ad
un int che si comporta come const”. Anche in questo caso, quindi,
const è riferito ad int, e l’effetto è lo stesso della
definizione precedente. Il fatto che queste due definizioni coincidano porta
confusione; per prevenire ciò, probabilmente si dovrebbe utilizzare solo la
prima forma.
Per rendere const il puntatore stesso, si
deve mettere lo specificatore const alla destra del simbolo *, in questo
modo:
int d = 1;
int* const w = &d;
In questo caso si legge: “w è un puntatore, const, che punta ad un int”.
Siccome ora è il puntatore stesso ad essere const, il compilatore
richiede che questo abbia un valore iniziale che non potrà essere cambiato per
tutta la “vita” del puntatore. È permesso, però, cambiare ciò a cui si punta
scrivendo:
*w = 2;
Si può anche creare un puntatore const ad un
oggetto const usando entrambe le forme corrette:
int d = 1;
const int* const x = &d; // (1)
int const* const x2 = &d; // (2)
In questo caso ne il puntatore ne l’oggetto puntato
possono cambiare valore.
Qualcuno asserisce che la seconda forma è più
coerente, perché const viene sempre messo alla destra di ciò che
modifica. Ognuno deciderà quale delle due forme permesse risulti più chiara per
il proprio stile di programmazione.
Di seguito vengono riproposte le righe precedenti
in un file compilabile:
//: C08:ConstPointers.cpp
const int* u;
int const* v;
int d = 1;
int* const w = &d;
const int* const x = &d; // (1)
int const* const x2 = &d; // (2)
int main() {} ///:~
In questo libro si è deciso di mettere ogni
definizione di puntatore su una linea diversa, e inizializzare ogni puntatore
nel punto in cui viene definito se è possibile. Per questo motivo, la scelta di
“attaccare” il simbolo ‘*’ al tipo di dato è possibile:
int* u = &i;
come se
int* fosse un tipo a parte. Questo rende il codice più semplice da
comprendere, ma, sfortunatamente, questo non il modo reale in cui le cose
funzionano. Il simbolo ‘*’ si riferisce all’identificatore e non al tipo. Può
essere messo ovunque tra il nome del tipo e l’identificatore. Quindi si può
fare:
int *u = &i, v = 0;
per creare un int* u, come per l’istruzione
precedente, e un int v. Dato che questo potrebbe confondere il lettore,
è meglio seguire la forma utilizzata in questo libro.
Il C++ è molto particolare per quanto riguarda il
type checking, e questa particolarità si applica anche alle assegnazioni dei
puntatori. Si può assegnare un oggetto non-const ad un puntatore const, perché si
è semplicemente promesso di non cambiare qualcosa che in verità potrebbe
cambiare. Non si può, invece, assegnare l’indirizzo di un oggetto const ad
un puntatore non-const, perché si potrebbe cambiare l’oggetto attraverso
il puntatore. Si può usare il cast per forzare questo tipo di assegnazione;
questa, però, è pessima tecnica di programmazione, perché si sta violando la
proprietà di invariabilità dell’oggetto, insieme al meccanismo di sicurezza
implicito nell’uso di const. Per esempio:
//: C08:PointerAssignment.cpp
int d = 1;
const int e = 2;
int* u = &d; // OK -- d non è const
//! int* v = &e; // Illegale -- e è const
int* w = (int*)&e; // Legale, ma pericoloso
int main() {} ///:~
Anche se il C++ aiuta a prevenire gli errori, non
protegge da se stessi quando si viola esplicitamente un meccanismo di
sicurezza.
Gli array di caratteri letterali rappresentano il
caso in cui la stretta invariabilità non viene forzata. Si può scrivere:
char* cp = "howdy";
ed il compilatore accetterà questa istruzione senza
segnalazioni di errore. Questo è tecnicamente un errore, perché un array di
caratteri letterali (“howdy”
in questo caso) viene creato dal compilatore come un array di caratteri
costanti, e cp ne rappresenta l’indirizzo di partenza in memoria.
Modificare uno qualsiasi dei caratteri nell’array comporterà un errore a
runtime, anche se non tutti i compilatori si segnalano l’errore durante la
compilazione.
Quindi gli array di caratteri letterali sono, di
fatto, degli array di caratteri costanti. Ciononostante il compilatore permette
che vengano trattati come non-const, perché c’è molto codice C che
sfrutta questo comportamento. Quindi se si provano a cambiare i valori in un
array di caratteri letterali, il comportamento non è definito, anche se
probabilmente funzionerà su molte macchine.
Se si vuole modificare la stringa, basta metterla
in un array:
char cp[] = "howdy";
Dato che i compilatori spesso si comportano nello
stesso modo nei due casi, ci si dimentica di usare quest’ultima forma e spesso
si ottengono strani funzionamenti.
L’uso di const per specificare gli argomenti
delle funzioni e i valori restituiti da queste, è un altro caso dove il
concetto di costante può essere confuso. Se si stanno passando oggetti per
valore, const non ha alcun significato per il chiamante (perché in
questo caso si sta specificando che la funzione chiamante non può modificare
l’argomento che gli è stato passato). Se si restituisce per valore un oggetto
di tipo definito dall’utente come const, vuol dire che il valore
restituito non può essere modificato. Se si stanno passando oppure restituendo indirizzi,
l’utilizzo di const è un “promessa” che ciò che è contenuto a quel
determinato indirizzo non verrà cambiato.
Quando si passano degli argomenti per valore,
questi possono essere specificati come const in questo modo:
void f1(const int i) {
i++; // Illegale – errore durante la compilazione
}
ma cosa vuol dire? Si sta promettendo che il valore
originale della variabile non verrà cambiato dalla funzione f1(). Dato
che l’argomento viene passato per valore, viene fatta immediatamente una copia
della variabile originale, quindi la promessa viene implicitamente mantenuta
dalla funzione chiamata.
Ma all’interno della funzione, const ha un
altro significato: l’argomento non può essere cambiato. Quindi è uno strumento
messo a disposizione del creatore della funzione, e non ha alcun significato
per il chiamante.
Per non creare confusione in chi utilizza la
funzione, si può trasformare l’argomento in const all’interno della
funzione, piuttosto che nella lista degli argomenti. Questo può essere fatto
utilizzando un puntatore, ma con un riferimento, un argomento che verrà
trattato nel capitolo 11: in questo modo si ottiene una sintassi più
leggibile. Brevemente, un riferimento è come un puntatore costante al quale
viene applicato automaticamente l’operatore ‘*’, quindi si comporta come un
alias dell’oggetto. Per creare un riferimento, si deve utilizzare & nella
definizione. Quindi, per non creare confusione, la funzione precedente diventa:
void f2(int ic) {
const int& i = ic;
i++; // Illegale – errore durante la compilazione
}
Anche in questo caso si avrà un messaggio d’errore,
ma questa volta la costanza dell’oggetto non è esposta nell’interfaccia della
funzione; la costanza viene nascosta al chiamante avendo significato solo per
chi sviluppa la funzione.
Un comportamento simile a quello visto sopra si ha
per il valore restituito dalla funzione. Se si crea una funzione che
restituisce un valore const:
const int g();
si sta promettendo che la variabile originale
(quella all’interno della funzione) non verrà cambiata. Ma anche in questo
caso, restituendo la variabile per valore, questa viene copiata e quindi il
valore originale non potrebbe essere modificato in nessun modo attraverso il
valore restituito.
In un primo momento può sembrare che questo
comportamento faccia si che const non abbia alcun significato. Si
può vedere l’apparente perdita di
effetto che si ha restituendo un valore const in questo esempio:
//: C08:Constval.cpp
// Restituire const per valore
// no ha significato per tipi built-in
int f3() { return 1; }
const int f4() { return 1; }
int main() {
const int j = f3(); // Funziona
int k = f4(); // Ma funziona anche questo!
} ///:~
Per i tipi built-in, non cambia nulla quando si
restituisce un const per valore, quindi, in questi casi, si dovrebbe
evitare di confondere l’utente ed eliminare const.
Restituire un valore come const diventa
importante quando si lavora con tipi definiti dall’utente. Se una funzione
restituisce un oggetto per valore come const, il valore restituito dalla
funzione non può essere un lvalue (cioè, non si può comparire a sinistra di
un’assegnazione o modificato in altro modo). Per esempio:
//: C08:ConstReturnValues.cpp
// const restituito per valore
// Il risultato non può essere utilizzato come lvalue
class X {
int i;
public:
X(int ii = 0);
void modify();
};
X::X(int ii) { i = ii; }
void X::modify() { i++; }
X f5() {
return X();
}
const X f6() {
return X();
}
void f7(X& x) { // Passaggio di un riferimento non-const
x.modify();
}
int main() {
f5() = X(1); // OK – valore restituito non-const
f5().modify(); // OK
//! f7(f5()); // Genera un warning oppure un errore
// Cause un errore a tempo di compilazione:
//! f7(f5());
//! f6() = X(1);
//! f6().modify();
//! f7(f6());
} ///:~
f5( )
restituisce un oggetto X non-const, mentre f6() restituisce
un oggetto X const. Solo il valore restituito non-const può
essere utilizzato come lvalue. È
importante usare const quando si restituisce un oggetto per
valore quando se ne vuole impedire l’utilizzo come lvalue.
La ragione per cui const non ha significato
quando si restituisce per valore un tipo built-in è che il compilatore
impedisce comunque che questo venga utilizzato come lvalue (perché è sempre un
valore e non una variabile). Solo quando si restituisce per valore un oggetto
definito dall’utente l’uso di const è significativo.
La funzione f7() accetta come argomento un riferimento
non-const (i riferimenti sono un altro modo di gestire gli
indirizzi in C++; questo argomento verrà trattato nel Capitolo 11). Lo stesso
comportamento si ottiene utilizzando un puntatore non-const; la
differenza risiede solo nella sintassi. La ragione per cui in C++ non si riesce
a compilare è dovuta alla creazione di un oggetto temporaneo.
A volte, durante la valutazione di un’espressione,
il compilatore deve creare degli oggetti temporanei. Questi oggetti come
tutti gli altri: hanno bisogno di spazio in memoria e devono essere costruiti e
distrutti. La differenza è che non si vedono – è il compilatore che decide
quando servono e quali debbano essere le loro proprietà. Gli oggetti temporanei
hanno la caratteristica di essere creati automaticamente come const.
Questo viene fatto perché tipicamente non si vuole permettere la modifica di un
oggetto temporaneo; forzare il cambiamento di un oggetto temporaneo rappresenta
quasi sempre un errore. Rendendo automaticamente tutti gli oggetti temporanei const,
il compilatore genera un avviso quando viene commesso questo errore.
Nell’esempio precedente, f5() restituisce un
oggetto X non-const. Ma nell’espressione:
f7(f5());
il compilatore deve creare un oggetto temporaneo
per conservare il valore restituito da f5(), in modo da poterlo passare
a f7(). Questo andrebbe bene se f7() accettasse l’argomento per
valore; in questo caso l’oggetto temporaneo verrebbe copiato all’interno di f7()
e le modifiche fatte sulla copia non interesserebbero l’X temporaneo. f7(),
invece, accetta un argomento passato come riferimento, vale a dire che
in questo esempio gli viene passato l’indirizzo dell’oggetto temporaneo. Dato
che f7() non accetta il suo argomento come riferimento const, può
modificare l’oggetto temporaneo. Il compilatore, però, sa che l’oggetto
temporaneo scomparirà appena la valutazione dell’espressione sarà terminata,
quindi ogni modifica fatta all’oggetto X temporaneo verrà persa. Creando
tutti gli oggetti temporanei automaticamente come const, questa
situazione viene segnalata da un messaggio durante la compilazione, evitando
quella che potrebbe essere una ricerca d’errore molto difficile.
Si notino, ora, le seguenti espressioni corrette:
f5() = X(1);
f5().modify();
Sebbene queste superino l’ispezione del
compilatore, creano comunque problemi. f5() restituisce un oggetto X e
il compilatore, per eseguire le due espressioni, deve creare un oggetto
temporaneo e conservare il valore restituito. In entrambe le espressioni,
quindi, l’oggetto temporaneo viene modificato, ed una volta che l’ultima riga è
stata eseguita, viene cancellato. Il risultato è che le modifiche vengono
perse, quindi questo codice contiene probabilmente degli errori – ma il
compilatore non genera ne errori ne warning. Ambiguità di questo tipo sono
abbastanza semplici da individuare; quando le cose sono più complesse gli
errori potrebbero annidarsi in queste falle del compilatore.
Il modo in cui la costanza degli oggetti viene
preservata verrà illustrato più avanti in questo capitolo.
Se si passa o restituisce un indirizzo (che sia un
puntatore oppure un riferimento), il programmatore utente può modificare il
valore originale dell’oggetto puntato. Se si crea il puntatore o il riferimento
come const si impedisce che questo avvenga, la qual cosa potrebbe far
risparmiare molte preoccupazioni. Di fatto, ogni volta che si passa un
indirizzo ad una funzione andrebbe fatto utilizzando const, qualora ciò
fosse possibile. In caso contrario, si sta escludendo la possibilità di
utilizzare la funzione con qualsiasi cosa sia const.
La scelta di restituire come const un
puntatore piuttosto che un riferimento dipende da cosa si vuole che il
programmatore utente possa fare con il valore restituito. Di seguito è riportato
un esempio che mostra l’uso di puntatori const come argomenti di
funzioni e valori restituiti:
//: C08:ConstPointer.cpp
// Puntatori costanti come argomenti/valori restituiti
void t(int*) {}
void u(const int* cip) {
//! *cip = 2; // Illegale – valore modificato
int i = *cip; // OK – copia il valore
//! int* ip2 = cip; // Illegale: non-const
}
const char* v() {
// Restituisce l'indirizzo di un array statico di caratteri:
return "result of function v()";
}
const int* const w() {
static int i;
return &i;
}
int main() {
int x = 0;
int* ip = &x;
const int* cip = &x;
t(ip); // OK
//! t(cip); // Non OK
u(ip); // OK
u(cip); // OK
//! char* cp = v(); // Non OK
const char* ccp = v(); // OK
//! int* ip2 = w(); // Non OK
const int* const ccip = w(); // OK
const int* cip2 = w(); // OK
//! *w() = 1; // Non OK
} ///:~
La funzione t() accetta un puntatore non-const
come argomento, mentre u() accetta un puntatore const. All’interno
della funzione u() non è consentito modificare la destinazione di un
puntatore const, mentre è consentito copiare l’informazione puntata in
una variabile non-const. Il compilatore impedisce anche di creare un
puntatore non-const che utilizzi l’indirizzo memorizzato dal puntatore const.
Le funzioni v( ) e w( ) controllano
la semantica del valore restituito. v() restituisce un const char*
creato attraverso un array di caratteri letterali. Questa istruzione, di fatto,
genera l’indirizzo dell’array di caratteri letterali, dopo averlo creato e
memorizzato nella memoria statica. Come visto precedentemente, questo array di
caratteri è tecnicamente una costante, quindi è corretto utilizzarlo come
valore restituito da v().
Il valore restituito da w() richiede che entrambi
il puntatore e l’elemento puntato siano dei const. Come per v(),
il valore restituito da w() è valido solo perché è static. È
sbagliato restituire puntatori a variabili allocate nello stack, perché questi
non saranno più validi dopo l’uscita dalla funzione e la “pulizia” dello stack.
(Un altro puntatore che può essere restituito è l’indirizzo di una zona di
memoria allocata nell’heap, che rimane valido anche dopo l’uscita dalla
funzione.)
Nel main(), le funzioni vengono testate con
argomenti diversi. Si può vedere come t() accetti un puntatore non-const,
mentre quando si prova a passare come argomento un puntatore ad un const,
dato che non c’è nessuna sicurezza che t() lasci l’oggetto puntato
invariato, il compilatore restituirà un messaggio d’errore. L’argomento di u()
è un puntatore const, quindi u() accetterà entrambi i
tipi di puntatore. Una funzione che accetti un puntatore const, quindi,
è più generale rispetto ad una che accetti un semplice puntatore.
Prevedibilmente, il valore restituito da v() può
essere assegnato solo ad un puntatore a const. Ci si dovrebbe anche
aspettare che il compilatore rifiuti di assegnare il valore restituito da w()
ad un puntatore non-const, mentre accetti di utilizzare un const
int* const. Può sorprendere il fatto che venga ritenuto valido anche un const
int*, anche se questo non corrisponde esattamente al tipo restituito. Anche
in questo caso, siccome il valore (cioè l’indirizzo contenuto nel puntatore)
viene copiato, la promessa di lasciare invariato il valore originale viene
automaticamente rispettata. Il secondo const nell’espressione const
int* const, in questo caso, è significativo solo quando si
cerca di usare questo valore come lvalue, nel qual caso il compilatore si
opporrà.
In C è molto comune passare argomenti per valore, e
quando si vuole passare un indirizzo non ci sono alternative all’uso del
puntatore[43]. In C++
viene utilizzato un altro approccio. Quando si vuole passare un argomento lo si
fa per riferimento, e in particolare utilizzando un riferimento const.
Per il programmatore utente, la sintassi apparirà identica ad un passaggio per
valore, non dovendo usare la sintassi confusa dei puntatori – in questo modo
non bisogna far nessun ragionamento che riguardi i puntatori. Per il creatore
della funzione, il passaggio di un indirizzo e praticamente sempre più
conveniente rispetto al passaggio di un intero oggetto, e se viene passato come
riferimenti const, la funzione scritta non potrà cambiare l’oggetto a
cui si fa riferimento, quindi dal punto di vista dell’utente è come se il
parametro venga passato per valore (ma in maniera più efficiente).
Sfruttando la sintassi dei riferimenti (dal punto
di vista del chiamante è come se si trattasse di un passaggio per valore) è
possibile passare un oggetto temporaneo ad una funzione che accetta un
riferimento const, mentre non è possibile fare lo stesso per una
funzione che accetti un puntatore , perché in questo caso l’indirizzo dovrebbe
essere utilizzato in modo esplicito. Passare un parametro per riferimento,
quindi, da luogo ad una comportamento che non si può ottenere in C: si può
passare ad una funzione l’indirizzo di un oggetto temporaneo, che è
sempre const. Questo è il motivo per cui, per passare ad una funzione
oggetti temporanei per riferimento, l’argomento deve essere un riferimento const.
L’esempio seguente ne è una dimostrazione:
//: C08:ConstTemporary.cpp
// Gli oggetti temporanei sono const
class X {};
X f() { return X(); } // Restituito per valore
void g1(X&) {} // Passato per riferimento non-const
void g2(const X&) {} // Passato per riferimento const
int main() {
// Errore: temporaneo const creato da f():
//! g1(f());
// OK: g2 accetta un riferimento const:
g2(f());
} ///:~
f() restituisce
un oggetto della classe X per valore. Questo significa che,
quando si passa immediatamente il valore restituito da f() ad un'altra
funzione, come nelle chiamate a g1( ) e g2( ), viene
creato un oggetto temporaneo const. La chiamata all’interno di g1() è
un errore, perché g1() non accetta un riferimento const, mentre
nel caso di g2() la chiamata è corretta.
In questa sezione viene mostrato come utilizzare const
con le classi. Si potrebbe voler creare un const locale all’interno
di una classe per poterlo utilizzare nella valutazione di espressioni
constanti, le quali verrebbero valutate a tempo di compilazione. In ogni caso,
il significato di const all’interno delle classi può essere diverso,
quindi si devono comprendere le varie opzioni che ci sono per creare membri const
di una classe.
Si può anche definire un intero oggetto come const
(e, come è stato visto, il compilatore crea sempre gli oggetti temporanei
come const). Preservare la costanza di un oggetto, però, è più
difficile. Il compilatore può assicurare la costanza di un tipo built-in, ma
non può controllare una classe nel suo complesso. Per garantire la costanza di
un oggetto, vengono introdotte le funzioni membro const: solo una
funzione membro const può essere chiamata da un oggetto const.
Si potrebbe voler utilizzare const per
valutare espressioni costanti all’interno delle classi. L’esempio tipico è
quando si crea un array all’interno di una classe e si vuole utilizzare un const
al posto di un #define per stabilire la dimensione dell’array e per
utilizzarlo nelle operazioni che coinvolgono l’array stesso. Si vuole tenere
nascosto l’array all’interno della classe, così se si usa un nome come size,
per esempio, si potrebbe utilizzare lo stesso nome in un’altra classe senza
avere conflitti. Il preprocessore tratta tutti i #define come globali a
partire dal punto in cui sono stati definiti, quindi non possono essere utilizzati
per raggiungere lo stesso scopo.
Si potrebbe pensare che la scelta più logica dove
mettere un const sia all’interno della classe. Questa scelta, però, non
produce il risultato desiderato. All’interno di una classe, const riassume,
anche se parzialmente, il significato che ha in C: permette di allocare memoria
all’interno di un oggetto e rappresenta un valore che, una volta inizializzato,
non può più essere cambiato. L’uso di const all’interno di una classe
significa “Questo è costante per tutta la vita dell’oggetto.” Ogni oggetto,
però, può avere un valore diverso per questa costante.
Questo è il motivo per cui, quando si crea un const
ordinario (non-static) all’interno di una classe, non gli si può
assegnare un valore iniziale. L’inizializzazione deve avvenire sicuramente nel
costruttore, ma in un punto particolare. Dato che un const deve essere
inizializzato nel punto in cui viene creato, all’interno del corpo del
costruttore il const deve già essere stato inizializzato. In caso
contrario si avrebbe la libertà di effettuare l’inizializzazione in punto
qualsiasi del costruttore, il che significa che il const potrebbe essere
non inizializzato per un periodo. Inoltre, non ci sarebbe nessuno che
impedirebbe di cambiare il valore del const in vari punti del
costruttore.
Il punto speciale in cui effettuare
l’inizializzazione viene chiamato lista di inizializzazione del costruttore,
ed è stato sviluppato originalmente per essere utilizzato con l’ereditarietà
(questo argomento è trattato nel Capitolo 14). La lista di inizializzazione del
costruttore – che, come il nome indica, è presente solo nella definizione del
costruttore – è una lista di “chiamate a costruttori” che viene messa dopo la
lista degli argomenti della funzione separata da un “:”, e prima della
parentesi che indica l’inizio del corpo del costruttore. Questo per ricordare
che l’inizializzazione nella lista avviene prima che venga eseguito il codice
del costruttore principale. È questo il punto dove inizializzare tutti i const.
L’uso corretto di const all’interno di una classe è mostrato di seguito:
//: C08:ConstInitialization.cpp
// Inizializzare const all’interno di una classe
#include <iostream>
using namespace std;
class Fred {
const int size;
public:
Fred(int sz);
void print();
};
Fred::Fred(int sz) : size(sz) {}
void Fred::print() { cout << size << endl; }
int main() {
Fred a(1), b(2), c(3);
a.print(), b.print(), c.print();
} ///:~
La forma della lista di inizializzazione del
costruttore vista sopra può confondere,in un primo momento, perché non si è
abituati a trattare un tipo built-in come se avesse un costruttore.
Durante lo sviluppo del linguaggio, quando si
aumentò lo sforzo fatto affinché i
tipi definiti dall’utente si comportassero come dei tipi built-in, si notò che
poteva essere utile anche che i tipi built-in si comportassero come tipi
definiti dall’utente. Per questo motivo, nella lista di inizializzazione del
costruttore, si può trattare un tipo built-in come se avesse un costruttore, in
questo modo:
//: C08:BuiltInTypeConstructors.cpp
#include <iostream>
using namespace std;
class B {
int i;
public:
B(int ii);
void print();
};
B::B(int ii) : i(ii) {}
void B::print() { cout << i << endl; }
int main() {
B a(1), b(2);
float pi(3.14159);
a.print(); b.print();
cout << pi << endl;
} ///:~
Questa caratteristica diventa essenziale quando si
devono inizializzare membri const, perché questi devono essere
inizializzati prima del corpo del costruttore.
Per generalizzare il caso, quindi, ha senso estendere
il concetto di “costruttore” anche ai tipi built-in (nel qual caso equivale ad
una semplice assegnazione) ed è questo il motivo per cui, nel codice
precedente, la definizione pi(3.14159) è corretta.
Spesso è utile incapsulare un tipo built-in
all’interno di una classe per garantirne l’inizializzazione attraverso il
costruttore. Come esempio, di seguito è riportata la classe Integer:
//: C08:EncapsulatingTypes.cpp
#include <iostream>
using namespace std;
class Integer {
int i;
public:
Integer(int ii = 0);
void print();
};
Integer::Integer(int ii) : i(ii) {}
void Integer::print() { cout << i << ' '; }
int main() {
Integer i[100];
for(int j = 0; j < 100; j++)
i[j].print();
} ///:~
Gli array di Integer nel main( )
vengono inizializzati automaticamente a zero. Questa modo di effettuare
l’inizializzazione non è necessariamente meno efficiente di un ciclo for oppure
di un memset(). Molti compilatori riescono ad ottimizzare questo codice ottenendo
un eseguibile molto veloce.
L’uso precedente di const è interessante ed utile
in alcuni casi, ma non risolve il problema originale, vale a dire: “come si può
ottenere una costante a tempo di compilazione all’interno di una classe?” La
risposta richiede l’uso di un’ulteriore parola chiave, la quale verrà
presentata in maniera completa nel Capitolo 10: static. La parola chiave
static, in questo caso, prende il seguente significato: “questa è
l’unica istanza di questo membro, a prescindere dal numero di oggetti creati
per questa classe,” che è proprio ciò che è necessario in questo caso: un
membro della classe che sia costante, e che non possa cambiare da un oggetto ad
un altro della stessa classe. Un static const di un tipo built-in,
quindi, può essere trattato come una costante a tempo di compilazione.
Uno static const, quando viene utilizzato
all’interno di una classe, si comporta in maniera strana: deve essere
inizializzato nel punto in cui viene definito. Questo deve essere fatto solo
per un static const; la stessa tecnica non funzionerebbe con un altro
elemento, perché tutti gli altri membri devono essere inizializzati nel
costruttore, oppure in altre funzioni membro.
L’esempio seguente mostra la creazione e l’utilizzo
di un static const chiamato size all’interno di una classe che
rappresenta uno stack di puntatori a stringa[44]:
//: C08:StringStack.cpp
// Utilizzo di static const per creare una
// costante a tempo di compilazione all’interno di una classe
#include <string>
#include <iostream>
using namespace std;
class StringStack {
static const int size = 100;
const string* stack[size];
int index;
public:
StringStack();
void push(const string* s);
const string* pop();
};
StringStack::StringStack() : index(0) {
memset(stack, 0, size * sizeof(string*));
}
void StringStack::push(const string* s) {
if(index < size)
stack[index++] = s;
}
const string* StringStack::pop() {
if(index > 0) {
const string* rv = stack[--index];
stack[index] = 0;
return rv;
}
return 0;
}
string iceCream[] = {
"pralines & cream",
"fudge ripple",
"jamocha almond fudge",
"wild mountain blackberry",
"raspberry sorbet",
"lemon swirl",
"rocky road",
"deep chocolate fudge"
};
const int iCsz =
sizeof iceCream / sizeof *iceCream;
int main() {
StringStack ss;
for(int i = 0; i < iCsz; i++)
ss.push(&iceCream[i]);
const string* cp;
while((cp = ss.pop()) != 0)
cout << *cp << endl;
} ///:~
Dato che size viene utilizzato per
determinare la dimensione dell’array stack, di fatto è una costante a
tempo di compilazione, ma viene nascosta all’interno della classe.
Si noti che: push( ) accetta un const
string* come argomento, pop( ) restituisce un const string*,
e StringStack contiene const string*. Se questo non fosse vero,
non si potrebbe utilizzare un StringStack per contenere i puntatori di iceCream.
Inoltre, in questo modo si impedisce che gli oggetti contenuti in StringStack
vengano modificati. Ovviamente non tutti i contenitori vengono progettati
con queste restrizioni.
Nelle vecchie versioni del C++, non era permesso
l’utilizzo di static const all’interno delle classi. Questo significava
che const non poteva essere utilizzato per valutare un’espressione
costante all’interno delle classi. Ciononostante, si voleva ottenere questo comportamento
e una soluzione tipica (solitamente chiamata “enum hack”) era quella di utilizzare
un enum senza etichetta e senza istanze. Tutti i valori di un tipo enum
devono essere stabiliti a tempo di compilazione, hanno validità solo
all’interno della classe, e sono disponibili per valutare espressioni costanti.
Quindi, si può comunemente incontrare:
//: C08:EnumHack.cpp
#include <iostream>
using namespace std;
class Bunch {
enum { size = 1000 };
int i[size];
};
int main() {
cout << "sizeof(Bunch) = " << sizeof(Bunch)
<< ", sizeof(i[1000]) = "
<< sizeof(int[1000]) << endl;
} ///:~
In questo caso l’uso di enum non occupa
memoria nell’oggetto, e i valori enumerati vengono tutti valutati a tempo di
compilazione. I valori possono essere esplicitati anche in questo modo:
enum { one = 1, two = 2, three };
Con i tipi enum interi, il compilatore
continuerà la numerazione a partire dall’ultimo valore, quindi three varrà
3.
Nell’esempio StringStack.cpp precedente, la
linea:
static const int size = 100;
diverrebbe:
enum { size = 100 };
anche se si potrà incontrare spesso nel codice la
tecnica che utilizza enum nel codice, static const è lo strumento
previsto dal linguaggio per risolvere questo problema. Non c’è nessun obbligo
che impone l’uso di static const al posto dell’”enum hack”, ed in
questo libro viene utilizzato l’”enum hack”, perché, nel momento in cui
il libro è stato scritto, questa tecnica è supportata da un numero maggiore di
compilatori.
Le funzioni membro di una classe possono essere const.
Cosa vuol dire? Per capirlo, bisogna aver assimilato bene il concetto di
oggetti const.
Un oggetto const è definito allo stesso modo
sia per un tipo definito dall’utente che per un tipo built-in. Per esempio:
const int i = 1;
const blob b(2);
In questo caso, b è un oggetto const di
tipo blob. Il suo costruttore viene chiamato con un argomento pari a
due. Affinché il compilatore possa forzare la costanza, deve assicurare che
nessun dato membro dell’oggetto venga cambiato durante la vita dell’oggetto
stesso. Può assicurare facilmente che nessun dato pubblico venga modificato, ma
come può conoscere quale funzione membro cambierà i dati membro e quale,
invece, sarà “sicura” per un oggetto const?
Se si dichiara una funzione membro const, si
sta dicendo al compilatore che la funzione può essere chiamata per un oggetto const.
Una funzione membro che non viene dichiarata specificatamente const,
viene trattata come una che potenzialmente potrebbe modificare i dati membro di
un oggetto, e il compilatore non permetterà che venga utilizzata con oggetti const.
Richiedere che una funzione membro sia const non garantisce che questa si
comporterà nel modo esatto, quindi il compilatore obbliga a ripetere la
specificazione const anche quando la funzione viene definita. (const diventa
un tratto caratteristico della funzione, e sia il compilatore che il linker
effettueranno dei controlli sulla costanza.) La costanza di un funzione viene
forzata durante le definizione generando un messaggio d’errore quando si prova
a cambiare un membro qualsiasi dell’oggetto, oppure quando si chiama un
funzione membro non-const. Questo è il modo in cui viene garantito,
all’interno della definizione, che ogni funzione dichiarata come const si
comporti nel modo corretto.
Per comprendere qual è la sintassi da utilizzare
per dichiarare una funzione membro const, si noti che la dichiarazione
con const significa che il valore restituito è const, quindi
questo non è il modo per ottenere il risultato desiderato. Lo specificatore const
va messo dopo la lista degli argomenti. Per esempio,
//: C08:ConstMember.cpp
class X {
int i;
public:
X(int ii);
int f() const;
};
X::X(int ii) : i(ii) {}
int X::f() const { return i; }
int main() {
X x1(10);
const X x2(20);
x1.f();
x2.f();
} ///:~
Si noti che la parola chiave const deve
essere ripetuta nella definizione altrimenti il compilatore la considererà come
una funzione differente. Dato che f() è una funzione membro const,
se si cercherà di cambiare i oppure si chiamerà una funzione non-const,
il compilatore segnalerà errore.
La chiamata ad una funzione membro const è
sicura sia utilizzando oggetti const che oggetti non-const. Può
essere considerata, quindi, come la forma più generale di funzione membro (ed è
una sfortuna che questo non sia il default per una funzione membro). Ogni
funzione che non modifichi i dati membro dovrebbe essere dichiarata come const,
in modo tale da poter essere utilizzata con oggetti const.
Di seguito vengono messe a confronto funzioni
membro const e non-const:
//: C08:Quoter.cpp
// Selezione di frasi casuali
#include <iostream>
#include <cstdlib> // Generatore di numeri casuali
#include <ctime> // Utile per fornire il seme al generatore di numeri casuali
using namespace std;
class Quoter {
int lastquote;
public:
Quoter();
int lastQuote() const;
const char* quote();
};
Quoter::Quoter(){
lastquote = -1;
srand(time(0)); // Generatore casuale del seme
}
int Quoter::lastQuote() const {
return lastquote;
}
const char* Quoter::quote() {
static const char* quotes[] = {
"Are we having fun yet?",
"Doctors always know best",
"Is it ... Atomic?",
"Fear is obscene",
"There is no scientific evidence "
"to support the idea "
"that life is serious",
"Things that make us happy, make us wise",
};
const int qsize = sizeof quotes/sizeof *quotes;
int qnum = rand() % qsize;
while(lastquote >= 0 && qnum == lastquote)
qnum = rand() % qsize;
return quotes[lastquote = qnum];
}
int main() {
Quoter q;
const Quoter cq;
cq.lastQuote(); // OK
//! cq.quote(); // Non OK; funzione non const
for(int i = 0; i < 20; i++)
cout << q.quote() << endl;
} ///:~
Ne il costruttore ne il distruttore possono essere
funzioni membro const, perché virtualmente effettuano sempre delle
modifiche sull’oggetto durante l’inizializzazione ed il clean-up. Anche la
funzione membro quote() non può essere const, perché modifica il
dato membro lastquote (si confronti l’istruzione return). lastQuote(),
invece, non effettua modifiche, quindi può essere const e può essere
utilizzata in modo sicuro per l’oggetto const cq.
Cosa bisognerebbe fare se si volesse creare una
funzione membro const, ma si volesse anche cambiare qualche dato
all’interno dell’oggetto? Questa è quella che, a volte, viene chiamata
differenza tra un const bitwise ed un const logico (o const memberwise).
In un const bitwise ogni bit dell’oggetto è costante, quindi l’immagine
bit a bit dell’oggetto non cambierà mai. In un const logico, l’intero
oggetto è concettualmente costante, e si possono tollerare cambiamenti tra
membri (cioè fatti da funzioni membro su dati membro dello stesso oggetto). Se
si comunica al compilatore che un oggetto è const, il compilatore
garantirà la costanza nell’accezione di bitwise. Per realizzare la costanza
logica, esistono due tecniche per cambiare un dato membro all’interno di una
funzione membro const.
Il primo approccio è quello storico e consiste nel “castare
via” la costanza. Questo viene fatto in modo particolare. Si utilizza this
(la parola chiave che restituisce l’indirizzo dell’oggetto corrente) e lo
si “casta” ad un puntatore al tipo di oggetto considerato. Potrebbe sembrare
che this sia già di questo tipo. All’interno di una funzione
membro const, però, this è un puntatore const: effettuando
il cast ad un puntatore ordinario, si può rimuovere la costanza. Ecco un
esempio:
//: C08:Castaway.cpp
// "Castare via" la costanza
class Y {
int i;
public:
Y();
void f() const;
};
Y::Y() { i = 0; }
void Y::f() const {
//! i++; // Errore – funzione membro const
((Y*)this)->i++; // OK: costanza “castata via”
// Migliore: utilizzo della sintassi esplicita di cast del C++:
(const_cast<Y*>(this))->i++;
}
int main() {
const Y yy;
yy.f(); // Effettua un cambiamento!
} ///:~
Sebbene questa tecnica funzioni e venga utilizzata all’interno
di codice corretto, non rappresenta la soluzione migliore. Il problema è che la
perdita di costanza è nascosta nella definizione della funzione membro,
l’interfaccia della classe non mostrerà esplicitamente che l’oggetto viene
modificato, quindi l’unico modo per venire a conoscenza di questo comportamento
è accedere al codice sorgente (e sospettare che la costanza sia stata “castata
via”). Per esporre all’esterno queste informazioni, si dovrebbe utilizzare la
parola chiave mutable nella dichiarazione della classe, per specificare
che un particolare dato membro potrebbe cambiare all’interno di un oggetto const:
//: C08:Mutable.cpp
// La parola chiave "mutable"
class Z {
int i;
mutable int j;
public:
Z();
void f() const;
};
Z::Z() : i(0), j(0) {}
void Z::f() const {
//! i++; // Errore – funzione membro const
j++; // OK: mutable
}
int main() {
const Z zz;
zz.f(); // Effettua un cambiamento!
} ///:~
In questo modo, l’utilizzatore di una classe può capire
quali membri possono essere modificati in una funzione membro const guardando
la dichiarazione della classe.
Se un oggetto è definito come const, si candida
ad essere memorizzato nella memoria a sola lettura (ROM, da cui ROMability);
spesso questa considerazione è molto importante nella programmazione di sistemi
embedded. Creare un oggetto const non è sufficiente – le caratteristiche
necessarie per la ROMability sono più strette. L’oggetto deve essere un const
bitwise, e non un const logico. Constatare se un oggetto è un const
logico può essere fatto facilmente utilizzando la parola chiave mutable;
altrimenti il compilatore probabilmente sarà in grado di determinare il tipo di
const, controllando se la costanza viene “castata via”
all’interno di una funzione membro. Bisogna anche considerare che:
L’effetto di un’operazione di scrittura su una
qualsiasi parte di un oggetto const ROMable non è definito. Sebbene un
oggetto creato appositamente possa essere memorizzato nella ROM, nessun oggetto
richiede di essere memorizzato in questa parte della memoria.
La sintassi di volatile è identica a quella
utilizzata per const, ma il significato di volatile è: “Questo
dato potrebbe cambiare in maniera non controllata dal compilatore.” In qualche
modo è l’”ambiente” esterno che cambia il dato (per esempio attraverso
multitasking, multithreading o interrupt), e volatile comunica al
compilatore di non fare nessuna assunzione sul dato, specialmente durante
l’ottimizzazione.
Se il compilatore ha spostato un dato in un
registro per leggerlo e non ha mai modificato il valore nel registro, quando
viene richiesto lo stesso dato verrebbe utilizzato il valore nel registro senza
accedere di nuovo alla memoria. Se il dato è volatile, il compilatore
non può fare questa supposizione perché il dato potrebbe essere stato cambiato da
un altro processo; quindi dovrà rileggere il dato piuttosto che ottimizzare il
codice e rimuovere quella che, normalmente, sarebbe un lettura ridondante.
Gli oggetti volatile si creano utilizzando
la stessa sintassi per la creazione di oggetti const. Si possono anche
creare oggetti const volatile, i quali non possono essere cambiati da un
programmatore che utilizza gli oggetti, ma vengono cambiati da un agente
esterno. L’esempio riportato di seguito potrebbe rappresentare una classe
associata con dell’hardware per la comunicazione:
//: C08:Volatile.cpp
// La parola chiave volatile
class Comm {
const volatile unsigned char byte;
volatile unsigned char flag;
enum { bufsize = 100 };
unsigned char buf[bufsize];
int index;
public:
Comm();
void isr() volatile;
char read(int index) const;
};
Comm::Comm() : index(0), byte(0), flag(0) {}
// è solo una demo; non funziona
// come interrupt service routine:
void Comm::isr() volatile {
flag = 0;
buf[index++] = byte;
// Ritorna all’inizio del buffer:
if(index >= bufsize) index = 0;
}
char Comm::read(int index) const {
if(index < 0 || index >= bufsize)
return 0;
return buf[index];
}
int main() {
volatile Comm Port;
Port.isr(); // OK
//! Port.read(0); // Errore, read() non è volatile
} ///:~
Come per const, si può utilizzare volatile
per dati membro, funzioni membro, e oggetti. Per oggetti volatile possono
essere chiamate solo funzioni membro volatile.
La ragione per la quale isr() non può essere
utilizzata come interrupt service routine è che all’interno di una funzione
membro, deve essere passato l’indirizzo all’oggetto corrente (this), e
generalmente un ISR non accetta nessun argomento. Per risolvere questo
problema, si può creare isr() come funzione membro static: questo
argomento verrà trattato nel Capitolo 10.
La sintassi di volatile è identica a quella
di const, per questo motivo le due parole chiave vengono presentate
insieme. Si fa riferimento ad entrambe parlando di qualificatori c-v.
La parola chiave const consente di definire
oggetti, argomenti di funzione, valori restituiti e funzioni membro come
costanti, ed eliminare l’utilizzo del preprocessore per la sostituzione di
valori senza perdere nessuno dei vantaggi derivanti dal suo uso. In questo modo
viene fornito un altro strumento per la sicurezza ed il controllo del codice.
L’utilizzo della cosiddetta const correctness (cioè dell’uso di const
ovunque sia possibile) può essere di grande aiuto nello sviluppo dei
progetti.
Sebbene si possa ignorare l’uso di const e
continuare ad utilizzare le tecniche di programmazione C, questo strumento viene
messo a disposizione per aiutare il programmatore. Dal Capitolo 11 in avanti si
in inizierà a fare un grosso uso dei riferimenti, e si potrà constatare ancora
di più quanto sia critico l’uso di const con gli argomenti delle
funzioni.
Le soluzioni agli esercizi presentati
si trovano nel documento elettronico The Thinking in C++ Annotated Solution
Guide, disponibile con un piccolo contributo all’indirizzo
www.BruceEckel.com.
[43] Qualcuno afferma che, in C, tutto viene passato per valore, dato che anche quando si passa un puntatore ne viene fatta una copia (quindi si sta passando il puntatore per valore). Anche se questo è corretto, penso che, in questo momento, potrebbe confondere le idee.
[44] Al momento della pubblicazione del libro non tutti i compilatori supportano questa funzionalità.