Overload degli operatori

 


 

Estendibilità del C++

 

In tutti i linguaggi, gli operatori sono dei simboli convenzionali che rendono più agevole la presentazione e lo sviluppo di concetti di uso frequente. Per esempio, la notazione:
              a
+b*c
risulta più agevole della frase:
             "moltiplica b per c aggiungi il risultato ad a"

L'utilizzo di una notazione concisa per le operazioni di uso comune è di importanza fondamentale.

Il C++ supporta, come ogni altro linguaggio, un insieme di operazioni per i suoi tipi nativi. Tuttavia la maggior parte dei concetti utilizzati comunemente non sono facilmente rappresentabili per mezzo di tipi nativi, e bisogna spesso fare ricorso ai tipi astratti. Per esempio, i numeri complessi, le matrici, i segnali, le stringhe di caratteri, le aggregazioni di dati, le code, le liste ecc... sono tutte entità che meglio si prestano a essere rappresentate mediante le classi. E' pertanto necessario che anche le operazioni fra queste entità possano essere descritte tramite simboli convenzionali, in alternativa alla chiamata di funzioni  specifiche (come avviene negli altri linguaggi), che non permetterebbero quella notazione concisa che, come si è detto, è di importanza fondamentale per una programmazione più semplice e chiara.

Il C++ consente di soddisfare questa esigenza tramite l'overload degli operatori: il programmatore ha la possibilità di creare nuove funzioni che ridefiniscono il significato dei simboli delle operazioni, rendendo queste applicabili anche ai tipi astratti (estendibilità del C++). La caratteristica determinante per il reale vantaggio di questa tecnica, è che, a differenza dalle normali funzioni, quelle che ridefiniscono gli operatori possono essere chiamate mediante il solo simbolo dell'operazione (con gli argomenti della funzione che diventano operandi): in definitiva la chiamata della  funzione "scompare" dal codice del programma e al suo posto si può inserire una "semplice e concisa" operazione. Per esempio, se viene creata una funzione che ridefinisce la somma (+) fra due oggetti, a e b, istanze di una certa classe, in luogo della chiamata della funzione si può semplicemente scrivere: a+b. Se si pensa che un'espressione può essere costituita da parecchie operazioni insieme, il vantaggio di questa tecnica per la concisione e la leggibilità del codice risulta evidente (in alternativa a ripetute chiamate di funzioni, "innestate" l'una nell'altra). Per esempio, tornando all'espressione iniziale, costituita da solo due operazioni:
          operatori in overload :         a+b*c
chiamata di funzioni  specifiche : somma(a,moltiplica(b,c))

 


 

Ridefinizione degli operatori

 

Per ottenere l'overload di un operatore bisogna creare una funzione il cui nome (che eccezionalmente non segue le regole generali di specifica degli identificatori) deve essere costituito dalla parola-chiave operator seguita, con o senza blanks in mezzo, dal simbolo dell'operatore (es.: operator+). Gli argomenti della funzione devono corrispondere agli operandi dell'operatore. Ne consegue che per gli operatori unari è necessario un solo argomento, per quelli binari ce ne vogliono due (e nello stesso ordine, cioè il primo argomento deve corrispondere al left-operand e il secondo argomento al right-operand).

Non è concesso "inventare" nuovi simboli, ma si possono solo utilizzare i simboli degli operatori esistenti. In più, le regole di precedenza e associatività restano legate al simbolo e non al suo significato, come pure resta legata al simbolo la categoria dell'operatore (unario o binario). Per esempio, un operatore in overload associato al simbolo della divisione (/) non può mai essere definito unario e ha sempre la precedenza sull'operatore  associato al simbolo +, qualunque sia il significato di entrambi.

E' possibile avere overload di quasi tutti gli operatori esistenti, salvo: ?:, sizeof, typeid e pochi altri, fra cui quelli (come :: e .) che hanno come operandi nomi non "parametrizzabili" (come i nomi delle classi o dei membri di una classe).

Come per le funzioni in overload, nel caso dello stesso operatore ridefinito più volte con tipi diversi, il C++ risolve l'ambiguità in base al contesto degli operandi, riconoscendone il tipo e decidendo di conseguenza quale operatore applicare.

Torniamo ora alla classe point e vediamo un esempio di possibile operatore di somma (il nostro intento è di ottenere la somma "vettoriale" fra due punti); supponiamo che la classe sia provvista di un costruttore con due argomenti:
   
          operazione :     p = p1+p2 ;
funzione somma : point operator+(const point& p1, const point& p2)
{
            point ptemp(0.0,0.0);
            ptemp.x = p1.x + p2.x ;
            ptemp.y = p1.y + p2.y ;
            return ptemp ;
}

Notare:

  1. la funzione ha un valore di ritorno di tipo point;

  2. gli argomenti-operandi sono passati by reference e dichiarati const, per maggiore sicurezza (const) e rapidità di esecuzione (passaggio by reference);

  3. nella funzione è definito un oggetto automatico (ptemp), inizializzato compatibilmente con il costruttore disponibile (vedere il problema della inizializzazione degli oggetti temporanei nel capitolo precedente);

  4. in ptemp i due operandi sono sommati membro a membro (la somma è ammessa in quanto fra due tipi double);

  5. in uscita ptemp (essendo un oggetto automatico) "muore", ma una sua copia è passata by value al chiamante, dove è successivamente assegnata a p

Nota ulteriore: è ammessa anche la chiamata della funzione nella forma tradizionale:
                p = operator+(p1, p2) ;
ma in questo caso si vanificherebbero i vantaggi offerti dalla notazione simbolica delle operazioni.

 


 

Metodi della classe o funzioni esterne?

 

Finora abbiamo parlato delle funzioni che ridefiniscono gli operatori in overload, senza preoccuparci di dove tali funzioni debbano essere definite. Quando esse accedono a membri privati della classe, possono appartenere soltanto a una delle seguenti tre categorie:

  1. sono metodi pubblici non statici della classe;

  2. sono metodi pubblici statici della classe;

  3. sono funzioni friend della classe.

Escludiamo subito che siano metodi statici, non perchè non sia permesso, ma perchè non sarebbe conveniente, in quanto un metodo statico può essere chiamato solo se il suo nome è qualificato con il nome della classe di appartenenza,
         es.:        p = point::operator+(p1, p2) ;
e quindi non esiste il modo di utilizzarlo nella rappresentazione simbolica di un'operazione.

Restano pertanto a disposizione solo i metodi non statici e le funzioni friend (o esterne, se non accedono a membri privati). La scelta più appropriata dipende dal contesto degli  operandi e dal tipo di operazione. In generale conviene che sia un metodo quando l'operatore è unario, oppure (e in questo caso è obbligatorio) quando il primo  operando è oggetto della classe e la funzione lo restituisce come  l-value, come accade per esempio per gli overload degli operatori di assegnazione (=) e in notazione compatta (+= ecc...). Viceversa, non ha molto senso che sia un metodo l'overload dell'addizione (che abbiamo visto come esempio nella sezione precedente), il quale opera su due oggetti e restituisce un risultato da memorizzare in un terzo.

La miglior progettazione degli operatori di una classe consiste nell'individuare un insieme ben definito di metodi per le operazioni che si applicano su un unico oggetto o che modificano il loro primo operando, e usare funzioni esterne (o friend) per le altre operazioni; il codice di queste funzioni risulta però facilitato, in quanto può utilizzare gli stessi operatori già definiti come metodi (vedremo più avanti un'alternativa dell'operatore + come funzione esterna, che usa l'operatore += implementato come metodo).
NOTA
: nei tipi astratti, l'esistenza degli operatori in overload + e = non implica che sia automaticamente definito anche l'operatore in overload +=

 


 

Il ruolo del puntatore nascosto this

 

E' chiaro a tutti perchè un'operazione che si applica su un unico oggetto o che modifica il primo operando è preferibile che sia implementata come metodo della classe? Perchè, in quanto metodo non staticopuò sfruttare la presenza del puntatore nascosto this, che, come sappiamo, punta allo stesso oggetto della classe in cui il metodo è incapsulato e viene automaticamente inserito dal C++ come primo argomento della funzione.
Ne consegue che:

  1. un operatore in overload può essere implementato come metodo di una classe solo se il primo operando è un oggetto della stessa classe; in caso contrario deve essere una funzione esterna (dichiarata friend se accede a membri privati) ;

  2. nella definizione del metodo il numero degli argomenti deve essere ridotto di un'unità rispetto al numero di operandi; in pratica, se l'operatore è binario, ci deve essere un solo argomento (quello corrispondente al secondo operando), se l'operatore è unario, la funzione non deve avere argomenti.

  3. se il risultato dell'operazione è l'oggetto stesso l'istruzione di ritorno deve essere:
    return
    *this;

   
Vediamo ora, a titolo di esempio, una possibile implementazione di overload dell'operatore in notazione compatta += della nostra classe point:

   
          operazione :     p += p1 ;
definizione metodo : point& point::operator+=(const point& p1)
{
            x += p1.x ;
            y += p1.y ;
            return *this ;
}

Notare:

  1. la funzione ha un un solo argomento, che corrisponde al secondo operando p1, in quanto il primo operando p è l'oggetto stesso, trasmesso per mezzo del puntatore nascosto this;

  2. la funzione è un metodo della classe, e quindi i membri dell'oggetto p sono indicati solo con il loro nome (il compilatore aggiunge this-> davanti a ognuno di essi);

  3. nel codice della funzione l'operatore += è "conosciuto", in quanto agisce sui membri della classe, che sono di tipo double;

  4. la funzione ritorna l'oggetto stesso p (deref. di this), by reference (cioè come  l-value), modificato dall'operazione (non esistono problemi di lifetime in questo caso, essendo l'oggetto p definito nel chiamante);

  5. la chiamata della funzione nella forma tradizionale sarebbe:
                   p.operator+=(p1) ;
    tradotta dal compilatore in:
                   operator+=(&p,p1) ;

   
Adesso che abbiamo definito l'operatore += come metodo della classe, l'implementazione dell'operatore  +, che invece preferiamo sia una funzione esterna, può essere fatta in modo più semplice (non occorre che sia dichiarata friend in quanto non accede a membri privati):

   
    operazione :     p = p1+p2 ;
funzione somma : point operator+(const point& p1, const point& p2)
{
            point ptemp = p1;  (uso il costruttore di copia)
            return ptemp += p2 ;
}

[p54]

 


 

Overload degli operatori di flusso di I/O

 

Un caso particolare rappresenta l'overload dell'I/O, cioè degli operatori di flusso "<<" (inserimento) e ">>" (estrazione). Notiamo che questi sono già degli operatori in overload, in quanto il significato originario dei simboli << e >> è quello di operatori di scorrimento di bit (se gli operandi sono  interi).

Se invece il left-operand non è un intero, ma l'oggetto cout, abbiamo visto che l'operatore << definisce un'operazione di output, che è eseguita "inserendo" in cout il dato da scrivere (costituito dal right-operand), il quale a sua volta può essere di qualunque tipo nativo o del corrispondente tipo puntatore (quest'ultimo è scritto come numero intero in forma esadecimale, salvo il tipo char *, che è interpretato come stringa).

Il nostro scopo è ora quello di creare un ulteriore overload di <<, in modo che anche un tipo astratto possa essere ammesso come  right-operand; per esempio potremmo volere che l'operazione:
               cout
<< a;            (dove a è un'istanza di una classe A)
generi su video una tabella dei valori assunti dai membri di a.

Per fare questo dobbiamo anzitutto sapere che cout, oggetto globale generato all'inizio dell'esecuzione del programma, é un'istanza della classe ostream, che viene detta "classe di flusso di output" (e dichiarata in <iostream.h>).
Inoltre il primo argomento passato alla funzione dovrà essere lo stesso oggetto cout (in quanto é il left-operand dell'operazione), mentre il secondo argomento, corrispondente al right-operand, dovrà essere l'oggetto a da trasferire in output.
Infine la funzione dovrà restituire by-reference lo stesso primo argomento (cioè sempre cout), per permettere l'associazione di ulteriori operazioni nella stessa istruzione.

Pertanto la funzione per l'overload di << dovrà essere così definita:
   
                ostream& operator<<(ostream& out, const A& a)
{
     ........ out << a.ma;  (ma è un membro di A di tipo nativo)
     ........ return out ;
}

Notare:

  1. il primo argomento della funzione appartiene a ostream e non ad A, e quindi la funzione non può essere un metodo di A, ma deve essere dichiarata come funzione friend nella definizione di A; viceversa, gli overload dell'operatore << con tipi nativi (e loro puntatori) sono definiti nella stessa classe ostream, e quindi sono metodi di quella classe;

  2. il valore di ritorno della funzione è trasmesso by-reference, in quanto deve essere un l-value di successive operazioni impilate;

  3. poichè nel chiamante il primo argomento è l'oggetto cout, il ritorno by-reference dello stesso  oggetto non rischia mai di creare problemi di lifetime;

  4. per i motivi suddetti, e per l'associatività dell'operatore <<, che procede da sinistra a destra, si possono impilare più operazioni di output in una stessa istruzione. Esempio:
             cout << a1 << a2 << a3;
    dove a1, a2 e a3 sono tutte istanze di A

   

Analogamente, si può definire un overload dell'operatore di estrazione ">>" per le operazioni di input (per esempio, cin >> a;), tramite la funzione:
       istream&
operator>>(istream& inp, A& a)
dove istream è la classe di flusso di input (anch'essa dichiarata in <iostream.h>), a cui appartiene l'oggetto globale cin. Notare che in questo caso il secondo argomento (cioè a), sempre passato by-referencenon è dichiarato const, in quanto l'operazione lo deve modificare.

[p55]

 


 

Operatori binari e conversioni

 

Analogamente a quanto visto negli esempi finora riportati, si possono definire gli overload dei seguenti operatori binari :

e di altri che tratteremo separatamente (per una maggiore leggibilità del programma, si consiglia, anche se non è obbligatorio, che gli overload di questi operatori mantengano comunque qualche "somiglianza" con il loro significato originario).

Tutti gli operatori sopra riportati avranno ovviamente almeno un operando che è oggetto della classe, non importa se left o right (a parte gli operatori in notazione compatta, per i quali l'oggetto della classe deve essere  sempre left). L'altro operando può essere un altro oggetto della stessa classe (come nell'esempio della somma che abbiamo visto prima), oppure un oggetto di qualsiasi altro tipo, nativo o astratto. Pertanto possono esistere parecchi overload dello stesso operatore, ciascuno con un operando di tipo diverso. Non solo, ma se si vuole salvaguardare la proprietà "commutativa" di certe operazioni (+  *  &  |  ^  ==  !=  &&  ||), o la "simmetria" di altre (< con  >= e > con <=), occorrono, per ognuna di esse, due funzioni, delle quali per giunta una può essere metodo e l'altra no.

Ne consegue che, se gli operatori da applicare in overload a una certa classe non sono progettati attentamente, si rischia di generare una pletora di funzioni, con varianti spesso molto piccole da una all'altra.

Il C++ offre una soluzione a questo problema, che è molto semplice ed efficace: il numero di funzioni può essere minimizzato utilizzando i costruttori con un argomento, che, come abbiamo visto, definiscono anche una conversione implicita di tipo: se "attrezziamo" la classe con un insieme opportuno di costruttori con un argomento, possiamo ottenere che tutti i tipi coinvolti nelle operazioni siano convertiti implicitamente nel tipo della classe e che ogni operazione sia perciò implementata da una sola funzione, quella che opera su due oggetti della stessa classe. Notare che la conversione implicita viene eseguita indipendentemente dalla posizione dell'operando, e ciò permette in particolare che ogni operazione "commutativa" sia definibile con una sola funzione.

Riprendendo la nostra classe point, vogliamo per esempio definire un operazione di somma fra un vettore p, oggetto di point, e un valore s di tipo double (detto: "scalare"), in modo tale che lo scalare venga sommato a ogni componente del vettore. Se definiamo il costruttore:
                         point::point(double d)  :  x(d),   y(d)  { }
otterremo che entrambe le operazioni:
                           p + s            e            s + p
comportino la conversione implicita di s da tipo double a tipo  point, e si trasformino nell'unica operazione di somma fra due oggetti di point (della quale abbiamo già visto un esempio di implementazione).

[p56]

 


 

Operatori unari e casting a tipo nativo

 

Si possono definire gli overload dei seguenti operatori unari :

Gli operatori unari devono avere come unico operando un oggetto della classe in cui sono definiti e quindi possono convenientemente essere definiti come metodi della stessa classe, nel qual caso le funzioni che li implementano devono essere senza argomenti.

Tutti gli operatori sopra menzionati sono prefissi dell'operando, salvo gli operatori di incremento e decremento che possono essere sia prefissi che suffissi. Per distinguerli, è applicata la seguente convenzione: se la funzione è senza argomenti, si tratta di un prefisso, se la funzione contiene un argomento fittizio di tipo int (che il sistema non usa in quanto l'operatore è unario) si tratta di un suffisso. Inoltre, per i prefissi, il valore di ritorno deve essere passato by reference, mentre per i suffissi deve essere passato by value (questo perché i prefissi possono essere degli l-values mentre i suffissi no). Infine, gli operatori suffissi devono essere progettati con particolare attenzione, se si vuole conservare la loro proprietà di eseguire un'operazione "posticipata", nonostanza la precedenza alta. Per  esempio, un operatore di incremento suffisso di una generica classe A, potrebbe essere implementato così (supponiamo che il corrispondente operatore prefisso sia già stato definito):
                                                                                   A  A::operator++(int)
{
        A temp = *this;  
        ++*this ;
        return temp ;
}

come si può notare, l'oggetto è correttamente incrementato, ma al chiamante non torna l'oggetto stesso, bensì una sua copia precedente (temp); in questo modo, non è l'oggetto, ma la sua copia precedente ad essere utilizzata come operando nelle eventuali successive operazioni dell'espressione di cui fa parte; solo dopo che l'intera espressione è stata eseguita, un nuovo accesso al nome dell'oggetto ritroverà l'oggetto incrementato.

   
Un caso a parte è quello dell'operatore di casting. Come abbiamo visto, la conversione di tipo può essere eseguita usando un costruttore con un argomento: questo consente conversioni, anche implicite, da tipi nativi a tipi astratti (o fra tipi astratti), ma non può essere utilizzato per conversioni da  tipi astratti  a tipi nativi, in quanto i tipi nativi non hanno costruttori con un argomento. A questo scopo occore invece definire esplicitamente un overload dell'operatore di casting, che deve essere espresso nella seguente forma (esempio di casting da una classe A a double):
                                 A::operator double( )

notare che il tipo di ritorno non deve essere specificato in quanto il C++ lo riconosce già dal nome della funzione; notare anche che esiste uno spazio (obbligatorio) fra le parole operator e double.

La conversione può essere eseguita implicitamente o esplicitamente, in C-style o in function-style. Se è eseguita implicitamente, può verificarsi un'ambiguità nel caso sia definita anche la conversione in senso inverso. Esempio:
              A  a ;    double  d ;  
a + d ; deve convertire un tipo A in double o un double in A ?

Nell'esempio sopra riportato si è supposto che:

  1. la  classe A abbia un metodo che definisce un overload dell'operatore di casting da A a double;

  2. la  classe A abbia un costruttore con un argomento double;

  3. esista una funzione esterna che definisce un overload dell'operatore di somma fra due oggetti di A.

in queste condizioni il compilatore segnala un errore di ambiguità, perchè non sa quale delle due conversioni implicite selezionare. In ogni caso, quando si tratta di operatori in overload, il C++ non fa preferenza fra i metodi della classe e le altre funzioni .

[p57]

 


 

Operatori in namespace

 

Abbiamo visto che, per una migliore organizzazione degli operatori in overload di una classe, è preferibile utilizzare in maggioranza funzioni  non metodi (se si tratta di operatori binari), che si appoggino a un insieme limitato di metodi della classe. Non ci siamo mai chiesti, però, in quale ambito sia conveniente che tali funzioni vengano definite e, per semplicità, negli esempi (ed esercizi) finora riportati abbiamo sempre definito le funzioni nel namespace globale.

Questo non è, tuttavia, il modo più corretto di procedere. Come abbiamo detto più volte, un affollamento eccessivo del namespace globale può essere fonte di confusione e di errori, specialmente in programmi di grosse dimensioni e con diversi programmatori che lavorano ad un unico progetto.

E' pertanto preferibile "racchiudere" la classe e le funzioni esterne che implementano gli operatori della classe in un namespace definito con un nome. In questo modo non si "inquina" il namespace globale e, nel contempo, si può mantenere la notazione simbolica nella chiamata delle operazioni. Infatti, a differenza dai metodi statici, che devono essere sempre qualificati con il nome della classe, una funzione appartenente a un namespace non ha bisogno di essere qualificata con il nome del namespace, se appartiene allo stesso namespace almeno uno dei suoi argomenti.

In generale, data una generica operazione (usiamo l'operatore @, che in realtà non esiste, proprio per indicare un'operazione qualsiasi):
          a @ b         (dove a è un'istanza di una classe A  e  b è un' istanza di una classe B)

il compilatore esegue la ricerca della funzione operator@  nel seguente modo:

Non sono fissati criteri di preferenza: se sono trovate più definizioni di operator@, il compilatore, se può, sceglie la "migliore" (per esempio, quella in cui i tipi degli operandi corrispondono esattamente, rispetto ad altre in cui la corrispondenza è ottenuta dopo una conversione implicita), altrimenti segnala l'ambiguità

Nel caso che operator@ sia trovata nel namespace in cui è definita una delle due classi, la funzione deve essere comunque dichiarata friend in entrambe le classi (se in entrambe accede a membri privati); ciò potrebbe far sorgere un problema di dipendenza circolare, problema che peraltro si risolve mediante dichiarazione anticipata di una delle classi (per fortuna un namespace si può spezzare in più parti!)

[p58][p58] [p58]

 


 

Oggetti-array e array associativi

 

Tratteremo ora di alcuni overload di operatori binari, da implementare obbligatoriamente come metodi, in quanto il loro primo  operando è oggetto della classe e l-value modificabile. Fermo restando il fatto che la ridefinizione del significato di un operatore in overload è assolutamente libera, questi operatori vengono comunemente ridefiniti con significati specifici.

     
Oggetti-array

Il primo overload che esaminiamo è quello dell'operatore indice [], che potrebbe servire, per esempio, se un membro della classe è un array. In tal caso, rinunciando, per non avere ambiguità, a trattare array di oggetti, ma solo il membro array di ogni oggetto, l'overload dell'operatore indice potrebbe essere definito come  nel seguente esempio:
data una classe A
:           class A  { int m[10] ;  ........ } ;
e una sua istanza a, vogliamo che l'operazione: a[i] non indichi l'oggetto di indice i di un array di oggetti a (come sarebbe senza overload di []), ma l'elemento di indice i del membro-array m dell'oggetto a. Per ottenere questo, basta definire in A il seguente metodo:

int& A::operator[] (const int& i) { return m[i]; }

da notare che il valore di ritorno è un riferimento, e questo fa sì che l'operatore [] funzioni come un l-value, rendendo possibili, non solo operazioni di estrazione, come:
                  num = a[i];
ma anche operazioni di inserimento, come:
                  a[i] = num;

Gli oggetti costituiti da un solo membro-array (o in cui il membro-array è predominante) sono talvolta detti: oggetti-array. Rispetto ai normali array, presentano il vantaggio di poter disporre delle funzionalità in più offerte dalla classe di appartenenza; per esempio possono controllare il valore dell'indice, sollevando eccezione in caso di overflow, oppure modificare la dimensione dell'array (se il membro-array è dichiarato come puntatore) ecc...

[p59][p59] [p59]

     
Array associativi

L' operatore indice ha un campo di applicazione molto più vasto e generalizzato di un normale array. Infatti non esiste nessuna regola che obblighi il secondo operando a essere un intero, come è l'indice di un array; al contrario, lo si può definire di un qualsiasi tipo, anche astratto, e ciò permette di stabilire una corrispondenza (o, come talvolta si dice, un'associazione) fra oggetti di due classi. Un array associativo, spesso chiamato mappa o anche dizionario, memorizza coppie di valori: dato un valore, la chiave, si può accedere all'altro, il valore mappato. La funzione che implementa l'overload dell' operatore indice fornisce l'algoritmo di mappatura, che associa un oggetto della classe (primo operando) a ogni valore della chiave (secondo operando).

 


 

Oggetti-funzione

 

Anche l'operatore di chiamata di una  funzione può essere ridefinito. In questo caso il primo operando deve essere un oggetto della classe (nascosto da this) e il secondo operando è una lista di espressioni, che viene valutata e trattata secondo le normali regole di passaggio degli argomenti di una funzione. Il metodo che implementa l'overload di questo operatore deve essere definito nel seguente modo (supponiamo che il nome della  classe sia A):

tipo del valore di ritorno  A::operator() (lista di argomenti) {  ........ }

L'uso più frequente dell'operatore () si ha quando si vuole fornire la normale sintassi della chiamata di una  funzione a oggetti che in qualche modo si comportano come funzioni (cioè che utilizzano in modo predominante un loro metodo). Tali oggetti sono spesso chiamati oggetti-funzione. Rispetto a una normale funzione, un oggetto-funzione ha il vantaggio di potersi "appoggiare" a una classe, e quindi di utilizzare le informazioni già memorizzate nei suoi membri, senza bisogno di dover trasmettere ogni volta queste informazioni come argomenti aggiuntivi nella chiamata.

[p60][p60] [p60]

 


 

Puntatori intelligenti

 

Abbiamo detto all'inizio che non tutti gli operatori possono essere ridefiniti in overload e in particolare non è ammesso ridefinire quegli operatori i cui operandi sono nomi non "parametrizzabili"; citiamo, a questo proposito, l'operatore di risoluzione di visibilità (::), in cui il left-operand è il nome di una classe o di un namespace, e gli operatori di selezione di un membro (. e ->), in cui il right-operand è il nome di un membro di una classe.

A questa regola fa eccezione l'operatore ->, che può essere ridefinito; ma, proprio perchè il suo right-operand non può essere trasmesso come argomento di una funzione, l'operatore -> in overload è "declassato" da operatore binario a operatore unario suffisso e mantiene, come unico operando, il suo originario left-operand, cioè l'indirizzo di un oggetto. La funzione che implementa questo (strano) overload deve essere un metodo di una classe, dal che si deduce che gli oggetti di tale classe possono essere usati come puntatori per accedere ai membri di un'altra classe. Per esempio, data una classe Ptr_to_A:

class Ptr_to_A  {  ........  public: A* operator->( );  ........ } ;

le sue istanze possono essere utilizzate per accedere a istanze della classe A, in una maniera molto simile a quella in cui sono utilizzati i normali puntatori

Se il metodo viene chiamato come una normale funzione, il suo valore di ritorno può essere usato come puntatore ad un oggetto di A; se invece si adotta la notazione simbolica dell'operazione, le regole di sintassi pretendono che il nome di un membro di A venga comunque aggiunto. Per chiarire, continuiamo nell'esempio precedente:
       
            Ptr_to_A  p ;
A* pa = p.operator->( );         OK
A* pa = p->; errore di sintassi
int num = p->ma; OK (ma è un membro di A di tipo int)
p->ma = 7 ; OK (può anche essere un l-value)

   
L'overload di -> è utile principalmente per creare puntatori "intelligenti", cioè oggetti che si comportano come puntatori, ma con il vantaggio di poter disporre delle funzionalità in più offerte dalla classe di appartenenza (esattamente come gli oggetti-array e gli oggetti-funzione).

C'è da sottolineare infine che, come di regola, la definizione dell' overload di -> non implica che siano automaticamente definite le operazioni equivalenti. Infatti, mentre per i normali puntatori valgono le seguenti uguaglianze:
                     p->ma =
=  (*p).ma = = p[0].ma
le stesse continuano a valere per gli operatori in overload solo se tutti gli operatori sono definiti in modo tale da produrre volutamente tale risultato.

[p61][p61] [p61]

 


 

Operatore di assegnazione

 

Abbiamo lasciato per ultimo di questo gruppo l'overload dell'operatore di assegnazione (=), non perchè fosse il meno importante (anzi ...), ma semplicemente perchè, negli esempi (e negli esercizi) finora riportati, non ne abbiamo avuto bisogno. Infatti, come già per il costruttore senza argomenti e per il costruttore di copia, il C++ fornisce un operatore di assegnazione di default, che copia membro a membro l'oggetto right-operand nell'oggetto left-operand.
Nota  

In alcune circostanze si potrebbe non desiderare che un oggetto venga costruito per copia o assegnato. Ma, se non si definiscono overload, il C++ inserirà quelli di default, e se invece li si definiscono, il programma li userà direttamente. Come fare allora? La soluzione è semplice: definire degli overload fittizi e collocarli nella sezione privata della classe; in questo modo gli overload  ridefiniti "nasconderanno" quelli di default, ma a loro volta saranno inaccessibili in quanto metodi non pubblici.

L'assegnazione mediante copia membro a membro può essere esattamente ciò che si vuole nella maggioranza dei casi, e quindi non ha senso ridefinire l'operatore. Ma, se la classe possiede membri puntatori, la semplice copia di un puntatore può generare due problemi:

Come si può notare, il secondo problema è identico a quello che si presenterebbe usando il costruttore di copia di default, mentre il primo è specifico dell'operatore di assegnazione (in quanto la copia viene eseguita su un oggetto già esistente).

Anche in questo caso, è perciò necessario che l'operatore di assegnazione esegua la copia, non del puntatore, ma dell'area puntata. Per evidenziare analogie e differenze, riprendiamo l'esempio del costruttore di copia del capitolo precedente (complicandolo un po', cioè supponendo che l'area puntata sia un array con dimensioni definite in un ulteriore membro della classe), e gli affianchiamo un esempio di corretto metodo di implementazione dell'operatore di assegnazione:

COSTRUTTORE DI COPIA OPERATORE DI ASSEGNAZIONE

operazioni :  

A a1 ;  ........ A a2 = a1 ;

A a1 , a2 ;  ........ a2 = a1 ;

A::A(const A& a) A& A::operator=(const A& a)
CLASSE
class A {
       int* pa;
       int dim;
public:
   A( );
   A(const A&);
   A& operator=
      (const
A&);
   ........  };
{ {
  dim = a.dim ;    if (this == &a) return *this;
  pa = new int [dim] ;    if (dim != a.dim)
   for(int i=0; i < dim; i++)
       *(pa+i) = *(a.pa+i) ;
  {

}

      delete [] pa;

      dim = a.dim ;
      pa = new int [dim] ;
  }
   for(int i=0; i < dim; i++)
       
*(pa+i) = *(a.pa+i) ;
   return *this;
}

Notare:

  1. la prima istruzione: if (this == &a) return *this; serve a proteggersi dalla cosidetta auto-assegnazione (a1 = a1); in questo caso la funzione deve restituire l'oggetto stesso senza fare altro;

  2. il metodo che implementa l'operatore di assegnazione è un po' più complicato del costruttore di copia, in quanto deve deallocare (con delete) l'area precedentemente puntata dal membro pa di a2 prima di allocare  (con new) la nuova area; tuttavia, se le aree puntate dai membri pa di a2 e a1 sono di uguali dimensioni, non è necessario deallocare e riallocare, ma si può semplicemente riutilizzare l'area già esistente di  a2 per copiarvi i nuovi dati;

  3. entrambi i metodi eseguono la copia (tramite un ciclo for) dell'area puntata e non del puntatore, come avverrebbe se si lasciasse fare ai metodi di default;

  4. la classe dovrà contenere altri metodi (o altri costruttori) che si occupano dell'allocazione iniziale dell'area e dell'inserimento dei dati; per semplicità li abbiamo omessi.

[p62]

 


 

Ottimizzazione delle copie

 

Tanto per ribadire il vecchio detto che "non è saggio chi non si contraddice mai", ci contraddiciamo subito: a volte può essere preferibile copiare i puntatori  e non le aree puntate! Anzi, in certi casi può essere utile creare ad-hoc un puntatore a un oggetto (apparentemente non necessario), proprio allo scopo di copiare il puntatore al posto dell'oggetto

Supponiamo, per esempio, che un certo oggetto a1 sia di "grosse dimensioni" e che, a un certo punto del programma, a1 debba essere assegnato a un altro oggetto a2, oppure un altro oggetto a2 debba essere costruito e inizializzato con a1. In entrambi i casi sappiamo che a1 viene copiato in a2. Ma la copia di un "grosso" oggetto può essere particolarmente onerosa, specie se effettuata parecchie volte nel programma. Aggiungasi il fatto che spesso vengono creati e immediatamente distrutti oggetti temporanei, che moltiplicano il numero delle copie, come si evince dal seguente esempio:
                                        a2 = f(a1);
in questa istruzione vengono eseguite ben 3 copie!

Ci chiediamo a questo punto: ma se, nel corso del programma, a1 e a2 non vengono modificati, che senso ha eseguire materialmente la copia? Solo la modifica di almeno uno dei due creerebbe di fatto due oggetti distinti, ma finchè ciò non avviene, la duplicazione "prematura" sarebbe un'operazione inutilmente costosa. In base a questo ragionamento, se si riuscisse a creare un meccanismo, che, di fronte a una richiesta di copia, si limiti a "prenotarla", ma ne rimandi l'esecuzione al momento dell'eventuale modifica di uno dei due oggetti (copy on write),  si otterrebbe lo scopo di ottimizzare il numero di copie, eliminando tutte quelle che, alla fine, sarebbero risultate inutili.

Puntualmente, è il C++ che mette a disposizione questo meccanismo. L'idea base è quella di "svuotare" la classe (che chiamiamo  A) di tutti i suoi dati-membro, lasciandovi solo i metodi (compresi gli eventuali metodi che implementano gli operatori in overload) e al loro posto inserire un unico membro, puntatore a un'altra classe (che chiamiamo  Arep). Questa seconda classe, che viene preferibilmente definita come struttura, è detta "rappresentazione" della classe A, e in essa vengono inseriti tutti i dati-membro che avrebbero dovuto essere di A. In questa situazione, si dice che A è implementata come handle (aggancio) alla sua rappresentazione, ma è la stessa rappresentazione (cioè la struttura Arep) che contiene realmente i dati.

Più oggetti di A possono "condividere" la stessa rappresentazione (cioè puntare allo stesso di oggetto di Arep). Per tenere memoria di ciò, Arep deve contenere un ulteriore membro, di tipo int, in cui contare il numero di oggetti di A agganciati; questo numero, inizializzato con 1, viene incrementato ogni volta che è "prenotata" una copia, e decrementato ogni volta che uno degli oggetti di A agganciati subisce una modifica: nel primo caso, la copia viene eseguita solo fra i membri puntatori dei due oggetti di A (in modo che puntino allo stesso oggetto di Arep); nel secondo caso, uno speciale metodo di Arep fa sì che l'oggetto di Arep "si cloni", cioè crei un nuovo oggetto copia di se stesso, su questo esegua le modifiche richeste, e infine ne assegni l'indirizzo al membro puntatore dell'oggetto di A da cui è provenuta la richiesta di modifica. Ovviamente spetta ai metodi di A individuare quali operazioni comportino la modifica di un suo oggetto e attivare le azioni conseguenti che abbiamo descritto. Per concludere, il distruttore di un oggetto di A deve decrementare il contatore di agganci nel corrispondente oggetto di Arep, e poi procedere alla distruzione di detto oggetto solo se il contatore è diventato zero.

Da notare che una rappresentazione è sempre creata nella memoria heap e quindi non ha problemi di lifetime, anche se gli oggetti che l'agganciano sono automatici: questo è particolarmente utile, per esempio, nel passaggio by value degli argomenti e del valore di ritorno fra chiamante e funzione (e viceversa): la copia viene eseguita solo apparentemente, in quanto permane la stessa unica rappresentazione, che sopravvive anche in ambiti di visibilità diversi da quello in cui è stata creata. Per esempio, tornando alla nostra istruzione:
                                        a2 = f(a1);
almeno 2 delle 3 copie previste non vengono eseguite, in quanto l'oggetto a2 si aggancia direttamente alla rappresentazione creata dall'oggetto locale di f, passato come valore di ritorno (prima copia "risparmiata") e successivamente assegnato ad a2 (seconda copia "risparmiata"); per quello che riguarda la terza copia (passaggio di a1 dal chiamante alla funzione), questa è realmente eseguita solo se il valore locale di a1 è modificato in f, altrimenti entrambi gli oggetti continuano a puntare alla stessa rappresentazione creata nel chiamante, fino a quando f termina e quindi l'a1 locale "muore" senza che la copia sia mai stata eseguita.

E' preferibile che Arep sia una struttura perchè così tutti i suoi membri sono pubblici di default. D'altra parte una rappresentazione di una classe deve essere accessibile solo dalla classe stessa. Pertanto Arep deve essere pubblica per A e privata per il "mondo esterno". Per ottenere questo, bisogna definire Arep "dentro" A (struttura-membro o struttura annidata), nella sua sezione privata (in questo modo non può essere istanziata se non da un metodo di A). Più elegantemente si può inserire in A la semplice dichiarazione di Arep e collocare esternamente la sua definizione; in questo caso, però, il suo nome deve essere qualificato:
               struct A::Arep
{ ........  };

Nell'esercizio che riportiamo come esempio tentiamo una "rudimentale" implementazione di una classe "stringa", al solo scopo di fornire ulteriori chiarimenti su quanto detto (l'esercizio è eccezionalmente molto commentato). Non va utilizzato nella pratica, in quanto la Libreria Standard fornisce una classe per la gestione delle stringhe molto più completa.

[p63][p63] [p63]

     
Nel prossimo esercizio consideriamo i tempi delle copie di oggetti del tipo "stringa" implementato come nell'esercizio precedente (cioè come handle a una rappresentazione), e li confrontiamo con i tempi ottenuti copiando le stringhe direttamente.

[p64][p64] [p64]

 


 

Espressioni-operazione

 

Quando si ha a che fare con espressioni che contengono varie operazioni, sappiamo che ogni operazione crea un oggetto temporaneo, che è usato come operando per l'operazione successiva, secondo l'ordine fissato dai criteri di precedenza e associatività fra gli operatori. Quando tutte le operazioni di un'espressione sono state eseguite (cioè, come si dice, l'espressione è stata valutata), tutti gli oggetti temporanei creati durante la valutazione dell'espressione vengono distrutti. Pertanto ogni oggetto temporaneo vien costruito, passato come operando, e alla fine, distrutto, senza svolgere altra funzione.

Normalmente ogni operazione viene eseguita mediante chiamata della funzione che implementa l'overload del corrispondente operatore: questa funzione di solito costruisce un oggetto locale, che poi ritorna per copia al chiamante (salvo i casi in cui l'oggetto del  valore di ritorno coincida con uno degli operandi, il passaggio non può essere eseguito by reference, perchè l'oggetto locale passato non sopravvive alla funzione). E quindi, in ogni operazione, viene non solo costruito ma anche copiato un oggetto temporaneo!

Se gli oggetti coinvolti nelle operazioni sono di "grosse dimensioni" (e soprattutto se le operazioni sono molte), il costo computazionale per la costruzione e la copia degli oggetti temporanei  potrebbe essere troppo elevato, e quindi bisogna trovare il modo di ottimizzare le prestazioni del programma minimizzando tale costo. In pratica bisogna ridurre al minimo:

La tecnica, anche in questo caso, consiste nella semplice "impostazione" di ogni operazione (senza eseguirla), tramite un  handle a una struttura, che funge da "rappresentazione" dell'operazione stessa; solo alla fine, l'intera espressione viene eseguita tutta in una volta, senza creazione di oggetti temporanei, con il minimo numero possibile di cicli, e senza copie di passaggio. Questa tecnica sostanzialmente tratta  un'espressione come unica operazione, traducendo n operatori binari in un solo operatore con n+1 operandi.

Supponiamo, per esempio, di avere la seguente espressione:

a = b * c d ;

e supponiamo per semplicità (anche se non è obbligatorio) che gli oggetti: a, b, c e d appartengano tutti alla stessa classe A. Siamo in presenza di tre operazioni binarie (che, nell'ordine di esecuzione sono: moltiplicazione, somma e assegnazione), ma vogliamo, per l'occasione, trasformarle in un'unica operazione "quaternaria" che esegua, in un sol colpo, l'intera espressione. Per ottenere questo, procediamo nel seguente modo:

  1. definiamo un overload della moltiplicazione fra due oggetti di A, che, anzichè eseguire l'operazione, si limita a istanziare una struttura di appoggio (che chiamiamo M), la quale non fa altro che memorizzare i riferimenti ai due operandi (in altre parole, il suo costruttore inizializza due suoi membri, dichiarati come riferimenti ad A, come alias di b e c); a sua volta, M contiene un metodo di casting ad A, che esegue materialmente la moltiplicazione, ma che viene chiamato solo se l'operazione rientra in  un altro contesto (ricordiamo che, nella scelta dell'overload più appropriato, il compilatore cerca prima fra quelli in cui i tipi degli operandi coincidono esattamente, e poi fra quelli in cui la coincidenza si ha tramite una conversione di tipo);

  2. definiamo un overload della somma fra un oggetto di M e un oggetto di A, che, anche in questo caso, si limita a istanziare una struttura di appoggio (che chiamiamo MS), la quale, esattamente come M, memorizza i riferimenti ai due operandi e contiene un metodo di casting ad A;

  3. infine, definiamo un overload del costruttore e dell'operatore di assegnazione di A, entrambi con un oggetto di MS come argomento, ed entrambi che chiamano un metodo privato di A, il quale è proprio quello deputato ad eseguire, in modo ottimizzato, l'intera operazione.  

[p65][p65] [p65]

 


 

Torna all'Indice