MindView Inc.

[ Viewing Hints ] [ Exercise Solutions ] [ Volume 2 ] [ Free Newsletter ]
[
Seminars ] [ Seminars on CD ROM ] [ Consulting ]

Thinking in C++, 2nd ed. Volume 1

©2000 by Bruce Eckel

[ Capitolo Precedente ] [ Indice Generale ] [ Indice Analitico ] [ Prossimo Capitolo ]

 

trad. italiana e adattamento a cura di Gianmaria De Tommasi

8: Costanti

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.

Sostituzione di valori

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.

const nei file di header

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.

Const e sicurezza

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.

Aggregati

È 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.

Differenze con il C

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.

Puntatori

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.

Puntare ad un const

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.

Puntatore const

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() {} ///:~

Formattazione

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.

Assegnazione e type checking

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.

Array di caratteri letterali

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.

Argomenti di funzioni
e valori restituiti da funzioni

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.

Passaggio di un parametro come valore const

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.

Restituire un valore const

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.

Oggetti temporanei

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.

Passare e restituire indirizzi

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à.

Passaggio di argomenti standard

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.

Classi

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.

const nelle classi

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.

La lista di inizializzazione 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.

 “Costruttori” per tipi built-in

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.

Costanti a tempo di compilazione nelle classi

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.

“Enum hack” nel codice old-style

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.

Oggetti const e funzioni membro

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.

mutable: const bitwise contro const logiche

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.

ROMability

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:

  1. La classe o la struttura (struct) non devono avere costruttori o distruttori definiti dall’utente.
  2. Non ci possono essere classi base (argomento trattato nel Capitolo 14)  o oggetti membro con costruttori o distruttori definiti dall’utente.

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.

volatile

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.

Sommario

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.

Esercizi

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.

 

  1. Creare tre valori const int, e sommarne i valori per ottenere un valore da utilizzare come dimensione in un definizione di un array. Si provi a compilare lo stesso codice in C e si osservi cosa succede (in generale è possibile forzare il proprio compilatore C++ ad funzionare come un compilatore C mediante delle opzioni da riga di comando)
  2. Dimostrare che i compilatori C e C++ trattano le costanti realmente in modo differente. Si crei un const globale e lo si utilizzi in un’espressione costante globale; si compili sia come C che come C++.
  3. Creare delle definizioni di const per ognuno dei tipi built-in e per ognuna delle loro varianti. Si utilizzino queste espressioni con altri const per creare delle nuove definizioni di const. Assicurarsi che la compilazione avvenga senza errori.
  4. Creare una definizione di const in un file di header e si includi questo header in due file .cpp; si compili entrambi i file e si effettui il link. Non si dovrebbero ottenere errori. Si provi a fare la stessa prova in C.
  5. Creare un const il cui valore sia determinato a runtime leggendo il tempo quando il programma parte (si dovrà utilizzare lo standard header <ctime>). Di seguito, nello stesso programma, si provi ad assegnare un altro valore di tempo al const e osservare cosa succede.
  6. Creare un array const di char; si provi, in seguito, a modificare un di char.
  7. In un file si dichiari un extern const, e si crei un main( ) che stampi il valore dell’extern const. Si definisca un extern const in un altro file e si compilino e si effettui il link dei due file.
  8. Creare due puntatori a const long usando entrambe le forma presentate in questo capitolo. Si punti uno dei due ad un array di long. Dimostrare che il puntatore può essere incrementato o decrementato, ma il valore puntato non può essere cambiato.
  9. Creare un puntatore const a double e lo si utilizzi per puntare ad un array di double. Si mostri di poter cambiare l’elemento puntato, mentre il puntatore non può essere ne incrementato ne decrementato.
  10. Creare un puntatore const ad un oggetto const. Si mostri che l’unica cosa possibile è la lettura del valore puntato, mentre è impossibile cambiare sia il valore del puntatore che l’oggetto puntato.
  11. Rimuovere il commento alla linea che genera errore nell’esempio PointerAssignment.cpp e si osservi l’errore generato dal compilatore.
  12. Creare un array di caratteri letterali con un puntatore che punti all’inizio dell’array. Si utilizzi il puntatore per modificare gli elementi dell’array. Il vostro compilatore segnala errore? Dovrebbe farlo? Se non genera errori, perché succede?
  13. Creare una funzione che accetti come argomento un const passato per valore; si provi a modificare questo argomento all’interno del corpo della funzione.
  14. Creare una funzione che accetti come argomento un float passato per valore. All’interno della funzione, si leghi un const float& all’argomento, e si utilizzi il riferimento per assicurarsi che il valore non venga modificato.
  15. Modificare ConstReturnValues.cpp rimuovendo, uno alla volta, i commenti alle linee che causano errore e si osservino i messaggi di errore generati dal compilatore.
  16. Modificare ConstPointer.cpp rimuovendo, uno alla volta, i commenti alle linee che causano errore e si osservino i messaggi di errore generati dal compilatore.
  17. Creare una nuove versione di ConstPointer.cpp e chiamarla ConstReference.cpp che utilizzi riferimenti al posto dei puntatori (prima  potrebbe essere necessario leggere il Capitolo 11).
  18. Modificare ConstTemporary.cpp rimuovendo, uno alla volta, i commenti alle linee che causano errore e si osservino i messaggi di errore generati dal compilatore.
  19. Creare una classe che contenga sia un const float che uno non-const. Inizializzare questi membri utilizzando la lista di inizializzazione del costruttore.
  20. Creare una classe chiamata MyString che contenga un string, un costruttore che inizializzi string, e una funzione print( ). Modificare StringStack.cpp in maniera tale che possa contenere oggetti del tipo MyString, e il main( ) in modo da stampare gli oggetti.
  21. In ConstMember.cpp, rimuovere lo specificatore const dalla definizione della funzione membro, e lasciarlo nella dichiarazione, per poter osservare il tipo di errore restituito dal compilatore.
  22. Creare una classe con funzioni membro const e non-const. Creare oggetti const e non-const appartenenti a questa classe, e si provi a chiamare i differenti tipi di funzioni per i diffirenti tipi di oggetto.
  23. Creare una classe con funzioni membro const e non-const. Si provi a chiamare una funzione membro non-const da una funzione membro const e si osservi il tipo di errore che restituito dal compilatore.
  24. In Mutable.cpp, rimuovere il commento alla linea che causa l’errore e si osservi il messaggio d’errore restituito dal compilatore.
  25. Modificare Quoter.cpp rendendo quote( ) una funzione membro const e lastquote mutable.
  26. Creare una classe con un dato membro volatile. Creare sia funzioni membro volatile che non-volatile, le quali modifichino il dato membro volatile ed osservare il comportamento del compilatore. Creare sia oggetti volatile che non-volatile, provare a chiamare sia le funzioni membro volatile che quelle non-volatile e osservare quali sono le combinazioni che funzionano e quali messaggi vengono generati dal compilatore in caso di errore.
  27. Creare una classe chiamata bird con il metodo fly( ) e una classe rock senza questo metodo. Creare un oggetto di tipo rock, si assegni il suo indirizzo ad un void*. Si prenda, poi, il puntatore void* e lo si assegni ad un bird* (si dovrà utilizzare il cast), e si chiami il metodo fly( ) attraverso questo puntatore. È evidente che la possibilità di assegnare liberamente qualsiasi cosa attraverso un void* (senza cast) rappresenta un “buco” nel linguaggio C; come si potrebbe porre rimedio a questo fenomeno utilizzando il C++?

[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à.

[ Capitolo Precedente ] [ Indice Generale ] [ Indice Analitico ] [ Prossimo Capitolo ]

Ultimo Aggiornamento:05/02/2003