L'overloading degli operatori
Ogni linguaggio di programmazione e` concepito per soddisfare
determinati requisiti; i linguaggi procedurali (come il C) sono stati
concepiti per realizzare applicazioni che non richiedano nel tempo piu` di
poche modifiche. Al contrario i linguaggi a oggetti hanno come obiettivo
l'estendibilita`, il programmatore e` in grado di estendere il linguaggio
per adattarlo al problema da risolvere, in tal modo diviene piu` semplice
modificare programmi creati precedentemente perche` via via che il problema
cambia, il linguaggio si adatta. Famoso in tal senso e` stato FORTH, un
linguaggio totalmente estensibile (senza alcuna limitazione), tuttavia nel
caso di FORTH questa grande liberta` si rivelo` controproducente perche`
spesso solo gli ideatori di un programma erano in grado di comprendene il
codice.
Anche il C++ puo` essere esteso, solo che per evitare i problemi di FORTH
vengono posti dei limiti: l'estensione del linguaggio avviene introducendo
nuove classi, definendo nuove funzioni e (vedremo ora) eseguendo
l'overloading degli operatori; queste modifiche devono tuttavia sottostare
a precise regole, ovvero essere sintatticamente corrette per il vecchio
linguaggio (in pratica devono seguire le regole precedentemente viste e
quelle che vedremo adesso).
Le prime regole
Cosi` come la definizione di classe deve
soddisfare precise regole sintattiche e semantiche, cosi` l'overloading di
un operatore deve soddisfare un opportuno insieme di requisiti:
- Non e` possibile definire nuovi operatori, si puo` solamente eseguire
l'overloading di uno per cui esiste gia` un simbolo nel linguaggio.
Possiamo ad esempio definire un nuovo operatore *, ma non
possiamo definire un operatore **.
Questa regola ha lo scopo di prevenire possibili ambiguita`.
- Non e` possibile modificare la precedenza di un operatore e non e`
possibile modificarne l'arieta` o l'associativita`, un operatore
unario rimarra`sempre unario, uno binario dovra` applicarsi sempre a
due operandi; analogamente uno associativo a sinistra rimmarra sempre
associativo a sinistra.
- Non e` concessa la possibilita` di eseguire l'overloading
di alcuni operatori, ad esempio l'operatore ternario ? :,
l'operatore sizeof e gli operatori di cast e in particolare
l'operatore .* e l'operatore punto (per la selezione dei campi
di una struttura).
- E` possibile ridefinire un operatore sia come funzione globale che
come funzione membro, i seguenti operatori devono tuttavia essere
sempre funzioni membro non statiche: operatore di assegnamento
( = ), operatore di sottoscrizione
( [ ] ) e l'operatore ->.
A parte queste poche restrizioni non esistono molti altri limiti, possiamo
ridefinire anche l'operatore virgola ( , ) e persino
l'operatore chiamata di funzione ( () ); inoltre non
c'e` alcuna restrizione riguardo il contenuto del corpo di un operatore: un
operatore altro non e` che un tipo particolare di funzione e tutto cio` che
puo` essere fatto in una funzione puo` essere fatto anche in un
operatore.
Un operatore e` indicato dalla keyword operator seguita dal simbolo
dell'operatore, per eseguirne l'overloading come funzione globale bisogna
utilizzare la seguente sintassi:
< ReturnType > operator@( < ArgumentList > ) { < Body > }
ReturnType e` il tipo restituito (non ci sono restrizioni);
@ indica un qualsiasi simbolo di operatore valido;
ArgumentList e` la lista di parametri (tipo e nome) che
l'operatore riceve, i parametri sono due per un operatore binario (il primo
e` quello che compare a sinistra dell'operatore quando esso viene
applicato) mentre e` uno solo per un operatore unario. Infine
Body e` la sequenza di istruzioni che costituiscono il corpo
dell'operatore.
Ecco un esempio di overloading di un operatore come funzione globale:
|
struct Complex {
float Re;
float Im;
};
Complex operator+(const Complex& A, const Complex& B) {
Complex Result;
Result.Re = A.Re + B.Re;
Result.Im = A.Im + B.Im;
return Result;
}
|
Si tratta sicuramente di un caso molto semplice, che fa capire che in fondo
un operatore altro non e` che una funzione. Il funzionamento del codice e`
chiaro e non mi dilunghero` oltre; si noti solo che i parametri sono
passati per riferimento, non e` obligatorio, ma solitamente e` bene passare
i parametri in questo modo (eventualmente utilizzando const come
nell'esempio).
Definito l'operatore, e` possibile utilizzarlo secondo l'usuale sintassi
riservata agli operatori, ovvero come nel seguente esempio:
|
Complex A, B;
/* ... */
Complex C = A + B;
|
L'esempio richiede che sia definito su Complex il costruttore di copia, ma
come gia` sapete il compilatore e` in grado di fornirne uno di default.
Detto questo il precedente esempio viene tradotto (dal compilatore) in
|
Complex A, B;
/* ... */
Complex C(operator+(A, B));
|
Volendo potete utilizzare gli operatori come funzioni, esattamente come li
traduce il compilatore (cioe` scrivendo Complex C =
operator+(A, B) o Complex C(operator+(A,
B))), ma non e` una buona pratica in quanto annulla il
vantaggio ottenuto ridefinendo l'operatore.
Quando un operatore viene ridefinito come funzione membro il primo
parametro e` sempre l'istanza della classe su cui viene eseguito e non
bisogna indicarlo nella lista di argomenti, un operatore binario quindi
come funzione globale riceve due parametri ma come funzione membro ne
riceve solo uno (il secondo operando); analogamente un operatore unario
come funzione globale prende un solo argomento, ma come funzione membro ha
la lista di argomenti vuota.
Riprendiamo il nostro esempio di prima ampliandolo con nuovi operatori:
|
class Complex {
public:
Complex(float re, float im);
Complex operator-() const; // - unario
Complex operator+(const Complex& B) const;
const Complex & operator=(const Complex& B);
private:
float Re;
float Im;
};
Complex::Complex(float re, float im = 0.0) {
Re = re;
Im = im;
}
Complex Complex::operator-() const {
return Complex(-Re, -Im);
}
Complex Complex::operator+(const Complex& B) const {
return Complex(Re+B.Re, Im+B.Im);
}
const Complex& Complex::operator=(const Complex& B) {
Re = B.Re;
Im = B.Im;
return B;
}
|
La classe Complex ridefinisce tre operatori. Il primo e` il
-(meno) unario, il compilatore capisce che si tratta del meno unario
dalla lista di argomenti vuota, il meno binario invece, come funzione
membro, deve avere un parametro. Successivamente viene ridefinito
l'operatore + (somma), si noti la differenza rispetto alla versione
globale. Infine viene ridefinito l'operatore di assegnamento che come detto
sopra deve essere una funzione membro non statica; si noti che a differenza
dei primi due questo operatore ritorna un riferimento, in tal modo possiamo
concatenare piu` assegnamenti evitando la creazione di inutili temporanei,
l'uso di const assicura che il risultato non venga utilizzato per
modificare l'oggetto. Infine, altra osservazione, l'ultimo operatore non e`
dichiarato const in quanto modifica l'oggetto su cui e` applicato
(quello cui si assegna), se la semantica che volete attribuirgli
consente di dichiararlo const fatelo, ma nel caso dell'operatore
di assegnamento (e in generale di tutti) e` consigliabile mantenere la
coerenza semantica (cioe` ridefinirlo sempre come operatore di
assegnamento, e non ad esempio come operatore di uguaglianza).
Ecco alcuni esempi di applicazione dei precedenti operatori e la loro
rispettiva traduzione in chiamate di funzioni (A,
B e C sono variabili di tipo
Complex):
|
B = -A; // B.operator=(A.operator-());
C = A+B; // C.operator=(A.operator+(B));
C = A+(-B); // C.operator=(A.operator+(B.operator-()))
C = A-B; // errore!
// complex& Complex::operator-(Complex&)
// non definito.
|
L'ultimo esempio e` errato poiche` quello che si vuole utilizzare e` il
meno binario, e tale operatore non e` stato definito.
Passiamo ora ad esaminare con maggiore dettaglio alcuni operatori che
solitamente svolgono ruoli piu` difficili da capire.
L'operatore di assegnamento
L'assegnamento e` un operatore molto
particolare, la sua semantica classica e` quella di modificare il valore
dell'oggetto cui e` applicato con quello ricevuto come parametro e
restituire poi tale valore al fine di consentire espressioni del tipo
|
A = B = C = < Valore >
|
che e` equivalente a
|
A = (B = (C = < Valore >));
|
Non lo si confonda con il costruttore di copia: il costruttore e`
utilizzato per costruire un nuovo oggetto inizializzandolo con il valore di
un altro, l'assegnamento viene utilizzato su oggetti gia` costruiti.
|
Complex C = B; // Costruttore di copia
/* ... */
C = D; // Assegnamento
|
Un'altra particolarita` di questo operatore lo rende simile al costruttore
(oltre al fatto che deve essere una funzione membro): se in una classe
non ne viene definito uno nella forma X::operator=(X&), il
compilatore ne fornisce uno che esegue la copia bit a bit. Lo standard
stabilisce che sia il costruttore di copia che l'operatore di assegnamento
forniti dal compilatore debbano eseguire non una copia bit a bit,
ma una inizializzazione o assegnamento a livello di membri chiamando il
costruttore di copia o l'operatore di assegnamento relativi al tipo di quel
membro. In ogni caso comunque e necessario definire esplicitamente sia
l'operatore di assegnamento che il costruttore di copia ogni qual volta la
classe contenga puntatori, onde evitare spiacevoli condivisioni di
memoria.
Notate infine che, come per le funzioni, anche per un operatore e`
possibile avere piu` versioni overloaded; in particolare una classe puo`
dichiarare piu` operatori di assegnamento, ma e` quello di cui sopra che il
compilatore fornisce quando manca.
L'operatore di sottoscrizione
Un altro operatore un po' particolare e`
quello di sottoscrizione [ ]. Si tratta di un operatore binario il
cui primo operando e` l'argomento che appare a sinistra di [, mentre
il secondo e` quello che si trova tra le parentesi quadre. La semantica
classica associata a questo operatore prevede che il primo argomento sia un
puntatore, mentre il secondo argomento deve essere un intero senza segno.
Il risultato dell'espressione Arg1[Arg2] e` dato da
*(Arg1+Arg2) cioe` il valore contenuto all'indirizzo
Arg1+Arg2.
Questo operatore puo` essere ridefinito unicamente come funzione membro
non statica e ovviamente non e` tenuto a sottostare al significato classico
dell'operatore fornito dal linguaggio. Il problema principale che si
riscontra nella definizione di questo operatore e` fare in modo che sia
possibile utilizzare indici multipli, ovvero poter scrivere
Arg1[Arg2][Arg3]; il trucco per riuscire in cio` consiste
semplicemente nel restituire un riferimento al tipo di Arg1,
ovvero seguire il seguente prototipo:
|
X& X::operator[](T Arg2);
|
dove T puo` essere anche un riferimento o un puntatore.
Restituendo un riferimento l'espressione Arg1[Arg2][Arg3]
viene tradotta in Arg1.operator[](Arg2).operator[](Arg3).
Il seguente codice mostra un esempio di overloading di questo operatore:
|
class TArray {
public:
TArray(unsigned int Size);
~TArray();
int operator[](unsigned int Index);
private:
int* Array;
unsigned int ArraySize;
};
TArray::TArray(unsigned int Size) {
ArraySize = Size;
Array = new int[Size];
}
TArray::~TArray() {
delete[] Array;
}
int TArray::operator[](unsigned int Index) {
if (Index < Size) return Array[Index];
else /* Errore */
}
|
Si tratta di una classe che incapsula il concetto di array per effettuare
dei controlli sull'indice, evitando cosi` accessi fuori limite.
La gestione della situazione di errore e` stata appositamente omessa,
vedremo meglio come gestire queste situazioni quando parleremo di
eccezioni.
Notate che l'operatore di sottoscrizione restituisce un int e non e`
pertanto possibile usare indicizzazioni multiple, d'altronde la classe e`
stata concepita unicamente per realizzare array monodimensionali di interi;
una soluzione migliore, piu` flessibile e generale avrebbe richiesto l'uso
dei template che saranno argomento del successivo
capitolo.
Pagina precedente - Pagina successiva
C++, una panoramica sul linguaggio - seconda edizione © Copyright 1996-1999, Paolo Marotta
|