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:

  1. 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`.
  2. 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.
  3. Non e` concessa la possibilita` di eseguire l'overloading di alcuni operatori, ad esempio l'operatore ternario ? :, l'operatore sizeof, l'operatore .* e l'operatore punto (per la selezione dei campi di una struttura).
  4. 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 importante e bisogna prestare attenzione quando lo si ridefinisce. 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 >));


Il prototipo standard dell'operatore di assegnamento e`


  X& X::operator=(const X&);


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& X::operator=(const X&), il compilatore ne fornisce uno automaticamente. 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.
Ci sono due buone norme da tenere presenti quando si ridefinisce l'operatore di assegnamento:

  1. Evitare che un oggetto assegni a se stesso;
  2. Ritornare un reference a *this.
E` importante evitare autoassegnamenti, perche` se una operazione di assegnamento comporta la deallocazione di risorse, e` facile che un autoassegnamento porti l'oggetto in uno stato inconsistente:


  class X {
    public:
      X& operator=(const X& rsh);
  
    private:
      char* Str;
  };

  X& X::operator=(const X& rsh) {
    delete[] Str;
    // Se this==&rsh le due istruzioni successive
    // hanno comportamento non definito
    Str = new char[strlen(rsh.Str)+1];
    strcpy(Str, rsh.Str);
    /* ... */
  }


Inoltre e` importante ritornare un riferimento a *this perche` l'argomento dell'operatore e` un riferimento a costante, mentre il valore generalmente restituito e` un reference non const. Si osservi che eliminare il const dal parametro non e` una soluzione praticabile poiche` impedirebbe l'assegnamento di valori costanti:


  Integer& Integer::operator=(Integer& rsh) {
    /* ... */
  }

  Integer I = 3;  // Errore!


In generale dunque la struttura di un buon operatore di assegnamento e`:


  X& X::operator=(X& rsh) {
    if (this!=&rsh) {
      /* ... */
    }
    return *this;
  }


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 (da tipi diversi), ma e` quello di cui sopra che il compilatore fornisce quando manca.



L'operatore di sottoscrizione

Anche l'operatore di sottoscrizione [ ] puo` essere sottoposto a overloading. 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] dell'operatore predefinito 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 < ArraySize) 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