Operatori && e ||

Anche gli operatori di AND e OR logico possono essere ridefiniti, tuttavia c'e` una profonda differenza tra quelli predefiniti e quelli che l'utente puo` 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 e` 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 poiche` il valore del primo e` 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 e` quello di dereferenzazione -> il cui comportamento e` un po' difficile da capire.
Se T e` una classe che ridefinisce -> (l'operatore di dereferenzazione deve essere un funzione membro non statica) e Obj e` una istanza di tale classe, l'espressione


  Obj -> Field;


e` valutata come


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


Conseguenza di cio` e` 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 e` 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 realta` un operatore binario il cui secondo argomento e` il nome del campo di una struttura, mentre l'operatore che l'utente puo` ridefinire deve essere unario.



L'operatore virgola

Anche la virgola e` un operatore (binario) che puo` essere ridefinito. La versione predefinita dell'operatore fa si` che entrambi gli argomenti siano valutati, ma il risultato prodotto e` il valore del secondo (quello del primo argomento viene scartato). Nella prassi comune, la virgola e` 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 e` 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, che serviva solo per alcuni suoi effetti collaterali. Quanto alle parentesi, esse sono necessarie perche` l'assegnamento ha la precedenza sulla virgola.
Questo operatore e` comunque sovraccaricato raramente.



Autoincremento e autodecremento

Gli operatori ++ e -- meritano un breve accenno poiche` 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.
Come operatori globali, la forma prefissa prende un solo argomento, l'oggetto cui e` applicato; la forma postfissa invece possiede un parametro fittizio in piu` 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 e` possibile accedere ad esso.
Ridefiniti come funzioni membro, la versione prefissa non presenta nel suo prototipo alcun parametro (il parametro e` l'oggetto su cui l'operatore e` chiamato), la forma postfissa ha un prototipo con il solo argomento fittizio.



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 e` 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.
Poiche` entrambi gli operatori hanno un prototipo predefinito, non e` possibile avere piu` versioni overloaded di new e delete, e` possibile averne al piu` 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) sara` utilizzata in tutti gli altri casi.
La ridefinizione di new e delete e` 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.
Ecco un esempio di new e delete globali:


  void* operator new(size_t Size) {
    return malloc(Size);
  }

  void operator delete(void* Ptr) {
    free(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 e` 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 e` di tipo size_t: nella 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 e` di fornire la dimensione del blocco da deallocare (utile in parecchi casi). Nel caso in cui lo si utilizzi, e` compito del programmatore supplire un valore per il secondo parametro (in effetti solo per il primo parametro della new e` 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) {
    if (Ptr) return Ptr;
    return malloc(Size);
  }

  int main() {
    // Supponiamo di voler mappare un certo
    // dispositivo hardware tramite una istanza di
    // un apposito tipo
    const void* DeviceAddr = 0xA23;

    // Si osservi il modo in cui viene fornito
    // il secondo parametro della new
    TMyDevice Unit1 = new(DeviceAddr) TMyDevice;

    /* ... */
    
    return 0;
  }   


Si noti che non c'e` una delete duale per questa forma di new (perche` una delete non puo` sapere se e come e` stata allocato l'oggetto da deallocare), questo vuol dire che gli oggetti allocati nel modo appena visto (cioe` fornendo alla new un indirizzo) vanno deallocati con tecniche diverse.
E` possibile sovraccaricare anche le versioni per array di questi operatori. I prototipi di new[] e delete[] sono identici a quelli gia` visti in particolare il valore che il compilatore fornisce come primo parametro alla new[] e` ancora la dimensione complessiva del blocco da allocare.

Per terminare il discorso su questi operatori, bisogna accennare a cio` che accade quando una allocazione non riesce (generalmente per mancanza di memoria). In caso di fallimento della new, lo standard prevede che venga chiamata una apposita funzione (detta new-handler) il cui comportamento di default e` sollevare una eccezione di tipo std::bad_alloc che bisogna intercettare per gestire il possibile fallimento.
E` possibile modificare tale comportamento definendo e istallando una nuova new-handler. La generica new-handler deve essere una funzione che non riceve alcun parametro e restituisce void, tale funzione va installata tramite una chiamata a std::set_new_handler il cui prototipo e` dato dalle seguenti definizioni:


  typedef void (*new_handler)();
  // new_handler e` un puntatore ad una funzione
  // che non prende parametri e restituisce void

  new_handler set_new_handler(new_handler HandlePtr);


La funzione set_new_handler riceve come parametro la funzione da utilizzare quando la new fallisce e restituisce un puntatore alla vecchia new-handler. Ecco un esempio di come utilizzare questo strumento:


  void NoMemory() {
    // cerr e` come cin, ma si usa per inviare
    // messaggi di errore...
    cerr << "Out of memory... Program aborted!" << endl;
    abort();
  }

  int main(int, char* []) {
    new_handler OldHandler = set_new_handler(NoMemory);
    
    char* Ptr = new char[1000000000];

    set_new_handler(OldHandler);

    /* ... */
  }


Il precedente esempio funziona perche` la funzione standard abort() provoca la terminazione del programma, in realta` la new-handler viene richiamata da new finche` l'operatore non e` in grado di restituire un valore valido, per cui bisogna tenere conto di cio` quando si definisce una routine per gestire i fallimenti di new.



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


  class Complex {
    public:
      /* ... */

    private:
      float Re, Im;
      friend ostream& operator<<(ostream& os,
                                 Complex& C);
  };

  ostream& operator<<(ostream& os, Complex& C) {
    os << C.Re << " + i" << C.Im;
    return os;
  }


Adesso e` possibile scrivere


  Complex C(1.0, 2.3);

  cout << C;





Pagina precedente - Pagina successiva



C++, una panoramica sul linguaggio - seconda edizione
© Copyright 1996-1999, Paolo Marotta