ePerTutti


Appunti, Tesina di, appunto informatica

L'overloading degli operatori

ricerca 1
ricerca 2

L'overloading degli operatori


Ogni linguaggio di programmazione è concepito per soddisfare determinati requisiti; i linguaggi procedurali (come il C) sono stati concepiti per realizzare applicazioni che non richiedano nel tempo più di poche modifiche. Al contrario i linguaggi a oggetti hanno come obiettivo l'estendibilità, il programmatore è in grado di estendere il linguaggio per adattarlo al problema da risolvere, in tal modo diviene più semplice modificare programmi creati precedentemente perché via via che il problema cambia, il linguaggio si adatta.




Famoso in tal senso è stato FORTH, un linguaggio totalmente estensibile (senza alcuna limitazione), tuttavia nel caso di FORTH questa grande libertà si rivelò controproducente perché spesso solo gli ideatori di un programma erano in grado di comprendere il codice.


Anche il C++ può 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


Così come la definizione di classe deve soddisfare precise regole sintattiche e semantiche, così l'overloading di un operatore deve soddisfare un opportuno insieme di requisiti:


Non è possibile definire nuovi operatori, si può solamente eseguire l'overloading di uno per cui esiste già 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 ambiguità.

Non è possibile modificare la precedenza di un operatore e non è possibile modificarne l'arietà o l'associatività, un operatore unario rimarrà sempre unario, uno binario dovrà applicarsi sempre a due operandi; analogamente uno associativo a sinistra rimarrà sempre associativo a sinistra.

Non è concessa la possibilità di eseguire l'overloading dell'operatore ternario ? : .

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


A parte queste poche restrizioni non esistono altri limiti, possiamo ridefinire anche l'operatore virgola ( , ) e persino l'operatore chiamata di funzione ( () ); inoltre non c'è alcuna restrizione riguardo il contenuto del corpo di un operatore: un operatore altro non è che un tipo particolare di funzione e tutto ciò che può essere fatto in una funzione può essere fatto anche in un operatore.


Un operatore è indicato dalla keyword operator seguita dal simbolo dell'operatore, per eseguirne l'overloading come funzione globale bisogna utilizzare la seguente sintassi:


<ReturnType> operator@(<ArgumentList>)


ReturnType è il tipo restituito (non ci sono restrizioni); @ indica un qualsiasi simbolo di operatore valido; ArgumentList è la lista di parametri (tipo e nome) che l'operatore riceve, i parametri sono due per un operatore binario (il primo è quello che e a sinistra dell'operatore quando esso viene applicato) mentre è uno solo per un operatore unario. Infine Body è la sequenza di istruzioni che costituiscono il corpo dell'operatore.

Ecco un esempio di overloading di un operatore come funzione globale:


struct Complex ;


Complex operator+(const Complex & A, const Complex & B)


Si tratta sicuramente di un caso molto semplice, che fa capire che in fondo un operatore altro non è che una funzione. Il funzionamento del codice è chiaro e non mi dilungherò oltre; si noti solo che i parametri sono passati per riferimento, non è obbligatorio, ma solitamente è bene passare i parametri in questo modo (eventualmente utilizzando const come nell'esempio).

Definito l'operatore, è 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 già sapete il compilatore è in grado di fornirne uno di default. Detto questo il precedente esempio viene tradotto (dal compilatore) in


Complex C(operator+(A, B));


Volendo potete utilizzare gli operatori come funzioni, esattamente come li traduce il compilatore (cioè scrivendo Complex C = operator+(A, B) o Complex C(operator+(A, B))), ma non è una buona pratica in quanto annulla il vantaggio ottenuto ridefinendo l'operatore.


Quando un operatore viene ridefinito come funzione membro il primo parametro è 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 ;


Complex::Complex(float re, float im = 0.0)


Complex Complex::operator-() const


Complex Complex::operator+(const Complex & B) const


const Complex & Complex::operator=(const Complex & B)


La classe Complex ridefinisce tre operatori. Il primo è 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 più 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 è dichiarato const in quanto modifica l'oggetto su cui è 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) è consigliabile mantenere la coerenza semantica (cioè 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; // analogo a B.operator=(A.operator-());

C = A+B; // analogo a C.operator=(A.operator+(B));

C = A+(-B); // analogo a C.operator=(A.operator+(B.operator-()))

C = A-B; // errore!

// complex & Complex::operator-(Complex &) non definito.


L'ultimo esempio è errato poiché quello che si vuole utilizzare è il meno binario, e tale operatore non è stato definito.

Passiamo ora ad esaminare con maggiore dettaglio alcuni operatori che solitamente svolgono ruoli più difficili da capire.

L'operatore di assegnamento


L'assegnamento è un operatore molto particolare, la sua semantica classica è quella di modificare il valore dell'oggetto cui è applicato con quello ricevuto come parametro, l'operatore ritorna poi il valore che ha assegnato all'oggetto e ciò, grazie all'associatività a destra, consente espressioni del tipo


A = B = C = <Valore>


Questa espressione è equivalente a


A = (B = (C = <Valore>));


Non lo si confonda con il costruttore di copia: il costruttore è utilizzato per costruire un nuovo oggetto inizializzandolo con il valore di un altro, l'assegnamento viene utilizzato su oggetti già costruiti.


Complex C = B; // Costruttore di copia

/* */

C = D; // Assegnamento


Un'altra particolarità 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. Il draft ANSI-C++ 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 è possibile avere più versioni overloaded; in particolare una classe può dichiarare più operatori di assegnamento, ma è quello di cui sopra che il compilatore fornisce quando manca.

L'operatore di sottoscrizione


Un altro operatore un po' particolare è quello di sottoscrizione [ ]. Si tratta di un operatore binario il cui primo operando è l'argomento che appare a sinistra di [, mentre il secondo è 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] è dato da *(Arg1+Arg2*sizeof(T)) dove T è il tipo del puntatore; in pratica si somma al puntatore una quantità tale da ottenere un puntatore che punti più avanti di Arg2-l celle di tipo T.


Questo operatore può essere ridefinito unicamente come funzione membro non statica e ovviamente non è tenuto a sottostare al significato classico dell'operatore fornito dal linguaggio. Il problema principale che si riscontra nella definizione di questo operatore è fare in modo che sia possibile utilizzare indici multipli, ovvero poter scrivere Arg1[Arg2][Arg3]; il trucco per riuscire in ciò consiste semplicemente nel restituire un riferimento al tipo di Arg1, ovvero seguire il seguente prototipo:


X & X::operator[](T Arg2);


dove T può 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 ;


TArray::TArray(unsigned int Size)


TArray::~TArray()


int TArray::operator[](unsigned int Index)


Si tratta di una classe che incapsula il concetto di array per effettuare dei controlli sull'indice, evitando così accessi fuori limite. La gestione della situazione di errore è stata appositamente omessa, vedremo meglio come gestire queste situazioni quando parleremo di eccezioni.

Notate che l'operatore di sottoscrizione restituisce un int e non è pertanto possibile usare indicizzazioni multiple, d'altronde la classe è stata concepita unicamente per realizzare array monodimensionali di interi; una soluzione migliore, più flessibile e generale avrebbe richiesto l'uso dei template che saranno argomento del successivo modulo.

Operatori && e ||


Anche gli operatori di AND e OR logico possono essere ridefiniti, tuttavia c'è una profonda differenza tra quelli predefiniti e quelli che l'utente può 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 è 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 poiché il valore del primo è 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 è quello di dereferenziazione -> il cui comportamento è un po' difficile da capire.

Se T è una classe che ridefinisce -> (l'operatore di dereferenziazione deve essere un funzione membro non statica) e Obj è una istanza di tale classe, l'espressione


Obj->Field;


è valutata come


(Obj.operator->())->Fielg;


Conseguenza di ciò è 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 è 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 realtà un operatore binario il cui secondo argomento è il nome del campo di una struttura, mentre l'operatore che l'utente può ridefinire deve essere unario.

L'operatore virgola


Anche la virgola è un operatore (binario) che può essere ridefinito. La versione predefinita dell'operatore fa sì che entrambi gli argomenti siano valutati, ma il risultato prodotto è il valore del secondo (quello del primo argomento viene scartato).



Nella prassi comune, la virgola è 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 è 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 (a patto che il valore restituito fosse del tipo opportuno), che serviva solo per alcuni suoi effetti collaterali. Quanto alle parentesi, esse sono necessarie perché l'assegnamento ha la precedenza sulla virgola. Questo operatore è comunque sovraccaricato raramente.

Autoincremento e autodecremento


Gli operatori e meritano un breve accenno poiché 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.

La forma prefissa prende un solo argomento: l'oggetto cui è applicato, la forma postfissa invece possiede un parametro fittizio in più 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 è possibile accedere ad esso.

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


Poiché entrambi gli operatori hanno un prototipo predefinito, non è possibile avere più versioni overloaded, è possibile averne al più 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) sarà utilizzata in tutti gli altri casi.


La ridefinizione di new e delete è 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. La ridefinizione di questi operatori richiede l'inclusione del file new.h fornito con tutti i compilatori.

Ecco un esempio di new e delete globali:


void * operator new(size_t Size)


void operator delete(void * 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 è 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 è di tipo size_t : nel caso della 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 è di fornire la dimensione del blocco da deallocare (utile in parecchi casi). Nel caso in cui lo si utilizzi, è compito del programmatore supplire un valore per il secondo parametro (in effetti solo per il primo parametro della new è 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)


Questa new permette proprio la mappatura in memoria di un dispositivo hardware.


Per concludere c'è da dire che allo stato attuale non è possibile ridefinire le versioni per array di new e

delete, non potete quindi ridefinire gli operatori new[ ] e delete[ ].

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'è differenza tra un operatore definito globalmente e uno analogo definito come funzione membro, nel primo caso per ovvi motivi l'operatore viene dichiarato friend nelle classi cui appartengono i suoi argomenti; nel caso di una funzione membro, il primo argomento è sempre una istanza della classe e l'operatore può accedere a tutti i suoi membri, per quanto riguarda l'eventuale secondo argomento può essere necessaria dichiararlo friend nell'altra classe. Per il resto non ci sono differenze per il compilatore, nessuno dei due metodi è più efficiente dell'altro; tuttavia non sempre è possibile utilizzare una funzione membro, ad esempio se si vuole permettere il flusso su stream della propria classe , è necessario ricorrere ad una funzione globale, perché il primo argomento non è una istanza della classe:


class Complex ;


ostream & operator<<(ostream & os, Complex & C)


Adesso è possibile scrivere


Complex C(1.0, 2.3);


cout << C;






Privacy

© ePerTutti.com : tutti i diritti riservati
:::::
Condizioni Generali - Invia - Contatta