Operatori && e ||
Anche gli operatori di AND e OR logico possono
essere ridefiniti, tuttavia c'e` una profonda differenza tra quelli
predefiniti e quelli che l'utente puo` definire. La versione predefinita di
entrambi gli operatori eseguono valutazioni parziali degli argomenti:
l'operatore valuta l'operando di sinistra, ma valuta anche quello di destra
solo quando il risultato dell'operazione e` ancora incerto.
In questi esempi l'operando di destra non viene mai valutato:
|
int var1 = 1;
int var2 = 0;
int var3 = var2 && var1;
var3 = var1 || var2;
|
In entrambi i casi il secondo operando non viene valutato poiche` il valore
del primo e` sufficiente a stabilire il risultato dell'espressione.
Le versioni sovraccaricate definite dall'utente non si comportano in
questo modo, entrambi gli argomenti dell'operatore sono sempre valutati
(al momento in cui vengono passati come parametri).
Smart pointer
Un operatore particolarmente interessante e`
quello di dereferenzazione -> il cui comportamento e` un po'
difficile da capire.
Se T e` una classe che ridefinisce -> (l'operatore
di dereferenzazione deve essere un funzione membro non statica) e
Obj e` una istanza di tale classe, l'espressione
|
Obj -> Field;
|
e` valutata come
|
(Obj.operator ->()) -> Field;
|
Conseguenza di cio` e` che il
risultato di questo operatore deve essere uno tra
- un puntatore ad una struttura o una classe che contiene un membro
Field;
- una istanza di un'altra classe che ridefinisce a sua volta
l'operatore. In questo caso l'operatore viene applicato
ricorsivamente all'oggetto ottenuto prima, fino a quando non si
ricade nel caso precedente;
In questo modo e` possibile realizzare puntatori intelligenti (smart
pointer), capaci di eseguire controlli per prevenire errori
disastrosi.
Pur essendo un operatore unario postfisso, il modo in cui viene trattato
impone che ci sia sul lato destro una specie di secondo operando; se volete
potete pensare che l'operatore predefinito sia in realta` un operatore
binario il cui secondo argomento e` il nome del campo di una struttura,
mentre l'operatore che l'utente puo` ridefinire deve essere unario.
L'operatore virgola
Anche la virgola e` un operatore (binario) che
puo` essere ridefinito. La versione predefinita dell'operatore fa si` che
entrambi gli argomenti siano valutati, ma il risultato prodotto e` il
valore del secondo (quello del primo argomento viene scartato). Nella
prassi comune, la virgola e` utilizzata per gli effetti collaterali
derivanti dalla valutazione delle espressioni:
|
int A = 5;
int B = 6;
int C = 10;
int D = (++A, B+C);
|
In questo esempio il valore assegnato a D e` quello ottenuto
dalla somma di B e C, mentre l'espressione a
sinistra della virgola serve per incrementare A. A sinistra
della virgola poteva esserci una chiamata di funzione, che serviva solo per
alcuni suoi effetti collaterali.
Quanto alle parentesi, esse sono necessarie perche` l'assegnamento ha la
precedenza sulla virgola.
Questo operatore e` comunque sovraccaricato raramente.
Autoincremento e autodecremento
Gli operatori ++ e -- meritano
un breve accenno poiche` esistono entrambi sia come operatori unari
prefissi che unari postfissi.
Le prime versioni del linguaggio non consentivano di distinguere tra le due
forme, la stessa definizione veniva utilizzata per le due sintassi. Le
nuove versioni del linguaggi consentono invece di distinguere e usano
due diverse definizioni per i due possibili casi.
Come operatori globali, la forma prefissa prende un solo argomento,
l'oggetto cui e` applicato; la forma postfissa invece possiede un parametro
fittizio in piu` di tipo int.
I prototipi delle due forme di entrambi gli operatori per gli interi sono
ad esempio le seguenti:
|
int operator++(int A); // caso ++Var
int operator++(int A, int); // caso Var++
int operator--(int A); // caso --Var
int operator--(int A, int); // caso Var--
|
Il parametro fittizio non ha un nome e non e` possibile accedere ad esso.
Ridefiniti come funzioni membro, la versione prefissa non presenta
nel suo prototipo alcun parametro (il parametro e` l'oggetto su cui
l'operatore e` chiamato), la forma postfissa ha un prototipo con il
solo argomento fittizio.
New e delete
Neanche gli operatori new e
delete fanno eccezione, anche loro possono essere ridefiniti sia a
livello di classe o addirittura globalmente.
Sia come funzioni globali che come funzioni membro, la new
riceve un parametro di tipo size_t che al momento della chiamata
e` automaticamente inizializzato con il numero di byte da allocare e deve
restituire sempre un void*; la delete invece riceve un
void* e non ritorna alcun risultato (va dichiarata void).
Anche se non esplicitamente dichiarate, come funzioni membro i due
operatori sono sempre static.
Poiche` entrambi gli operatori hanno un prototipo predefinito,
non e` possibile avere piu` versioni overloaded di new e
delete, e` possibile averne al piu` una unica definizione globale e
una sola definizione per classe come funzione membro.
Se una classe ridefinisce questi operatori (o uno dei due)
la funzione membro viene utilizzata al posto di quella globale per gli
oggetti di tale classe; quella globale definita (anch'essa eventualmente
ridefinita dall'utente) sara` utilizzata in tutti gli altri casi.
La ridefinizione di new e delete e` solitamente effettuata in
programmi che fanno massiccio uso dello heap al fine di evitarne una eccessiva frammentazione e soprattutto per ridurre l'overhead globale
introdotto dalle singole chiamate.
Ecco un esempio di new e delete globali:
|
void* operator new(size_t Size) {
return malloc(Size);
}
void operator delete(void* Ptr) {
free(Ptr);
}
|
Le funzioni malloc() e free() richiedono al sistema
(rispettivamente) l'allocazione di un blocco di Size byte o
la sua deallocazione (in quest'ultimo caso non e` necessario indicare il
numero di byte).
Sia new che delete possono accettare un secondo parametro,
nel caso di new ha tipo void* e nel caso della delete
e` di tipo size_t: nella new il secondo parametro
serve per consentire una allocazione di un blocco di memoria ad un
indirizzo specifico (ad esempio per mappare in memoria un dispositivo
hardware), mentre nel caso della delete il suo compito e` di fornire
la dimensione del blocco da deallocare (utile in parecchi casi). Nel caso
in cui lo si utilizzi, e` compito del programmatore supplire un valore per
il secondo parametro (in effetti solo per il primo parametro della
new e` il compilatore che fornisce il valore).
Ecco un esempio di new che utilizza il secondo parametro:
|
void* operator new(size_t Size, void* Ptr = 0) {
if (Ptr) return Ptr;
return malloc(Size);
}
int main() {
// Supponiamo di voler mappare un certo
// dispositivo hardware tramite una istanza di
// un apposito tipo
const void* DeviceAddr = 0xA23;
// Si osservi il modo in cui viene fornito
// il secondo parametro della new
TMyDevice Unit1 = new(DeviceAddr) TMyDevice;
/* ... */
return 0;
}
|
Si noti che non c'e` una delete duale per questa forma di
new (perche` una delete non puo` sapere se e come e`
stata allocato l'oggetto da deallocare), questo vuol dire che gli oggetti
allocati nel modo appena visto (cioe` fornendo alla new un indirizzo) vanno deallocati con tecniche diverse.
E` possibile sovraccaricare anche le versioni per array di questi
operatori. I prototipi di new[] e delete[] sono identici
a quelli gia` visti in particolare il valore che il compilatore fornisce
come primo parametro alla new[] e` ancora la dimensione complessiva
del blocco da allocare.
Per terminare il discorso su questi operatori, bisogna accennare a cio` che
accade quando una allocazione non riesce (generalmente per mancanza di
memoria). In caso di fallimento della new, lo standard prevede che
venga chiamata una apposita funzione (detta new-handler) il cui
comportamento di default e` sollevare una eccezione
di tipo std::bad_alloc che bisogna intercettare per gestire il
possibile fallimento.
E` possibile modificare tale comportamento definendo e istallando una nuova
new-handler. La generica new-handler deve essere una funzione
che non riceve alcun parametro e restituisce void, tale funzione
va installata tramite una chiamata a std::set_new_handler il
cui prototipo e` dato dalle seguenti definizioni:
|
typedef void (*new_handler)();
// new_handler e` un puntatore ad una funzione
// che non prende parametri e restituisce void
new_handler set_new_handler(new_handler HandlePtr);
|
La funzione set_new_handler riceve come parametro la funzione
da utilizzare quando la new fallisce e restituisce un puntatore alla
vecchia new-handler. Ecco un esempio di come utilizzare questo
strumento:
|
void NoMemory() {
// cerr e` come cin, ma si usa per inviare
// messaggi di errore...
cerr << "Out of memory... Program aborted!" << endl;
abort();
}
int main(int, char* []) {
new_handler OldHandler = set_new_handler(NoMemory);
char* Ptr = new char[1000000000];
set_new_handler(OldHandler);
/* ... */
}
|
Il precedente esempio funziona perche` la funzione standard
abort() provoca la terminazione del programma, in realta`
la new-handler viene richiamata da new finche` l'operatore
non e` in grado di restituire un valore valido, per cui bisogna tenere conto
di cio` quando si definisce una routine per gestire i fallimenti di
new.
Conclusioni
Per terminare questo argomento restano da
citare gli operatori per la conversione di tipo e analizzare la differenza
tra operatori come funzioni globali o come funzioni membro.
Per quanto riguarda la conversione di tipo, si rimanda
all'appendice A.
Solitamente non c'e` differenza tra un operatore definito globalmente e uno
analogo definito come funzione membro, nel primo caso per ovvi motivi
l'operatore viene solitamente dichiarato friend delle classi cui
appartengono i suoi argomenti; nel caso di una funzione membro, il primo
argomento e` sempre una istanza della classe e l'operatore puo` accedere a
tutti i suoi membri, per quanto riguarda l'eventuale secondo argomento puo`
essere necessaria dichiararlo friend nell'altra classe. Per il resto
non ci sono differenze per il compilatore, nessuno dei due metodi e` piu`
efficiente dell'altro; tuttavia non sempre e` possibile utilizzare una
funzione membro, ad esempio se si vuole permettere il flusso su stream
della propria classe , e` necessario ricorrere ad una funzione globale,
perche` il primo argomento non e` una istanza della classe:
|
class Complex {
public:
/* ... */
private:
float Re, Im;
friend ostream& operator<<(ostream& os,
Complex& C);
};
ostream& operator<<(ostream& os, Complex& C) {
os << C.Re << " + i" << C.Im;
return os;
}
|
Adesso e` possibile scrivere
|
Complex C(1.0, 2.3);
cout << C;
|
Pagina precedente - Pagina successiva
C++, una panoramica sul linguaggio - seconda edizione © Copyright 1996-1999, Paolo Marotta
|