trad. italiana e adattamento a cura di Giacomo Grande
Il sovraccaricamento degli operatori è semplicemente "uno zuccherino
sintattico," che vuol dire un altro modo di chiamare le funzioni.
La differenza
è che gli argomenti di questa funzione non appaiono dentro le parentesi, ma
piuttosto circondano o seguono caratteri che abbiamo sempre pensato come
immutabili operatori.
Ci sono due
differenze tra l'uso di un operatore e una chiamata ad una funzione ordinaria.
La sintassi è differente; un operatore spesso è "chiamato" mettendolo tra gli argomenti o a volte dopo gli
argomenti. La seconda differenza è che il compilatore stabilisce quale "funzione"
chiamare. Per esempio, se usiamo l'operatore + con argomenti
floating-point, il compilatore "chiama"
la funzione per sommare due floating-point (questa "chiamata" è tipicamente l'atto
di mettere del codice in-line, o un'istruzione di un processore
floating-point). Se usiamo l'operatore + con un numero floating-point e
un int, il compilatore "chiama"
una funzione speciale per trasformare l' int in float, e poi "chiama" il codice per sommare due floating-point.
Ma in C++ è possibile definire nuovi
operatori che lavorano con le classi. Questa definizione è proprio come la
definizione di una funzione ordinaria, solo che il nome della funzione consiste
della parola chiave operator seguita dall'operatore. Questa è la sola
differenza e diventa una funzione come tutte le altre, che il compilatore
chiama quando vede il tipo appropriato.
Si è portati ad essere
super entusiasti con il sovraccaricamento degli operatori. È un giocattolo divertente,
sulle prime. Ma ricordiamoci che è solo uno zuccherino sintattico, un
altro modo per chiamare una funzione. Guardando la cosa sotto questo aspetto,
non abbiamo nessuna ragione di sovraccaricare un operatore, se non ci permette
di rendere il codice che coinvolge la nostra classe più facile da scrivere e
soprattutto più facile da leggere (notare che si legge molto più codice
di quanto non se ne scriva). Se questo non è il caso, non ci dobbiamo preoccupare.
Un'altra reazione
diffusa al sovraccaricamento degli operatori è il panico; improvvisamente gli
operatori C non hanno più un significato familiare. "Ogni cosa è cambiata e
il mio codice C farà cose diverse!" Questo non è vero. Tutti gli operatori
che contengono solo dati di tipo predefinito non possono cambiare. Non potremo
mai sovraccaricare operatori del tipo
1 << 4;
oppure
1.414 << 2;
Solo espressioni che
contengono tipi definiti dall'utente possono avere un operatore sovraccaricato.
Definire un operatore
sovraccaricato è come definire una funzione, ma il nome della funzione è operator@,
dove @ rappresenta l'operatore da sovraccaricare. Il numero di argomenti
nella lista degli argomenti dell'operatore sovraccaricato dipende da due
fattori:
Qui c'è una piccola
classe che mostra la sintassi per il sovraccaricamento di un operatore :
//: C12:OperatorOverloadingSyntax.cpp #include <iostream> using namespace std; class Integer { int i; public: Integer(int ii) : i(ii) {} const Integer operator+(const Integer& rv) const { cout << "operator+" << endl; return Integer(i + rv.i); } Integer& operator+=(const Integer& rv) { cout << "operator+=" << endl; i += rv.i; return *this; } }; int main() { cout << "built-in types:" << endl; int i = 1, j = 2, k = 3; k += i + j; cout << "user-defined types:" << endl; Integer ii(1), jj(2), kk(3); kk += ii + jj; } ///:~
I due operatori
sovraccaricati sono definiti come funzioni membro inline che si annunciano quando
vengono chiamate. Il singolo argomento è quello che appare alla destra dell'operatore
per operatori binari. Gli operatori unari non hanno argomenti quando definiti
come funzioni membro. La funzione membro viene chiamata per l'oggetto alla
sinistra dell'operatore.
Per operatori non-condizionali
(gli operatori condizionali restituiscono usualmente valori Boolean), quasi
sempre il valore di ritorno è un oggetto o un riferimento dello stesso tipo
su cui si sta operando, se i due argomenti sono dello stesso tipo (se non sono
dello stesso tipo, l'interpretazione di quello che potrebbe restituire sta a
noi). In questo modo possono essere costruite espressioni complicate, come:
kk += ii + jj;
L' operator+
produce un nuovo Integer (temporaneo) che viene usato come argomento rv
per operator+=. Questo dato
temporaneo viene distrutto quando non è più necessario.
Benchè sia possibile
sovraccaricare quasi tutti gli operatori disponibili in C, l'uso del
sovraccaricamento degli operatori è abbastanza restrittivo. In particolare,
non si possono combinare operatori che non hanno nessun significato in C (come **
per rappresentare l'elevamento a potenza), non si può cambiare la precedenza
degli operatori, e non si può cambiare il numero di argomenti richiesti per un
operatore. Questo ha senso – tutte queste azioni produrrebbero operatori che
confondono il significato, invece di chiarirlo.
Le prossime due
sottosezioni mostrano esempi di tutti gli operatori "regolari", sovraccaricati nella forma che più verosimilmente viene usata.
L'esempio seguente
mostra la sintassi per sovraccaricare tutti gli operatori unari, sia nella
forma di funzioni globali (non friend di funzioni membro) che di
funzioni membro. Questo (esempio) va dalla classe Integer mostrata
precedentemente fino ad una nuova classe byte. Il significato degli
operatori specifici dipende dall'uso che ne vogliamo fare, ma teniamo presente
il cliente programmatore prima di fare cose inaspettate.
Qui c'è un catalogo
di tutte le funzioni unarie:
//: C12:OverloadingUnaryOperators.cpp #include <iostream> using namespace std; // Funzioni non-membro: class Integer { long i; Integer* This() { return this; } public: Integer(long ll = 0) : i(ll) {} //Operatori senza effetti collaterali accettano argomenti const&: friend const Integer& operator+(const Integer& a); friend const Integer operator-(const Integer& a); friend const Integer operator~(const Integer& a); friend Integer* operator&(Integer& a); friend int operator!(const Integer& a); // Operatori con effetti collaterali hanno argomenti non-const&: // Prefisso: friend const Integer& operator++(Integer& a); // Postfisso: friend const Integer operator++(Integer& a, int); // Prefisso: friend const Integer& operator--(Integer& a); // Postfisso: friend const Integer operator--(Integer& a, int); }; // Operatori globali: const Integer& operator+(const Integer& a) { cout << "+Integer\n"; return a; //L'operatore + unario non ha effetto sull'argomento } const Integer operator-(const Integer& a) { cout << "-Integer\n"; return Integer(-a.i); } const Integer operator~(const Integer& a) { cout << "~Integer\n"; return Integer(~a.i); } Integer* operator&(Integer& a) { cout << "&Integer\n"; return a.This(); // &a è ricorsivo! } int operator!(const Integer& a) { cout << "!Integer\n"; return !a.i; } // Prefisso; restituisce il valore incrementato const Integer& operator++(Integer& a) { cout << "++Integer\n"; a.i++; return a; } // Postfisso; restituisce il valore prima di incrementarlo: const Integer operator++(Integer& a, int) { cout << "Integer++\n"; Integer before(a.i); a.i++; return before; } // Prefisso; restituisce il valore decrementato const Integer& operator--(Integer& a) { cout << "--Integer\n"; a.i--; return a; } // Postfisso; restituisce il valore prima di decrementarlo: const Integer operator--(Integer& a, int) { cout << "Integer--\n"; Integer before(a.i); a.i--; return before; } // Mostra che gli operatori sovraccaricati funzionano: void f(Integer a) { +a; -a; ~a; Integer* ip = &a; !a; ++a; a++; --a; a--; } // Funzioni membro("this" implicito): class Byte { unsigned char b; public: Byte(unsigned char bb = 0) : b(bb) {} // Non ci sono effetti collaterali: funzione membro const: const Byte& operator+() const { cout << "+Byte\n"; return *this; } const Byte operator-() const { cout << "-Byte\n"; return Byte(-b); } const Byte operator~() const { cout << "~Byte\n"; return Byte(~b); } Byte operator!() const { cout << "!Byte\n"; return Byte(!b); } Byte* operator&() { cout << "&Byte\n"; return this; } //Ci sono effetti collaterali: funzione membro non-const: const Byte& operator++() { // Prefisso cout << "++Byte\n"; b++; return *this; } const Byte operator++(int) { // Postfisso cout << "Byte++\n"; Byte before(b); b++; return before; } const Byte& operator--() { // Prefisso cout << "--Byte\n"; --b; return *this; } const Byte operator--(int) { // Postfisso cout << "Byte--\n"; Byte before(b); --b; return before; } }; void g(Byte b) { +b; -b; ~b; Byte* bp = &b; !b; ++b; b++; --b; b--; } int main() { Integer a; f(a); Byte b; g(b); } ///:~
Le funzioni sono raggruppate
in base al modo in cui vengono passati i loro argomenti. Le linee guida su come
passare e restituire argomenti saranno fornite in seguito. Le forme di sopra
(e quelle che seguono nella prossima sezione) sono quelle tipicamente usate,
così assumiamo queste come modello per il sovraccaricamento dei nostri operatori.
Gli
operatori sovraccaricati ++ e – – presentano un dilemma
perchè vorremmo essere in grado di chiamare funzioni diverse a seconda che essi
appaiano prima (prefisso) o dopo (postfisso) l'oggetto su cui operano. La soluzione
è semplice, ma qualche volta la gente trova la cosa un pò fuorviante sulle prime.
Quando il compilatore vede, per esempio, ++a (pre-incremento), esso genera
una chiamata a operator++(a); ma quando vede a++, esso genera
una chiamata a operator++(a, int). Cioè il compilatore distingue le due
forme effettuando chiamate a due diverse funzioni sovraccaricate. In OverloadingUnaryOperators.cpp
per le versioni riferite alle funzioni membro, se il compilatore vede ++b,
genera una chiamata a B::operator++( ); se vede b++ chiama
B::operator++(int).
Tutto quello che l'utente
vede è che viene chiamata una funzione diversa per la versione con il prefisso
e quella con il postfisso. In fondo le chiamate alle due funzioni hanno firme diverse, così esse si aggangiano
a due corpi di funzione diversi. Il compilatore passa un valore costante fittizio
per l'argomento int (il quale non ha mai un identificativo, in quanto
il suo valore non è mai usato) per generare una firma diversa per la versione
con postfisso.
Il listato seguente
ripete l'esempio di OverloadingUnaryOperators.cpp per gli operatori
binari, così abbiamo un esempio per tutti gli operatori che potremmo voler
sovraccaricare.
//: C12:Integer.h // Operatori sovraccaricati non-membri #ifndef INTEGER_H #define INTEGER_H #include <iostream> // Funzioni non-membro: class Integer { long i; public: Integer(long ll = 0) : i(ll) {} // Operatori che creano valori nuovi, modificati: friend const Integer operator+(const Integer& left, const Integer& right); friend const Integer operator-(const Integer& left, const Integer& right); friend const Integer operator*(const Integer& left, const Integer& right); friend const Integer operator/(const Integer& left, const Integer& right); friend const Integer operator%(const Integer& left, const Integer& right); friend const Integer operator^(const Integer& left, const Integer& right); friend const Integer operator&(const Integer& left, const Integer& right); friend const Integer operator|(const Integer& left, const Integer& right); friend const Integer operator<<(const Integer& left, const Integer& right); friend const Integer operator>>(const Integer& left, const Integer& right); //Modifica per assegnamento & ritorno di lvalue: friend Integer& operator+=(Integer& left, const Integer& right); friend Integer& operator-=(Integer& left, const Integer& right); friend Integer& operator*=(Integer& left, const Integer& right); friend Integer& operator/=(Integer& left, const Integer& right); friend Integer& operator%=(Integer& left, const Integer& right); friend Integer& operator^=(Integer& left, const Integer& right); friend Integer& operator&=(Integer& left, const Integer& right); friend Integer& operator|=(Integer& left, const Integer& right); friend Integer& operator>>=(Integer& left, const Integer& right); friend Integer& operator<<=(Integer& left, const Integer& right); // Gli operatori condizionali restituiscono true/false: friend int operator==(const Integer& left, const Integer& right); friend int operator!=(const Integer& left, const Integer& right); friend int operator<(const Integer& left, const Integer& right); friend int operator>(const Integer& left, const Integer& right); friend int operator<=(const Integer& left, const Integer& right); friend int operator>=(const Integer& left, const Integer& right); friend int operator&&(const Integer& left, const Integer& right); friend int operator||(const Integer& left, const Integer& right); // Scrive i contenuti su un ostream: void print(std::ostream& os) const { os << i; } }; #endif // INTEGER_H ///:~
//: C12:Integer.cpp {O} // Implementazione di operatori sovraccaricati #include "Integer.h" #include "../require.h" const Integer operator+(const Integer& left, const Integer& right) { return Integer(left.i + right.i); } const Integer operator-(const Integer& left, const Integer& right) { return Integer(left.i - right.i); } const Integer operator*(const Integer& left, const Integer& right) { return Integer(left.i * right.i); } const Integer operator/(const Integer& left, const Integer& right) { require(right.i != 0, "divide by zero"); return Integer(left.i / right.i); } const Integer operator%(const Integer& left, const Integer& right) { require(right.i != 0, "modulo by zero"); return Integer(left.i % right.i); } const Integer operator^(const Integer& left, const Integer& right) { return Integer(left.i ^ right.i); } const Integer operator&(const Integer& left, const Integer& right) { return Integer(left.i & right.i); } const Integer operator|(const Integer& left, const Integer& right) { return Integer(left.i | right.i); } const Integer operator<<(const Integer& left, const Integer& right) { return Integer(left.i << right.i); } const Integer operator>>(const Integer& left, const Integer& right) { return Integer(left.i >> right.i); } // Modifica per assegnamento & ritorno di lvalue: Integer& operator+=(Integer& left, const Integer& right) { if(&left == &right) {/* auto-assegnamento */} left.i += right.i; return left; } Integer& operator-=(Integer& left, const Integer& right) { if(&left == &right) {/* auto-assegnamento */} left.i -= right.i; return left; } Integer& operator*=(Integer& left, const Integer& right) { if(&left == &right) {/* auto-assegnamento */} left.i *= right.i; return left; } Integer& operator/=(Integer& left, const Integer& right) { require(right.i != 0, "divide by zero"); if(&left == &right) {/* auto-assegnamento */} left.i /= right.i; return left; } Integer& operator%=(Integer& left, const Integer& right) { require(right.i != 0, "modulo by zero"); if(&left == &right) {/* auto-assegnamento */} left.i %= right.i; return left; } Integer& operator^=(Integer& left, const Integer& right) { if(&left == &right) {/* auto-assegnamento */} left.i ^= right.i; return left; } Integer& operator&=(Integer& left, const Integer& right) { if(&left == &right) {/* auto-assegnamento */} left.i &= right.i; return left; } Integer& operator|=(Integer& left, const Integer& right) { if(&left == &right) {/* auto-assegnamento */} left.i |= right.i; return left; } Integer& operator>>=(Integer& left, const Integer& right) { if(&left == &right) {/* auto-assegnamento */} left.i >>= right.i; return left; } Integer& operator<<=(Integer& left, const Integer& right) { if(&left == &right) {/* auto-assegnamento */} left.i <<= right.i; return left; } // Gli operatori condizionali restituiscono true/false: int operator==(const Integer& left, const Integer& right) { return left.i == right.i; } int operator!=(const Integer& left, const Integer& right) { return left.i != right.i; } int operator<(const Integer& left, const Integer& right) { return left.i < right.i; } int operator>(const Integer& left, const Integer& right) { return left.i > right.i; } int operator<=(const Integer& left, const Integer& right) { return left.i <= right.i; } int operator>=(const Integer& left, const Integer& right) { return left.i >= right.i; } int operator&&(const Integer& left, const Integer& right) { return left.i && right.i; } int operator||(const Integer& left, const Integer& right) { return left.i || right.i; } ///:~
//: C12:IntegerTest.cpp //{L} Integer #include "Integer.h" #include <fstream> using namespace std; ofstream out("IntegerTest.out"); void h(Integer& c1, Integer& c2) { // Un'espressione complessa: c1 += c1 * c2 + c2 % c1; #define TRY(OP) \ out << "c1 = "; c1.print(out); \ out << ", c2 = "; c2.print(out); \ out << "; c1 " #OP " c2 produces "; \ (c1 OP c2).print(out); \ out << endl; TRY(+) TRY(-) TRY(*) TRY(/) TRY(%) TRY(^) TRY(&) TRY(|) TRY(<<) TRY(>>) TRY(+=) TRY(-=) TRY(*=) TRY(/=) TRY(%=) TRY(^=) TRY(&=) TRY(|=) TRY(>>=) TRY(<<=) // Espressioni condizionali: #define TRYC(OP) \ out << "c1 = "; c1.print(out); \ out << ", c2 = "; c2.print(out); \ out << "; c1 " #OP " c2 produces "; \ out << (c1 OP c2); \ out << endl; TRYC(<) TRYC(>) TRYC(==) TRYC(!=) TRYC(<=) TRYC(>=) TRYC(&&) TRYC(||) } int main() { cout << "friend functions" << endl; Integer c1(47), c2(9); h(c1, c2); } ///:~
//: C12:Byte.h // Operatori sovraccaricati membri di classi #ifndef BYTE_H #define BYTE_H #include "../require.h" #include <iostream> // Funzioni membro ("this" implicito): class Byte { unsigned char b; public: Byte(unsigned char bb = 0) : b(bb) {} // Senza effetti collaterali: funzioni membro const: const Byte operator+(const Byte& right) const { return Byte(b + right.b); } const Byte operator-(const Byte& right) const { return Byte(b - right.b); } const Byte operator*(const Byte& right) const { return Byte(b * right.b); } const Byte operator/(const Byte& right) const { require(right.b != 0, "divide by zero"); return Byte(b / right.b); } const Byte operator%(const Byte& right) const { require(right.b != 0, "modulo by zero"); return Byte(b % right.b); } const Byte operator^(const Byte& right) const { return Byte(b ^ right.b); } const Byte operator&(const Byte& right) const { return Byte(b & right.b); } const Byte operator|(const Byte& right) const { return Byte(b | right.b); } const Byte operator<<(const Byte& right) const { return Byte(b << right.b); } const Byte operator>>(const Byte& right) const { return Byte(b >> right.b); } // Modifica per assegnamento & ritorno di lvalue. // operator= può essere solo funzione membro: Byte& operator=(const Byte& right) { // Gestisce l'auto-assegnamento: if(this == &right) return *this; b = right.b; return *this; } Byte& operator+=(const Byte& right) { if(this == &right) {/* auto-assegnamento */} b += right.b; return *this; } Byte& operator-=(const Byte& right) { if(this == &right) {/* auto-assegnamento */} b -= right.b; return *this; } Byte& operator*=(const Byte& right) { if(this == &right) {/* auto-assegnamento */} b *= right.b; return *this; } Byte& operator/=(const Byte& right) { require(right.b != 0, "divide by zero"); if(this == &right) {/* auto-assegnamento */} b /= right.b; return *this; } Byte& operator%=(const Byte& right) { require(right.b != 0, "modulo by zero"); if(this == &right) {/* auto-assegnamento */} b %= right.b; return *this; } Byte& operator^=(const Byte& right) { if(this == &right) {/* auto-assegnamento */} b ^= right.b; return *this; } Byte& operator&=(const Byte& right) { if(this == &right) {/* auto-assegnamento */} b &= right.b; return *this; } Byte& operator|=(const Byte& right) { if(this == &right) {/* auto-assegnamento */} b |= right.b; return *this; } Byte& operator>>=(const Byte& right) { if(this == &right) {/* auto-assegnamento */} b >>= right.b; return *this; } Byte& operator<<=(const Byte& right) { if(this == &right) {/* auto-assegnamento */} b <<= right.b; return *this; } // Operatori condizionali restituiscono true/false: int operator==(const Byte& right) const { return b == right.b; } int operator!=(const Byte& right) const { return b != right.b; } int operator<(const Byte& right) const { return b < right.b; } int operator>(const Byte& right) const { return b > right.b; } int operator<=(const Byte& right) const { return b <= right.b; } int operator>=(const Byte& right) const { return b >= right.b; } int operator&&(const Byte& right) const { return b && right.b; } int operator||(const Byte& right) const { return b || right.b; } // Scrive i contenuti su un ostream: void print(std::ostream& os) const { os << "0x" << std::hex << int(b) << std::dec; } }; #endif // BYTE_H ///:~
//: C12:ByteTest.cpp #include "Byte.h" #include <fstream> using namespace std; ofstream out("ByteTest.out"); void k(Byte& b1, Byte& b2) { b1 = b1 * b2 + b2 % b1; #define TRY2(OP) \ out << "b1 = "; b1.print(out); \ out << ", b2 = "; b2.print(out); \ out << "; b1 " #OP " b2 produces "; \ (b1 OP b2).print(out); \ out << endl; b1 = 9; b2 = 47; TRY2(+) TRY2(-) TRY2(*) TRY2(/) TRY2(%) TRY2(^) TRY2(&) TRY2(|) TRY2(<<) TRY2(>>) TRY2(+=) TRY2(-=) TRY2(*=) TRY2(/=) TRY2(%=) TRY2(^=) TRY2(&=) TRY2(|=) TRY2(>>=) TRY2(<<=) TRY2(=) // Operatori di assegnamento // Espressioni condizionali: #define TRYC2(OP) \ out << "b1 = "; b1.print(out); \ out << ", b2 = "; b2.print(out); \ out << "; b1 " #OP " b2 produces "; \ out << (b1 OP b2); \ out << endl; b1 = 9; b2 = 47; TRYC2(<) TRYC2(>) TRYC2(==) TRYC2(!=) TRYC2(<=) TRYC2(>=) TRYC2(&&) TRYC2(||) // Assegnamenti concatenati: Byte b3 = 92; b1 = b2 = b3; } int main() { out << "member functions:" << endl; Byte b1(47), b2(9); k(b1, b2); } ///:~
Si può vedere che all'
operator= è consentito essere solo una funzione membro. Questo è spiegato
in seguito.
Notare che tutti gli
operatori di assegnamento hanno del codice per controllare l'auto-assegnamento;
questo è una linea guida generale. In certi casi questo non è necessario; per
esempio, con operator+= possiamo scrivere A+=A e ottenere
la somma di A con se stesso. Dove è più importante controllare l'auto
assegnamento è nell'operator= perchè con oggetti complicati si possono
ottenere risultati disastrosi (in certi casi è OK, ma bisogna sempre stare attenti
quando si scrive operator=)
Tutti gli operatori mostrati nei due esempi
precedenti sono sovraccaricati per manipolare un singolo tipo. È anche possibile
sovraccaricare operatori per manipolare tipi misti e sommare, per esempio, mele
con arancie. Prima di cominciare ad usare in modo esaustivo il sovraccaricamento
degli operatori, tuttavia, bisogna dare uno sguardo alla sezione sulla conversione
automatica dei tipi più avanti in questo capitolo. Spesso una conversione di
tipo alla destra dell'operatore può far risparmiare un sacco di operatori sovraccaricati.
Può
sembrare leggermente fuorviante sulle prime quando si guarda dentro OverloadingUnaryOperators.cpp,
Integer.h e Byte.h e si vedono tutti i modi diversi in cui vengono
passati gli argomenti e restituiti i valori di ritorno. Benchè si possano
passare e restituire gli argomenti in tutti i modi possibili, le scelte fatte
in questi esempi non sono casuali. Esse seguono un modello logico, che è lo
stesso che dovremmo usare nella maggior parte dei casi.
Come per una qualunque argomento
di una funzione, se l'argomento serve solo in lettura e non bisogna fare
cambiamenti su di esso è bene passarlo come riferimento const. Le
operazioni aritmetiche ordinarie (come + e –, ecc.)
e i Booleani non cambiano i loro argomenti, e quindi il modo più diffuso
di passare gli argomenti è come riferimento const. Quando la funzione
è un membro di una classe, questo si traduce nell'avere funzioni membro
const. Solo con gli operatori di assegnamento (come +=) e
l' operator=, che cambiano l'argomento alla sinistra dell'operatore,
l'argomento di sinistra non è una costante, ma viene passato come
indirizzo perchè viene modificato.
Il tipo del valore di ritorno
che dobbiamo scegliere dipende dal significato atteso dell'operatore (di
nuovo, si può fare qualunque cosa con gli argomenti e i valori di ritorno.)
Se l'effetto dell'operatore è di produrre un nuovo valore, c'è bisogno di
generare un nuovo oggetto come valore di ritorno. Per esempio, Integer::operator+
deve produrre un oggetto Integer che è la somma degli operandi. Questo
oggetto viene restituito per valore come const, così il risultato
non può essere modificato come un lvalue (valore di sinistra).
Tutti gli operatori di assegnamento
modificano il valore di sinistra. Per consentire al risultato di un assegnamento
di essere usato in espressioni concatenate, come a=b=c, ci si aspetta
che il valore di ritorno sia un riferimento allo stesso valore di sinistra
appena modificato. Ma questo riferimento deve essere const o nonconst?
Benchè noi leggiamo l'espressione a=b=c da sinistra verso destra,
il compilatore la analizza da destra verso sinistra, cosicchè non siamo
obbligati a restituire un nonconst per supportare il concatenamento
dell'assegnazione. Tuttavia la gente qualche volta si aspetta di poter effettuare
operazioni sull'oggetto a cui è stata appena fatta l'assegnazione, come
(a=b).func( ); per chiamare func( ) su a
dopo avergli assegnato b. Perciò il valore di ritorno per tutti gli
operatori di assegnamento potrebbe essere un riferimento nonconst
al valore di sinistra.
Per gli operatori logici
tutti si aspettano di avere come valore di ritorno nel peggiore dei casi
un int e nel migliore dei casi un bool (le librerie sviluppate
prima che molti compilatori C++ supportassero il tipo incorporato bool,
usano un int o un typedef equivalente).
Gli operatori di incremento
e decremento presentano il solito dilemma delle versioni pre- e postfissa. Entrambe
le versioni modificano l'oggetto e quindi non possono trattare l'oggetto come
const. La versione con prefisso restituisce il valore dell'oggetto
dopo averlo modificato, quindi ci si aspetta come ritorno lo stesso oggetto
su cui opera. In questo caso, quindi, si può semplicemente restituire un *this
come riferimento. La versione con postfisso si suppone che restituisca il valore
dell'oggetto prima che questo venga modificato, perciò siamo obbligati
a creare un oggetto separato per rappresentare questo valore e restituirlo.
In questo caso il ritorno deve essere fatto per valore se si vuole preservare
il significato atteso (Notare che qualche volta si possono trovare gli operatori
di incremento e decremento che restituiscono un int o un bool per indicare, per esempio, che
un oggetto utilizzato per scorrere una lista ha raggiunto la fine di questa
lista). Adesso la domanda è: il valore di ritorno deve essere const o
nonconst? Se si consente all'oggetto di essere modificato e qualcuno
scrive (++a).func( ), func( ) opera su a stessa,
ma con (a++).func( ), func( ) opera su un oggetto temporaneo
restituito da operator++ in versione con postfisso. Gli oggetti temporanei
sono automaticamente const, perciò questo sarà marcato dal compilatore,
ma per coerenza ha molto più senso renderli entrambi const, come viene
fatto qui. Oppure si può scegliere di rendere non-const la versione con prefisso
e const la versione con postfisso. A causa della varietà di significati
che si possono dare agli operatori di incremento e decremento, è necessario
considerarli caso per caso.
Il
ritorno per valore come const
potrebbe sembrare un pò subdolo sulle prime, perciò richiede un minimo di spiegazione.
Consideriamo l'operatore binario operator+. Se lo usiamo in un'espressione
come f(a+b), il risultato di a+b diventa un oggetto temporaneo
usato nella chiamata di f( ). Siccome è temporaneo, esso è automaticamente
const, perciò non ha nessun effetto la dichiarazione esplicita const
o non-const.
Tuttavia è anche possibile
inviare un messaggio al valore di ritorno di a+b, piuttosto che passarlo
semplicemente ad una funzione. Per esempio possiamo scrivere (a+b).g( ),
in cui g( ) è una qualche funzione membro della classe Integer,
in questo caso. Rendendo const
il valore di ritorno si impone che solo le funzioni membro const
possano essere chiamate per questo valore di ritorno. Questo è un uso corretto
di const, perchè previene il potenziale errore di memorizzare un'informazione
preziosa in un oggetto che molto probabilmente verrebbe perso.
Quando
vengono creati nuovi oggetti da restituire per valore, guardiamo la forma usata.
In operator+, per esempio:
return Integer(left.i + right.i);
Questa potrebbe sembrare
sulle prime una "chiamata a funzione di un costruttore," ma non lo è. La sintassi
è quella di un oggetto temporaneo; l'istruzione dice "costruisci un oggetto
temporaneo di tipo Integer e restituiscilo." Per questo, si potrebbe
pensare che il risultato è lo stesso che si ottiene creando un oggetto locale
e restituendolo. Invece la cosa è alquanto diversa. Se invece scriviamo:
Integer tmp(left.i + right.i); return tmp;
succedono tre cose. Primo,
l'oggetto tmp viene creato includendo la chiamata al suo costruttore.
Secondo, il costruttore di copia copia tmp nella locazione del valore di ritorno (esterna allo
scope). Terzo, alla fine viene chiamato il distruttore di tmp.
In
contrasto, l'approccio del "ritorno
di un oggetto temporaneo" funziona in maniera completamente diversa. Quando
il compilatore vede fare una cosa del genere, sa che non c'è nessun altro interesse
sull'oggetto creato se non quello di usarlo come valore di ritorno. Il compilatore
trae vantaggio da questa informazione, creando l'oggetto direttamente
dentro la locazione del valore di ritorno, esterna allo scope. Questo richiede
solo una chiamata al costruttore ordinario (non è necessario alcun costruttore
di copia) e non c'è la chiamata al distruttore, in quanto di fatto non viene
mai creato l'oggetto temporaneo. Così, se da una parte non costa nulla se non
un pò di accortezza da parte del programmatore, dall'altra è significativamente
molto più efficiente. Questo viene spesso chiamata ottimizzazione del valore
di ritorno.
Alcuni
operatori aggiuntivi hanno una sintassi leggermente diversa per il sovraccaricamento.
L'operatore sottoscrizione,
operator[ ], deve essere una funzione membro e richiede un singolo argomento.
Siccome l' operator[ ] implica che l'oggetto per il quale viene chiamato
agisce come un array, il valore di ritorno per questo operatore deve essere
un riferimento, così può essere usato convenientemente alla sinistra del segno
di uguale. Questo operatore viene comunemente sovraccaricato; se ne potranno
vedere esempi nel resto del libro.
Gli operatori new
e delete controllano l'allocazione dinamica della memoria e possono essere
sovraccaricati in molti modi diversi. Questo argomento è trattato nel Capitolo
13.
L'operatore
virgola viene chiamato quando appare dopo un oggetto del tipo per cui l'operatore
virgola è definito . Tuttavia, "operator," (operatore virgola) non viene chiamato per le liste
di argomenti di funzioni, ma solo per oggetti che appaiono separati da virgole.
Non sembra che ci siano tanti usi pratici di questo operatore ed è stato messo
nel linguaggio solo per coerenza. Qui c'è un esempio di come la funzione virgola
può essere chiamata quando appare una virgola prima o dopo un
oggetto:
//: C12:OverloadingOperatorComma.cpp #include <iostream> using namespace std; class After { public: const After& operator,(const After&) const { cout << "After::operator,()" << endl; return *this; } }; class Before {}; Before& operator,(int, Before& b) { cout << "Before::operator,()" << endl; return b; } int main() { After a, b; a, b; // Viene chiamato l'operatore virgola Before c; 1, c; // Viene chiamato l'operatore virgola } ///:~
La funzione globale consente
alla virgola di essere posta prima dell'oggetto in questione. L'uso mostrato
è piuttosto oscuro e opinabile. Benchè si potrebbe usare un lista separata da
virgole come parte di un'espressione più complessa, è molto subdolo nella maggior
parte delle situazioni.
L'
operator–> (dereferenziazione di puntatore) è generalmente usato
quando si vuole fare apparire un oggetto come puntatore. Siccome un oggetto
di questo tipo incorpora molta più "intelligenza" di quanta ne abbia un puntatore
normale, spesso viene chiamato puntatore intelligente. Questo è utile
soprattutto se si vuole "avvolgere" una classe intorno ad un puntatore per renderlo
sicuro, o nell'uso comune di un iteratore, cioè un oggetto che scorre
all'interno di una collezione /contenitore di altri oggetti e
li seleziona uno alla volta, senza fornire l'accesso diretto all'implementazione
del contenitore (I contenitori e gli iteratori si possono trovare spesso nelle
librerie di classi, come nella libreria Standard del C++, descritta nel Volume
2 di questo libro).
Questo operatore deve
essere una funzione membro. Esso ha dei vincoli aggiuntivi atipici: deve restituire
un oggetto (o un riferimento ad un oggetto) che abbia anch'esso un operatore
di dereferenziazione di puntatore, oppure deve restituire un puntatore che può
essere usato per selezionare quello a cui punta l'operatore. Qui c'è un esempio:
//: C12:SmartPointer.cpp #include <iostream> #include <vector> #include "../require.h" using namespace std; class Obj { static int i, j; public: void f() const { cout << i++ << endl; } void g() const { cout << j++ << endl; } }; // Definizioni di membri static: int Obj::i = 47; int Obj::j = 11; // Container: class ObjContainer { vector<Obj*> a; public: void add(Obj* obj) { a.push_back(obj); } friend class SmartPointer; }; class SmartPointer { ObjContainer& oc; int index; public: SmartPointer(ObjContainer& objc) : oc(objc) { index = 0; } // Il valore di ritorno indica la fine della lista: bool operator++() { // Prefisso if(index >= oc.a.size()) return false; if(oc.a[++index] == 0) return false; return true; } bool operator++(int) { // Postfisso return operator++(); // Usa la versione con prefisso } Obj* operator->() const { require(oc.a[index] != 0, "Zero value " "returned by SmartPointer::operator->()"); return oc.a[index]; } }; int main() { const int sz = 10; Obj o[sz]; ObjContainer oc; for(int i = 0; i < sz; i++) oc.add(&o[i]); // Lo riempie SmartPointer sp(oc); // Crea un iteratore do { sp->f(); // Chiamata all'operatore di dereferenziazione di puntatore sp->g(); } while(sp++); } ///:~
La classe Obj
definisce gli oggetti che vengono manipolati in questo programma. Le funzioni
f( ) e g( ) semplicemente stampano valori di interesse
usando i dati membri static. I puntatori a questi oggetti sono memorizzati
all'interno dei contenitori di tipo ObjContainer usando la sua funzione
add( ). ObjContainer si presenta come un array di puntatori,
ma si può notare che non c'è nessun modo di tirar fuori questi puntatori. Tuttavia,
SmartPointer è dichiarata come classe friend, perciò ha il permesso
di guardare dentro al contenitore. La classe SmartPointer si presenta
molto più come un puntatore intelligente – lo si può spostare in avanti
usando l' operator++ (si può anche definire un operator– –),
non può oltrepassare la fine del contenitore a cui punta, e produce (attraverso
l'operatore di dereferenziazione di puntatori) il valore a cui punta. Notare
che SmartPointer è un adattamento personalizzato per il contenitore per
cui è stato creato; a differenza di un puntatore ordinario, non c'è un puntatore
intelligente "general purpose". Si può apprendere molto di più sui puntatori
intelligenti detti "iteratori" nell'ultimo capitolo di questo libro e nel Volume
2 (scaricabile da www.BruceEckel.com).
In
main( ), una volta che il contenitore oc è riempito con oggetti
di tipo Obj , viene creato uno SmartPointer sp. Le chiamate al
puntatore intelligente avvengono nelle espressioni:
sp->f(); // Chiamate a puntatori intelligenti sp->g();
Qui, anche se sp
di fatto non ha le funzioni membro f( ) e g( ), l'operatore
di dereferenziazione di puntatori automaticamente chiama queste funzioni per l'Obj*
restituito da SmartPointer::operator–>. Il compilatore effettua tutti
i controlli per assicurare che la chiamata alla funzione lavori correttamente.
È molto più comune
vedere una classe "puntatore intelligente" o "iteratore" nidificata all'interno
della classe che essa serve. L'esempio precedente può essere riscritto per
nidificare SmartPointer all'interno di ObjContainer, come segue:
//: C12:NestedSmartPointer.cpp #include <iostream> #include <vector> #include "../require.h" using namespace std; class Obj { static int i, j; public: void f() { cout << i++ << endl; } void g() { cout << j++ << endl; } }; // Definizioni di membri Static: int Obj::i = 47; int Obj::j = 11; // Container: class ObjContainer { vector<Obj*> a; public: void add(Obj* obj) { a.push_back(obj); } class SmartPointer; friend class SmartPointer; class SmartPointer { ObjContainer& oc; unsigned int index; public: SmartPointer(ObjContainer& objc) : oc(objc) { index = 0; } // Il valore di ritorno indica la fine della lista: bool operator++() { // Prefisso if(index >= oc.a.size()) return false; if(oc.a[++index] == 0) return false; return true; } bool operator++(int) { // Postfisso return operator++(); // Usa la versione con prefisso } Obj* operator->() const { require(oc.a[index] != 0, "Zero value " "returned by SmartPointer::operator->()"); return oc.a[index]; } }; // Funzione per produrre un puntatore intelligente che // punta all'inizio di ObjContainer: SmartPointer begin() { return SmartPointer(*this); } }; int main() { const int sz = 10; Obj o[sz]; ObjContainer oc; for(int i = 0; i < sz; i++) oc.add(&o[i]); //Lo riempie ObjContainer::SmartPointer sp = oc.begin(); do { sp->f(); // Chiamata all'operatore di dereferenziazione di puntatore sp->g(); } while(++sp); } ///:~
Oltre alla
nidificazione delle classi, ci sono solo due differenze qui. La prima è nella
dichiarazione della classe in modo che possa essere friend:
class SmartPointer; friend SmartPointer;
Il compilatore deve
innanzitutto sapere che la classe esiste prima che possa dire che è una friend.
La seconda differenza
è nella funzione begin( ), membro di ObjContainer, che produce uno SmartPointer che punta all'inizio
della sequenza ObjContainer. Benchè sia solo una questione di
convenienza, è preziosa perchè segue parte della forma usata nella libreria
Standard del C++.
L'
operator–>* è un operatore binario che si comporta come tutti
gli altri operatori binari. Viene fornito per quelle situazioni in cui si vuole
simulare il comportamento di un puntatore-a-membro,
descritto nel capitolo precedente.
Proprio come l' operator->,
l'operatore di dereferenziazione di puntatore-a-membro viene generalmente usato
con certi tipi di oggetti che rappresentano un "puntatore intelligente," anche
se l'esempio mostrato qui è più semplice, in modo da risultare comprensibile.
Il trucco quando si definisce l' operator->* è che deve ritornare
un oggetto per il quale l' operator( ) (chiamata a funzione) può
essere chiamato con gli argomenti da passare alla funzione membro che si sta chiamando.
L'operatore chiamata
a funzione, operator( ),
deve essere una funzione membro, ed è unica per il fatto che accetta un numero
qualsiasi di argomenti. Esso fa si che il nostro oggetto si presenti come se
fosse una funzione. Anche se è possibile definire diverse funzioni con l'
operator( ) sovraccaricato, con diversi argomenti, questo viene spesso
usato per tipi che hanno una sola operazione (funzione membro) o che hanno almeno
un'operazione particolarmente importante rispetto alle altre. Possiamo vedere
nel Volume 2 che la Libreria dello Standard C++ usa l'operatore chiamata a funzione
per creare "oggetti funzione."
Per creare un operator->*
bisogna prima creare una classe con un operator( ) che rappresenta
il tipo di oggetto che l' operator->* deve restituire. Questa classe
deve in qualche modo catturare le informazioni necessarie affinchè quando l'
operator( ) viene chiamato (il che avviene automaticamente), il
puntatore-a-membro viene dereferenziato per l'oggetto. Nell'esempio seguente,
il costruttore FunctionObject cattura e memorizza sia il puntatore all'oggetto
che il puntatore alla funzione membro, e quindi l' operator( ) li
usa per costruire la chiamata al puntatore-a-membro:
//: C12:PointerToMemberOperator.cpp #include <iostream> using namespace std; class Dog { public: int run(int i) const { cout << "run\n"; return i; } int eat(int i) const { cout << "eat\n"; return i; } int sleep(int i) const { cout << "ZZZ\n"; return i; } typedef int (Dog::*PMF)(int) const; // operator->* deve restituire un oggetto // che ha un operator(): class FunctionObject { Dog* ptr; PMF pmem; public: // Salva il puntatore all'oggetto e il puntatore al membro FunctionObject(Dog* wp, PMF pmf) : ptr(wp), pmem(pmf) { cout << "FunctionObject constructor\n"; } // Effettua la chiamata usando il puntatore all'oggetto // e il puntatore al membro int operator()(int i) const { cout << "FunctionObject::operator()\n"; return (ptr->*pmem)(i); // Effettua la chiamata } }; FunctionObject operator->*(PMF pmf) { cout << "operator->*" << endl; return FunctionObject(this, pmf); } }; int main() { Dog w; Dog::PMF pmf = &Dog::run; cout << (w->*pmf)(1) << endl; pmf = &Dog::sleep; cout << (w->*pmf)(2) << endl; pmf = &Dog::eat; cout << (w->*pmf)(3) << endl; } ///:~
Dog ha tre funzioni membro,
e tutte prendono un argomento int e restituiscono un int. PMF
è un typedef per semplificare la definizione di un puntatore-a-membro
per le funzioni membro di Dog.
Con operator->*
viene creata e restituita una FunctionObject. Notare che operator->*
conosce sia il puntatore-a-membro che l'oggetto per cui questo viene chiamato
(this), e li passa al costruttore di FunctionObject che ne memorizza
i valori. Quando viene chiamato operator->*, il compilatore chiama
immediatamente l' operator( ) per calcolare il valore di ritorno
di operator->*, passandogli gli argomenti di operator->*.
L'operatore FunctionObject::operator( ) prende gli argomenti e dereferenzia
il "reale" puntatore-a-membro usando
il puntatore all'oggetto e il puntatore-a-membro da esso memorizzati.
Notare che quello che
stiamo facendo qui, proprio come con l' operator->, è di inserirci
nel mezzo della chiamata ad operator->*. Questo ci permette di fare
delle operazioni extra, se ne abbiamo bisogno.
Il meccanismo dell'operator->*
implementato qui funziona solo con funzioni membro che prendono come argomento
un int e restituiscono un int. Questo è limitativo, ma creare
un meccanismo di sovraccaricamento per ogni singola possibilità sembra un compito
proibitivo. Fortunatamente il meccanismo dei template del C++ (descritto
nell'ultimo capitolo di questo libro e nel Volume 2) è stato pensato proprio
per gestire problemi di questo tipo.
L' operator. (operatore
punto) di selezione dei membri. Correntemente, il punto ha un significato
per qualunque membro di classe, ma se se ne permettesse il sovraccaricamento,
allora non si potrebbe più accedere ai membri in maniera normale, ma solo
con i puntatori o con l'operatore operator->.
L' operator .* (operatore
punto asterisco), dereferenziatore del puntatore a membro, per gli stessi
motivi dell' operator. (operatore punto).
Non c'è un operatore per
l'elevamento a potenza. La scelta più popolare per questa operazione è stato
l' operator** (operatore **) dal Fortran, ma questo solleva un difficile
problema di parsing. Neanche il C ha un operatore per l'elevamento a potenza,
per cui sembra che il C++ non ne abbia bisogno di uno, anche perchè si può
sempre usare una funzione. Un operatore di elevamento a potenza potrebbe
aggiungere una notazione comoda, ma questa nuova funzionalità nel linguaggio
non compenserebbe la complessità aggiunta al compilatore.
Non ci sono operatori definiti
dall'utente. Cioè non si possono aggiungere (e sovraccaricare) operatori
che non siano già tra quelli disponibili nel linguaggio. Il problema è in
parte nella difficoltà di gestire le precedenze e in parte nel fatto che
lo sforzo non sarebbe compensato dai vantaggi.
Non si possono cambiare le regole di precedenza. Sono già abbastanza difficili da ricordare così come sono, senza lasciare che la gente ci giochi.
In
alcuni degli esempi precedenti, gli operatori possono essere membri o non-membri
e non sembra esserci molta differenza. Questo in genere solleva una questione,
"Quale dobbiamo scegliere?" In generale, se non c'è differenza è preferibile
scegliere un membro di classe, per enfatizzare l'associazione tra l'operatore
e la sua classe. Quando l'operando di sinistra è sempre un oggetto della classe
corrente questo funziona benissimo.
Tuttavia qualche volta
si vuole che l'operando di sinistra sia un oggetto di qualche altra classe.
Un caso comune in cui si può vedere questo è quando gli operatori <<
e >> sono sovraccaricati per iostreams. Siccome iostreams è una
libreria fondamentale del C++, probabilmente si vogliono sovraccaricare questi
operatori per molte classi proprie, per cui il problema merita di essere affrontato:
//: C12:IostreamOperatorOverloading.cpp // Esempi di operatori sovraccaricati non-membri #include "../require.h" #include <iostream> #include <sstream> // "String streams" #include <cstring> using namespace std; class IntArray { enum { sz = 5 }; int i[sz]; public: IntArray() { memset(i, 0, sz* sizeof(*i)); } int& operator[](int x) { require(x >= 0 && x < sz, "IntArray::operator[] out of range"); return i[x]; } friend ostream& operator<<(ostream& os, const IntArray& ia); friend istream& operator>>(istream& is, IntArray& ia); }; ostream& operator<<(ostream& os, const IntArray& ia) { for(int j = 0; j < ia.sz; j++) { os << ia.i[j]; if(j != ia.sz -1) os << ", "; } os << endl; return os; } istream& operator>>(istream& is, IntArray& ia){ for(int j = 0; j < ia.sz; j++) is >> ia.i[j]; return is; } int main() { stringstream input("47 34 56 92 103"); IntArray I; input >> I; I[4] = -1; // Usa l'operatore sovraccaricato operator[] cout << I; } ///:~
Questa classe contiene
anche un operator [ ] sovraccaricato, che restituisce un riferimento al
giusto valore nell'array. Siccome viene restituito un riferimento, l'espressione
I[4] = -1;
non solo si presenta
in modo molto più umano di quanto non si sarebbe ottenuto con l'uso dei
puntatori, ma sortisce anche l'effetto desiderato.
È importante che gli
operatori di shift sovraccaricati ritornino per riferimento, in modo che l'azione si rifletta
sugli oggetti esterni. Nelle definizioni di funzioni, un'espressione come
os << ia.i[j];
causa la chiamata alle
funzioni esistenti dell'operatore sovraccaricato (cioè quelle definite
in <iostream>). In questo caso, la funzione chiamata è ostream&
operator<<(ostream&, int) perchè ia.i[j] si risolve in un
int.
Una volta che sono
state effettuate tutte le azioni su istream o su ostream, questo
viene restituito in modo che possa essere usato in espressioni più complicate.
In main( )
viene usato un nuovo tipo di iostream: stringstream (dichiarato in <sstream>).
Questa è una classe che prende una string (che può costruire a partire
da un array di char, come mostrato qui) e la traduce in un iostream.
Nell'esempio di sopra, questo significa che gli operatori di shift possono
essere testati senza aprire un file o digitando dati dalla linea di comando.
La forma mostrata in
questo esempio per l'inserimento e l'estrazione dei dati è standard. Se si
vogliono creare questi operatori per la propria classe si può copiare il
prototipo delle funzioni e i tipi di ritorno e seguire la stessa forma per il
corpo.
Murray[49] suggerisce queste linee guida per la
scelta tra membri e non-membri:
Operatore |
Uso raccomandato |
Tutti gli
operatori unari |
membri |
= ( )
[ ] –> –>* |
Devono essere membri |
+= –= /=
*= ^= |
membri |
Tutti gli
altri operatori binari |
non-membri |
Una fonte comune di
confusione per i nuovi programmatori in C++ è l'assegnamento. Questo perchè
il segno di = (uguale) è un'operazione fondamentale nella
programmazione, dal livello più alto fino alla copia di un registro a livello
macchina. In più qualche volta viene invocato anche il costruttore di copia
(descritto nel Capitolo 11) quando si usa il segno di =:
MyType b; MyType a = b; a = b;
Nella seconda linea
viene definito l'oggetto a. Un nuovo oggetto viene creato laddove
non ne esisteva uno prima. Siccome sappiamo come il compilatore C++ sia
difensivo riguardo all'inizializzazione di oggetti, sappiamo anche che deve
essere sempre chiamato un costruttore dove si definisce un oggetto. Ma quale
costruttore? a viene creato da un oggetto di tipo MyType
preesistente (b, sul lato destro del segno di =), per cui c'è una sola
scelta: il costruttore di copia. Anche se è coinvolto un segno di uguale,
viene di fatto chiamato il costruttore di copia.
Nella terza linea le
cose sono diverse. Sul lato sinistro del segno di uguale c'è un oggetto precedentemente
inizializzato. Chiaramente non viene chiamato un costruttore per un oggetto
che è stato già creato. In questo caso viene chiamato l'operatore MyType::operator=
per a, prendendo come argomento tutto ciò che si trova sul lato destro
del segno di uguale (si possono avere più funzioni operator= per trattare
differenti titpi di argomenti ).
Questo comportamento
non è limitato al costruttore di copia. Ogni volta che si inizializza un
oggetto usando un segno di =
invece della forma ordinaria di chiamata a funzione del costruttore, il
compilatore cerca un costruttore che accetta tutto ciò che sta sul lato destro
dell'operatore:
//: C12:CopyingVsInitialization.cpp class Fi { public: Fi() {} }; class Fee { public: Fee(int) {} Fee(const Fi&) {} }; int main() { Fee fee = 1; // Fee(int) Fi fi; Fee fum = fi; // Fee(Fi) } ///:~
Quando si ha a che
fare con il segno di = è importante avere in mente questa distinzione :
se l'oggetto non è stato ancora creato, è richiesta un'inizializzazione;
altrimenti viene usato l' operator=.
È comunque meglio
evitare di scrivere codice che usa il segno di = per l'inizializzazione; usare, invece, sempre la
forma esplicita del costruttore. Le due costruzioni con il segno di uguale
diventano quindi:
Fee fee(1); Fee fum(fi);
In questo modo si
evita di confondere il lettore.
In Integer.h e Byte.h,
asseriamo che operator= può essere solo una funzione membro. Esso è
intimamente legato all'oggetto sul lato sinistro di ‘='. Se fosse
possibile definire l' operator= globalmente, potremmo tentare di
ridefinire il segno ‘=' predefinito:
int operator=(int, MyType); // Global = non consentito!
Il compilatore evita
questa possibilità forzando l' operator= ad essere una funzione membro.
Quando si crea un operator=,
bisogna copiare tutte le informazioni necessarie dall'oggetto presente sul lato
destro all'oggetto corrente (cioè l'oggetto per il quale l' operator= viene
chiamato) per effettuare quello che consideriamo un "assegnamento" per la nostra classe. Per oggetti semplici
questo è ovvio:
//: C12:SimpleAssignment.cpp // Semplice operator=() #include <iostream> using namespace std; class Value { int a, b; float c; public: Value(int aa = 0, int bb = 0, float cc = 0.0) : a(aa), b(bb), c(cc) {} Value& operator=(const Value& rv) { a = rv.a; b = rv.b; c = rv.c; return *this; } friend ostream& operator<<(ostream& os, const Value& rv) { return os << "a = " << rv.a << ", b = " << rv.b << ", c = " << rv.c; } }; int main() { Value a, b(1, 2, 3.3); cout << "a: " << a << endl; cout << "b: " << b << endl; a = b; cout << "a after assignment: " << a << endl; } ///:~
Qui l'oggetto sul lato
sinistro del segno di = copia tutti gli elementi dell'oggetto sulla destra,
e poi ritorna un riferimento a se stesso, il che permette di creare un'espressione
molto più complessa.
Questo esempio contiene
un errore comune. Quando si effettua un assegnamento tra due oggetti dello stesso
tipo bisogna sempre fare il controllo sull'auto assegnamento: l'oggetto viene
assegnato a se stesso? In certi casi, come questo, è innocuo effettuare l'operazione
di assegnamento in qualunque modo, ma se vengono apportate modifiche all'implementazione
della classe ci possono essere delle differenze, e se non si tiene conto di
questo si possono introdurre errori molto difficili da scovare.
Cosa
succede se l'oggetto non è così semplice? Per esempio, cosa succede se l'oggetto
contiene puntatori ad altri oggetti? Semplicemente, copiando un puntatore si
finisce per avere due oggetti che puntano alla stessa locazione di memoria.
In situazioni come queste, bisogna fare un pò di conti.
Ci sono due approcci
comuni al problema. La tecnica più semplice è quella di copiare tutti i dati
puntati dal puntatore quando si fa l'assegnamento o si invoca il costruttore
di copia. Questo è chiaro:
//: C12:CopyingWithPointers.cpp // Soluzione del problema della replica del puntatore // con la duplicazione dell'oggetto che viene puntato // durante l'assegnamento e la costruzione della copia. #include "../require.h" #include <string> #include <iostream> using namespace std; class Dog { string nm; public: Dog(const string& name) : nm(name) { cout << "Creating Dog: " << *this << endl; } // Il costruttore di copia & operator= // sono corretti. // Crea un oggetto Dog da un puntatore a Dog: Dog(const Dog* dp, const string& msg) : nm(dp->nm + msg) { cout << "Copied dog " << *this << " from " << *dp << endl; } ~Dog() { cout << "Deleting Dog: " << *this << endl; } void rename(const string& newName) { nm = newName; cout << "Dog renamed to: " << *this << endl; } friend ostream& operator<<(ostream& os, const Dog& d) { return os << "[" << d.nm << "]"; } }; class DogHouse { Dog* p; string houseName; public: DogHouse(Dog* dog, const string& house) : p(dog), houseName(house) {} DogHouse(const DogHouse& dh) : p(new Dog(dh.p, " copy-constructed")), houseName(dh.houseName + " copy-constructed") {} DogHouse& operator=(const DogHouse& dh) { // Controlla l'auto-assegnamento: if(&dh != this) { p = new Dog(dh.p, " assigned"); houseName = dh.houseName + " assigned"; } return *this; } void renameHouse(const string& newName) { houseName = newName; } Dog* getDog() const { return p; } ~DogHouse() { delete p; } friend ostream& operator<<(ostream& os, const DogHouse& dh) { return os << "[" << dh.houseName << "] contains " << *dh.p; } }; int main() { DogHouse fidos(new Dog("Fido"), "FidoHouse"); cout << fidos << endl; DogHouse fidos2 = fidos; // Costruzione della copia cout << fidos2 << endl; fidos2.getDog()->rename("Spot"); fidos2.renameHouse("SpotHouse"); cout << fidos2 << endl; fidos = fidos2; // Assegnamento cout << fidos << endl; fidos.getDog()->rename("Max"); fidos2.renameHouse("MaxHouse"); } ///:~
Dog è una classe
semplice che contiene solo una string che memorizza il nome del cane.
Tuttavia bisogna in genere sapere quando succede qualcosa a Dog perchè
i costruttori e i distruttori
stampano informazioni quando vengono chiamati. Notare che il secondo costruttore è come un costruttore
di copia, solo che prende un puntatore a Dog invece di un riferimento,
ed ha un secondo argomento che è un messaggio che viene concatenato al nome
dell'argomento Dog. Questo viene usato per aiutare nel tracciamento del
comportamento del programma.
Si può vedere che
ogni volta che una funzione membro stampa delle informazioni, non accede
direttamente a queste informazioni, ma invia *this a cout. Questo
a sua volta chiama l' ostream operator<<. È importante
farlo in questo modo, perchè se vogliamo riformattare il modo in cui le
informazioni di Dog vengono visualizzate (come abbiamo fatto aggiungendo
‘[' e ‘]') bisogna agire in un posto solo.
Un DogHouse contiene
un Dog* e mostra le quattro funzioni che bisogna sempre definire quando
la classe contiene dei puntatori: tutti i costruttori ordinari necessari, il costruttore
di copia, operator= (o lo si definisce o se ne impedisce l'uso), e il
distruttore. L' operator= fa il controlo sull'auto-assegnamento, come
deve essere, anche se qui non è strettamente necessario. Questo elimina
virtualmente la possibilità di dimenticarsene se si fanno delle modifiche al
codice che lo rendono necessario.
Nell'esempio di sopra,
il costruttore di copia e l' operator= effettuano una nuova copia dei
dati puntati dal puntatore, e il distruttore la cancella. Tuttavia se il nostro
oggetto richiede un sacco di memoria o un elevato overhead di inizializzazione,
si vorrebbe evitare questa copia. Un approccio comune a questo problema è
chiamato conteggio dei riferimenti. Si conferisce intelligenza all'oggetto
che viene puntato in modo che esso sa quanti oggetti lo stanno puntando. La
costruzione-copia o l'assegnamento comportano l'aggiunta di un altro puntatore
ad un oggetto esistente e l'incremento di un contatore di riferimenti. La
distruzione comporta il decremento del contatore dei riferimenti e la
distruzione dell'oggetto se il contatore arriva a zero.
Ma cosa succede se
vogliamo scrivere nell'oggetto ( Dog nell'esempio di sopra)? Più di un
oggetto può usare questo Dog, cosicchè potremmo andare a modificare il
Dog di qualcun altro come il nostro, il che non sembra essere molto
carino. Per risolvere questo problema di "duplicazione" viene usata una tecnica
aggiuntiva chiamata copia-su-scrittura. Prima di scrivere un blocco di
memoria ci si assicura che nessun altro vi sta scrivendo. Se il contatore di
riferimenti è maggiore di uno bisogna fare una copia personale del blocco di
memoria prima di scriverci dentro, in modo da non disturbare il territorio di
qualcun altro . Qui c'è un semplice esempio del contatore di riferimenti e
della copia su scrittura:
//: C12:ReferenceCounting.cpp // Contatore di riferimenti, copia-su-scrittura #include "../require.h" #include <string> #include <iostream> using namespace std; class Dog { string nm; int refcount; Dog(const string& name) : nm(name), refcount(1) { cout << "Creating Dog: " << *this << endl; } // Previene l'assegnamento: Dog& operator=(const Dog& rv); public: // Gli oggetti Dog possono essere creati solo sull'heap: static Dog* make(const string& name) { return new Dog(name); } Dog(const Dog& d) : nm(d.nm + " copy"), refcount(1) { cout << "Dog copy-constructor: " << *this << endl; } ~Dog() { cout << "Deleting Dog: " << *this << endl; } void attach() { ++refcount; cout << "Attached Dog: " << *this << endl; } void detach() { require(refcount != 0); cout << "Detaching Dog: " << *this << endl; // Distrugge l'oggetto se nessuno lo sta usando: if(--refcount == 0) delete this; } // Copia questo oggetto Dog in modo condizionale. // La chiama prima di modificare Dog, assegna // il puntatore di ritorno a Dog*. Dog* unalias() { cout << "Unaliasing Dog: " << *this << endl; // Non lo duplica se non replicato: if(refcount == 1) return this; --refcount; // Usa il costruttore di copia per duplicare: return new Dog(*this); } void rename(const string& newName) { nm = newName; cout << "Dog renamed to: " << *this << endl; } friend ostream& operator<<(ostream& os, const Dog& d) { return os << "[" << d.nm << "], rc = " << d.refcount; } }; class DogHouse { Dog* p; string houseName; public: DogHouse(Dog* dog, const string& house) : p(dog), houseName(house) { cout << "Created DogHouse: "<< *this << endl; } DogHouse(const DogHouse& dh) : p(dh.p), houseName("copy-constructed " + dh.houseName) { p->attach(); cout << "DogHouse copy-constructor: " << *this << endl; } DogHouse& operator=(const DogHouse& dh) { // Controlla auto-assegnamento: if(&dh != this) { houseName = dh.houseName + " assigned"; // Prima cancella quello che stiamo usando: p->detach(); p = dh.p; // Simile al costruttore di copia p->attach(); } cout << "DogHouse operator= : " << *this << endl; return *this; } // Decrementa refcount, e distrugge l'oggetto condizionatamente ~DogHouse() { cout << "DogHouse destructor: " << *this << endl; p->detach(); } void renameHouse(const string& newName) { houseName = newName; } void unalias() { p = p->unalias(); } // Copia-su-scrittura. Ogni volta che modifichiamo // i contenuti del puntatore // bisogna prima chiamare unalias(): void renameDog(const string& newName) { unalias(); p->rename(newName); } // ... o quando permettiamo l'accesso a qualcun altro: Dog* getDog() { unalias(); return p; } friend ostream& operator<<(ostream& os, const DogHouse& dh) { return os << "[" << dh.houseName << "] contains " << *dh.p; } }; int main() { DogHouse fidos(Dog::make("Fido"), "FidoHouse"), spots(Dog::make("Spot"), "SpotHouse"); cout << "Entering copy-construction" << endl; DogHouse bobs(fidos); cout << "After copy-constructing bobs" << endl; cout << "fidos:" << fidos << endl; cout << "spots:" << spots << endl; cout << "bobs:" << bobs << endl; cout << "Entering spots = fidos" << endl; spots = fidos; cout << "After spots = fidos" << endl; cout << "spots:" << spots << endl; cout << "Entering auto-assegnamento" << endl; bobs = bobs; cout << "After auto-assegnamento" << endl; cout << "bobs:" << bobs << endl; // Commentare le seguenti linee: cout << "Entering rename(\"Bob\")" << endl; bobs.getDog()->rename("Bob"); cout << "After rename(\"Bob\")" << endl; } ///:~
La classe Dog è
l'oggetto puntato da DogHouse. Essa contiene un contatore di riferimenti
e delle funzioni per controllare e leggere questo contatore. C'è un costruttore
di copia in modo che possiamo costruire un nuovo oggetto Dog da uno
esistente.
La funzione attach( )
incrementa il contatore dei riferimennti di un oggetto Dog per indicare
che c'è un altro oggetto che lo usa. detach( ) decrementa il
contatore dei riferimenti. Se il contatore arriva a zero allora nessuno lo sta
usando più, così la funzione membro distrugge il suo oggetto chiamando delete
this.
Prima di effettuare
delle modifiche (come rinominare un Dog), bisogna assicurarsi che non si
sta modificando un Dog che qualche altro oggetto sta usando. Si ottiene
ciò chiamando DogHouse::unalias( ), che a sua volta chiama Dog::unalias( ).
L'ultima funzione restituisce il puntatore al Dog esistente se il
contatore dei riferimenti è uno (che significa che nessun altro sta puntando a
questo Dog), ma duplicherà il Dog se il contatore dei
riferimenti è maggiore di uno.
Il costruttore di
copia, invece di creare la sua propria memoria, assegna Dog al Dog
dell'oggetto sorgente. Quindi, siccome adesso c'è un oggetto in più che usa
questo blocco di memoria, esso incrementa il contatore dei riferimenti chiamando
Dog::attach( ).
L' operator= ha
a che fare con un oggetto che è stato già creato sul lato sinistro del segno =,
perciò deve prima pulire a fondo questo oggeto chiamando detach( )
per l'oggetto Dog, che distrugge il vecchio Dog se nessun altro
lo sta usando. Quindi l' operator= ripete il comportamento del costruttore
di copia. Notare che esso controlla prima se stiamo assegnando l'oggetto a se
stesso.
Il distruttore chiama detach( )
per distruggere l'oggetto Dog, se ci sono le condizioni.
Per implementare la
copia-su-scrittura, bisogna controllare tutte le azioni che scrivono il nostro
blocco di memoria. Per esempio, la funzione membro renameDog( ) ci
permette di cambiare i valori nel blocco di memoria. Ma prima usa unalias( )
per prevenire la modifica di un Dog
"replicato"(un Dog con più di un oggetto DogHouse che punta ad
esso). E se abbiamo bisogno di produrre un puntatore a un Dog dall'interno
di DogHouse, bisogna dapprima chiamare unalias( ) per questo
puntatore.
main( ) testa le varie
funzioni che devono lavorare correttamente per implementare il conteggio dei
riferimenti: il costruttore, il costruttore di copia, l'operator= e il
distruttore. Esso controlla anche la copia-suscrittura chiamando renameDog( ).
Qui c'è l'output
(dopo qualche piccola riformattazione):
Creating Dog: [Fido], rc = 1 Created DogHouse: [FidoHouse] contains [Fido], rc = 1 Creating Dog: [Spot], rc = 1 Created DogHouse: [SpotHouse] contains [Spot], rc = 1 Entering copy-construction Attached Dog: [Fido], rc = 2 DogHouse copy-constructor: [copy-constructed FidoHouse] contains [Fido], rc = 2 After copy-constructing bobs fidos:[FidoHouse] contains [Fido], rc = 2 spots:[SpotHouse] contains [Spot], rc = 1 bobs:[copy-constructed FidoHouse] contains [Fido], rc = 2 Entering spots = fidos Detaching Dog: [Spot], rc = 1 Deleting Dog: [Spot], rc = 0 Attached Dog: [Fido], rc = 3 DogHouse operator= : [FidoHouse assigned] contains [Fido], rc = 3 After spots = fidos spots:[FidoHouse assigned] contains [Fido],rc = 3 Entering auto-assegnamento DogHouse operator= : [copy-constructed FidoHouse] contains [Fido], rc = 3 After auto-assegnamento bobs:[copy-constructed FidoHouse] contains [Fido], rc = 3 Entering rename("Bob") After rename("Bob") DogHouse destructor: [copy-constructed FidoHouse] contains [Fido], rc = 3 Detaching Dog: [Fido], rc = 3 DogHouse destructor: [FidoHouse assigned] contains [Fido], rc = 2 Detaching Dog: [Fido], rc = 2 DogHouse destructor: [FidoHouse] contains [Fido], rc = 1 Detaching Dog: [Fido], rc = 1 Deleting Dog: [Fido], rc = 0
Studiando l' output,
scorrendo il codice sorgente e facendo esperimenti con il programma, si può
affinare la conoscenza di queste tecniche.
Siccome assegnare un
oggetto ad un altro oggetto dello stesso tipo è un'attività che molti
si aspettano che sia possibile, il compilatore crea automaticamente type::operator=(type)
se non se ne fornisce uno. Il comportamento di questo operatore imita quello
del costruttore di copia creato automaticamente dal compilatore; se una classe
contiene oggetti (o è ereditata da un'altra classe), l' operator= per
questi oggetti viene chiamato ricorsivamente. Questo viene detto assegnamento
per membro. Per esempio,
//: C12:AutomaticOperatorEquals.cpp #include <iostream> using namespace std; class Cargo { public: Cargo& operator=(const Cargo&) { cout << "inside Cargo::operator=()" << endl; return *this; } }; class Truck { Cargo b; }; int main() { Truck a, b; a = b; // Stampa: "inside Cargo::operator=()" } ///:~
L' operator=
generato automaticamente per Truck chiama Cargo::operator=.
In generale non vogliamo
che il compilatore faccia questo per noi. Con classi di una certa complessità
(specialmente se contengono puntatori!) è preferibile creare esplicitamente
l' operator=. Se non vogliamo che gli utenti della nostra classe effettuino
l'assegnamento, dichiariamo l' operator= come funzione private
(non è necessario definirla a meno che non la stiamo usando nella nostra classe).
In C e C++, se il
compilatore vede un'espressione o una chiamata a funzione che usano un tipo che
non è proprio quello richiesto, spesso può effettuare una conversione di tipo.
In C++, possiamo ottenere lo stesso effetto per i tipi definiti dall'utente,
definendo delle funzioni di conversione automatica dei tipi. Queste funzioni si
presentano in due forme: come tipo particolare di costruttore o come operatore
sovraccaricato.
Se si definisce un
costruttore che prende come unico argomento un oggetto (o riferimento) di un
altro tipo, questo permette al compilatore di effettuare una conversione
automatica di tipo. Per esempio,
//: C12:AutomaticTypeConversion.cpp // Costruttore per la conversione di tipo class One { public: One() {} }; class Two { public: Two(const One&) {} }; void f(Two) {} int main() { One one; f(one); // Si aspetta un Two, riceve un One } ///:~
Quando il compilatore
vede la funzione f( ) chiamata con l'oggetto One, guarda la
dichiarazione di f( ) e si accorge che il tipo richiesto è Two.
Quindi vede se c'è un modo di ricavare Two da One, trova il
costruttore Two::Two(One) e silenziosamente lo chiama. L'oggetto
risultante di tipo Two viene passato ad f( ).
In questo caso la
conversione automatica di tipo ci ha risparmiato l'onere di definire due
versioni sovraccaricate di f( ). Tuttavia il costo è una chiamata
nascosta a un costruttore di Two, che può importare se si è
interessati all'efficienza delle chiamate ad f( ).
Ci sono dei casi in
cui la conversione automatica con costruttore può causare problemi. Per
disattivare questa conversione si può modificare il costruttore, facendolo
precedere dalla parola chiave explicit (che funziona solo con i
costruttori). Usata per modificare il costruttore della classe Two nell'esempio
di sopra, si ha:
//: C12:ExplicitKeyword.cpp // Usando la parola chiave "explicit" class One { public: One() {} }; class Two { public: explicit Two(const One&) {} }; void f(Two) {} int main() { One one; //! f(one); // Nessuna autoconversione permessa f(Two(one)); // OK -- l'utente effettua la conversione } ///:~
Rendendo explicit
il costruttore di Two, si dice al compilatore di non effettuare nessuna
conversione automatica usando questo particolare costruttore (altri costruttori
non-explicit nella stessa classe possono comunque effetture la
conversione automatica). Se l'utilizzatore vuole che la conversione avvenga,
deve scrivere del codice fuori dalla classe Nel codice di sopra, f(Two(one))
crea un oggetto temporaneo di tipo Two da one, proprio come ha
fatto il compilatore nella versione precedente dell'esempio.
Il secondo modo per
ottenere la conversione automatica è attraverso un operatore sovraccaricato.
Si può creare una funzione membro che prende il tipo corrente e lo converte in
quello desiderato usando la parola chiave operator seguita dal tipo
verso cui vogliamo fare la conversione. Questa forma di sovraccaricamento di
operatore è unica, in quanto si vede specificato il valore di ritorno – il
valore di ritorno è il nome dell'operatore che stiamo sovraccaricando.
Qui c'è un esempio:
//: C12:OperatorOverloadingConversion.cpp class Three { int i; public: Three(int ii = 0, int = 0) : i(ii) {} }; class Four { int x; public: Four(int xx) : x(xx) {} operator Three() const { return Three(x); } }; void g(Three) {} int main() { Four four(1); g(four); g(1); // Chiama Three(1,0) } ///:~
Con la tecnica del
costruttore è la classe di destinazione che effettua la conversione, mentre
con la tecnica dell'operatore è la classe sorgente a fare la conversione. Il
pregio della tecnica del costruttore è che si possono aggiungere nuovi
percorsi di conversione ad un sistema esistente quando si crea una nuova classe.
Tuttavia creando un costruttore con un solo argomento si definisce sempre
una conversione automatica di tipo (anche nel caso di più argomenti, se agli
altri vengono lasciati i valori di default), che potrebbe non essere la cosa
che si vuole (in qual caso si può disattivare la conversione con explicit).
In più, non c'è la possibilità di usare la conversione con costruttore da un
tipo definito dall'utente ad un tipo predefinito; questo è possibile solo con
il sovraccaricamento di operatore.
Uno dei principali motivi
di convenienza nell'usare operatori globali sovraccaricati invece di operatori
membri di classe è che nelle versioni globali la conversione automatica di tipo
può essere applicata ad entrambi gli operandi, mentre con gli oggetti membro
l'operando di sinistra deve essere sempre del tipo giusto. Se si vuole che entrambi
gli operandi siano convertiti, la versione globale fa risparmiare un sacco di
codice. Qui c'è un piccolo esempio:
//: C12:ReflexivityInOverloading.cpp class Number { int i; public: Number(int ii = 0) : i(ii) {} const Number operator+(const Number& n) const { return Number(i + n.i); } friend const Number operator-(const Number&, const Number&); }; const Number operator-(const Number& n1, const Number& n2) { return Number(n1.i - n2.i); } int main() { Number a(47), b(11); a + b; // OK a + 1; // 2nd argomento convertito a Number //! 1 + a; // Sbagliato! il primo argomento non è di tipo Number a - b; // OK a - 1; // 2nd argomento convertito a Number 1 - a; // 1mo argomento convertito a Number } ///:~
La classe Number
ha sia un operator+ come membro, sia un friend operator–.
Siccome c'è un costruttore che prende come unico argomento un int, un int
può essere automaticamente convertito a Number, ma solo alle giuste
condizioni. In main( ), possiamo vedere che aggiungere un Number
ad un altro Number funziona bene perchè c'è una corrispondenza esatta
con l'operatore sovraccaricato. Quando il compilatore vede un Number
seguito da un + e da un int int, può trovare la corrispondenza
con la funzione membro Number::operator+ e convertire l'argomento int
a Number usando il costruttore. Ma quando vede un int, un +
e un Number, non sa cosa fare perchè tutto quello che ha è Number::operator+,
che richiede che l'operando di sinistra sia già un oggetto di tipo Number.
Cosicchè il compilatore produce un errore.
Con il friend operator–,
le cose sono diverse. Il compilatore ha bisogno di inserire entrambi gli
argomenti, per quanto può; non è costretto ad avere un tipo Number
come argomento sul lato sinistro. Così, se vede
1 – a
può convertire il
primo argomento a Number usando il costruttore.
A volte si vuole
limitare l'uso dei propri operatori, rendendoli membri di classe. Per esempio,
quando si moltiplica una matrice per un vettore, il vettore deve stare sulla
destra. Ma se si vuole che gli operatori convertano entrambi gli argomenti,
bisogna definire l'operatore come funzione friend.
Fortunatamente il
compilatore se vede 1 – 1 non converte gli argomenti a oggetti di tipo Number
per poi chiamare l' operator–. Questo significherebbe che il codice C
esistente si troverebbe improvvisamente a funzionare in maniera diversa. Il
compilatore cerca dapprima il confronto più "semplice", che è l'operator
predefinito nell'espressione 1 – 1.
Un esempio in cui la
conversione automatica di tipo è estremamente utile accade con qualsiasi
classe che incapsula stringhe di caratteri (in questo caso possiamo
implementare la classe usando
semplicemente la classe string del C++ Standard, perchè è semplice). Senza la conversione
automatica di tipo, se si vogliono usare tutte le funzioni per la manipolazione
delle stringhe presenti nella libreria Standard del C, bisogna creare una
funzione membro per ognuna di esse, come questa:
//: C12:Strings1.cpp // Nessuna autoconversione di tipo #include "../require.h" #include <cstring> #include <cstdlib> #include <string> using namespace std; class Stringc { string s; public: Stringc(const string& str = "") : s(str) {} int strcmp(const Stringc& S) const { return ::strcmp(s.c_str(), S.s.c_str()); } // ... ecc., per ogni funzione in string.h }; int main() { Stringc s1("hello"), s2("there"); s1.strcmp(s2); } ///:~
Qui viene creata solo
la funzione strcmp( ), ma si può creare la funzione corrispondente
per ognuna di quelle presenti in <cstring>, se necessario.
Fortunatamente possiamo fornire una conversione automatica di tipo permettendo
l'accesso a tutte le funzioni in <cstring>:
//: C12:Strings2.cpp // Con auto conversione di tipo #include "../require.h" #include <cstring> #include <cstdlib> #include <string> using namespace std; class Stringc { string s; public: Stringc(const string& str = "") : s(str) {} operator const char*() const { return s.c_str(); } }; int main() { Stringc s1("hello"), s2("there"); strcmp(s1, s2); // Funzione C Standard strspn(s1, s2); // Qualunque funzione su string! } ///:~
Ora qualunque funzione
che prende un argomento di tipo char* può prendere anche un argomento
di tipo Stringc perchè il compilatore sa come costruire un char*
a partire da una Stringc.
Siccome il compilatore
deve scegliere il modo in cui effettuare silenziosamente la conversione, può
cadere in errore se non la progettiamo correttamente. Una situazione semplice
ed ovvia si presenta quando una classe X può convertire se stessa in un
oggetto di tipo Y con un operator
Y( ). Se la classe Y ha un costruttore che prende un unico
argomento di tipo X, questo rappresenta lo stesso tipo di conversione.
Il compilatore ha ora due modi di effettuare la conversione da X a Y,
e si può creare un'ambiguità :
//: C12:TypeConversionAmbiguity.cpp class Orange; // Dichiarazione di Classe class Apple { public: operator Orange() const; // Converte Apple in Orange }; class Orange { public: Orange(Apple); // Converte Apple in Orange }; void f(Orange) {} int main() { Apple a; //! f(a); // Errore: conversione ambigua } ///:~
La soluzione ovvia al
problema è di non fare ciò. Bisogna fornire una sola possibilità di
conversione automatica da un tipo ad un altro.
Un problema molto più
difficile da individuare accade quando si fornisce una conversione automatica
verso più tipi. Questa viene detta fan-out:
//: C12:TypeConversionFanout.cpp class Orange {}; class Pear {}; class Apple { public: operator Orange() const; operator Pear() const; }; //eat() sovraccaricata: void eat(Orange); void eat(Pear); int main() { Apple c; //! eat(c); // Errore: Apple -> Orange o Apple -> Pear ??? } ///:~
La classe Apple
ha una conversione automatica sia verso Orange che verso Pear.
La cosa insidiosa rispetto a questo è che non c'è nessun problema fino a quando
qualcuno non crea due versioni sovraccaricate della funzione eat( )
(con una sola versione il codice in main( ) funziona bene).
Di nuovo, la soluzione
– e la parola d'ordine generale con la conversione automatica dei tipi – è
quella di fornire una singola conversione automatica da un tipo a un altro. Si
possono avere conversioni verso altri tipi; ma queste possono semplicemente
essere non automatiche. Si possono definire chiamate a funzioni
esplicite con nomi come makeA( ) e makeB( ).
La conversione
automatica dei tipi può introdurre molte più attività sottostanti di quante
ci si possa aspettare. Come piccolo esercizio mentale, osserviamo questa
modifica al file CopyingVsInitialization.cpp:
//: C12:CopyingVsInitialization2.cpp class Fi {}; class Fee { public: Fee(int) {} Fee(const Fi&) {} }; class Fo { int i; public: Fo(int x = 0) : i(x) {} operator Fee() const { return Fee(i); } }; int main() { Fo fo; Fee fee = fo; } ///:~
Non c'è nessun costruttore
per creare l'oggetto Fee fee dall'oggetto Fo. Tuttavia, Fo
ha una funzione di conversione automatica verso Fee. Non c'è nessun costruttore
di copia per creare un oggetto di tipo Fee da un altro oggetto di tipo
Fee, ma questa è una delle funzioni speciali che il compilatore è in
grado di creare per noi (il costruttore di default, il costruttore di copia,
l'operator=, e il distruttore possono essere automaticamente sintetizzati
dal compilatore). Cosicchè per l'istruzione relativamente innocua
Fee fee = fo;
viene chiamato l' operatore
di conversione di tipo e viene creato un costruttore di copia.
Usare la conversione automatica di tipo con
attenzione. Come per ogni sovraccaricamento di operatore, è eccellente quando riduce significativamente
il codice da scrivere, ma non è meritevole di un uso gratuito.
La ragione dell'esistenza
del sovraccaricamento degli operatori è per quelle situazioni in cui questo
rende la vita più facile. Non c'è niente di particolarmente magico riguardo a
ciò; gli operatori sovraccaricati sono semplicemente delle funzioni con nomi
simpatici, e le chiamate a queste funzioni vengono fatte dal compilatore quando
questo riconosce la giusta corrispondenza. Ma se il sovraccaricamento non
fornisce un significativo beneficio al creatore della classe o all'utente della
stessa, è inutile complicare le cose con la sua aggiunta.
Le
soluzioni agli esercizi selezionati si possono trovare nel documento
elettronico The Thinking in C++ Annotated Solution Guide, disponibile su
www.BruceEckel.com dietro una piccola
ricompensa.
[49] Rob Murray, C++ Strategies & Tactics, Addison-Wesley, 1993, pagina 47.