Dr. Carlo Pescio
Operatori User-Defined

Pubblicato su C++ Informer No. 5, Giugno/Luglio 1998

Il C++ permette al programmatore di definire il significato di alcuni operatori (come &, |, *, +, ecc) all'interno delle proprie classi. Apparentemente, pero', non ci consente di introdurre dei nuovi operatori, ad esempio un operatore ** o un operatore /&. Questa possibilita' e' stata considerata da Stroustrup ed abbandonata per una serie di problemi legati alla specifica di precedenza ed associativita' dei nuovi operatori: gli interessati possono consultare [1], paragrafo 11.6.2
Cio' non toglie che talvolta aggiungere un operatore risulti comodo, e che di fatto, con un po' di creativita', il C++ ci consenta di aggiungere infiniti operatori user-defined.

Un semplice esempio
Supponiamo di voler implementare una nostra classe Bool, con le ovvie operazioni di OR, AND, NOT. Possiamo ovviamente ridefinire gli operatori ed utilizzare |, &, e ! per rappresentare le succitate operazioni.
D'altra parte, il C++ mette a disposizione anche l'operatore XOR, normalmente rappresentato con ^. A questo punto decidiamo di supportare sia lo XOR che il NAND, ed incontriamo il primo problema: siccome non esiste un operatore NAND bitwise sugli interi, non abbiamo nessun operatore esistente da utilizzare per la nostra classe Bool.
Non possiamo neanche prendere un operatore qualunque e trattarlo come NAND: deve avere la giusta arita' (due parametri), deve essere infisso, deve associare a sinistra e, se vogliamo seguire l'esempio di XOR, deve avere precedenza piu' alta di AND (si veda ad esempio la sezione 6.2 in [2]).
Infine, l'operatore dovrebbe anche avere un "aspetto" sensato: usare + come operatore NAND non sembrerebbe molto ragionevole. In casi piu' complessi, tutti gli operatori disponibili potrebbero gia' essere utilizzati nella nostra classe, e quindi dovremmo comunque cercare un operatore "nuovo".

Una via d'uscita
Anche se il C++ non supporta ufficialmente gli operatori user-defined, non e' poi molto difficile ottenere un risultato simile. L'idea di fondo e' semplice ed e' peraltro utilizzata abbastanza comunemente in situazioni diverse: qui la sfrutteremo per creare "nuovi" operatori. Vi descrivero' dapprima una soluzione specifica per il caso Bool, ed in seguito vedremo una generalizzazione della tecnica.
Normalmente, un operatore come | definito sulla classe Bool prendera' due parametri Bool e restituira' un Bool. Tuttavia nulla ci impedisce di ridefinire un operatore (ad esempio la versione prefissa di &) in modo da prendere un Bool e restituire (anziche' l'indirizzo dell'oggetto come avviene di norma) un oggetto di classe diversa, normalmente detto Proxy.
Il Proxy deve avere in questo caso una doppia funzionalita': se e' necessario, deve poter restituire l'indirizzo dell'oggetto al quale avevamo inizialmente applicato l'operatore &. In questo modo, preserviamo il significato originale dell'operatore. Tuttavia, deve anche essere in grado di "combinarsi" con altri operatori, definiti sulla classe Bool, in modo da formare un operatore composto.
Vediamo un esempio concreto: nel listato seguente ho implementato una classe Bool che utilizza l'operatore (user-defined) /& per il NAND (quindi scriveremo ad esempio a /& b per intendere a NAND b):

#include <iostream>

class BoolProxy ;


class Bool
  {
  public :
    static const Bool T ;
    static const Bool F ;

    Bool operator &( Bool b ) const ;
    Bool operator |( Bool b ) const ;
    Bool operator ^( Bool b ) const ;
    Bool operator !() const ;
    const BoolProxy operator &() const ;
    Bool operator /( BoolProxy b ) const ;
    std::ostream& Print( std::ostream& s ) const ;
  private :
    Bool( bool b ) ;
    bool v ;
  } ;

std::ostream& operator <<( std::ostream& s, const Bool b  ) ;


class BoolProxy
  {
  public :
    operator const Bool*() const ;
  private :
    const Bool* v ;
    BoolProxy( const Bool* b ) ;
  friend class Bool ;
  } ;

const Bool Bool :: T( 1 ) ;

const Bool Bool :: F( 0 ) ;

Bool Bool :: operator &( Bool b ) const 
  {
  if( v && b.v )
    return( T ) ;
  else
    return( F ) ;
  }

Bool Bool :: operator |( Bool b ) const 
  {
  if( v || b.v )
    return( T ) ;
  else
    return( F ) ;
  }

Bool Bool :: operator ^( Bool b ) const 
  {
  if( v ^ b.v )
    return( T ) ;
  else
    return( F ) ;
  }

Bool Bool :: operator !() const 
  {
  if( v )
    return( F ) ;
  else
    return( T ) ;
  }

const BoolProxy Bool :: operator &() const
  {
  return( BoolProxy( this ) ) ;
  }

Bool Bool :: operator /( BoolProxy b ) const
  {
  if( ! ( v && b.v->v ) )
    return( T ) ;
  else
    return( F ) ;
  }

std::ostream& Bool:: Print( std::ostream& s ) const 
  {
  if( v )
    s << "T" ;
  else
    s << "F" ;
  return( s ) ;
  }

std::ostream& operator <<( std::ostream& s, const Bool b  )
  {
  return( b.Print( s ) ) ;
  }

Bool :: Bool( bool b ) : v( b )
  {
  }

BoolProxy :: operator const Bool*() const
  {
  return( v ) ;
  }

BoolProxy :: BoolProxy( const Bool* b ) :
v( b )
  {
  }


int main()
  {
  // esempi vari di uso di /&
  Bool k =  Bool::T /& Bool::F & Bool::F ;
  std::cout << 
    ( Bool::T & Bool::F | Bool::T & Bool::F ) <<
    std::endl ;
  std::cout << 
    ( Bool::T /& Bool::F | Bool::T /& Bool::T & Bool::F ) <<
    std::endl ;
  Bool c = Bool::T ;
  const Bool* r = &c ;
  return( 0 ) ;
  }
Il funzionamento e' abbastanza semplice: quando applichiamo l'operatore & prefisso ad un Bool, otteniamo un BoolProxy che "ricorda" l'oggetto Bool che lo ha generato. Il BoolProxy ha una sola funzione pubblica, ovvero l'operatore di conversione a Bool*. Questo consente a codice come il seguente di funzionare:

Bool c = Bool::T ;
const Bool* r = &c ;
Notiamo che &c e' un oggetto BoolProxy, ma puo' essere convertito implicitamente in un Bool*. A questo punto, aggiungiamo alla classe Bool un ulteriore operatore (/) che prende come secondo parametro un BoolProxy, ed implementa la funzionalita' desiderata per /&. Il lato interessante e' che questa tecnica funzionerebbe anche se l'operatore scelto (/) fosse gia' definito sulla classe Bool, perche' il secondo operando avrebbe una classe diversa (BoolProxy), ed avremmo quindi un semplice caso di overloading di operator /(). Osserviamo anche che il BoolProxy ha un solo costruttore privato. Questo impedisce a chiunque di creare oggetti BoolProxy, tranne alla classe Bool che e' dichiarata friend. In questo modo, possiamo essere certi che Bool::operator /() non venga mai utilizzato in modo scorretto dall'utente, ma sempre e solo in congiunzione con un operator & prefisso. La conseguenza e' che / ed & possono essere considerati un vero e proprio operatore unico /&. Per brevita', il proxy che ho definito puo' essere solo convertito in un puntatore a costante. In un caso reale, occorre gestire la conversione di un proxy generato da oggetto const a const Bool* , e di un proxy generato da un oggetto non const a Bool*. Ho anche scelto di definire le costanti true e false come veri e propri oggetti (indicati con Bool::T e Bool::F) e di lasciare il costruttore di Bool privato, ma questo e' solo un dettaglio che non ha legami con la tecnica degli operatori user-defined.

Infiniti Operatori
La tecnica utilizzata sopra puo' essere estesa per generare un numero infinito di operatori user-defined. In generale, se distinguiamo gli operatori del C++ come segue: <post> ++ -- () <pre> + - * & ~ ! ++ -- <inf> + - * / % ^ & | = < > += -= *= /= %= ^= &= |= << >> >>= <<= == != <= >= && || ++ -- , ->* -> Allora con la tecnica su esposta possiamo ottenere tutti gli operatori user-defined generati da: <post>* <inf> <pre>* (dove <s>* sta per zero o piu' simboli dell'insieme <s>) Vediamo alcuni esempi: /&, -->>, <<--, ecc. Ovviamente, alcuni sono gia' definiti dal linguaggio (es. &&) e non vanno considerati nell'insieme degli operatori user-defined. In generale, sia in C che in C++ esiste una regola di "maximum munching", per cui lo scanner cerca sempre la sequenza piu' lunga di simboli che forma un token valido. Quindi && verra' sempre considerato come un unico simbolo infisso e mai come "&infisso &prefisso". Come scegliere la sequenza di operatori da utilizzare? Non e' in generale un'operazione semplice, perche' dobbiamo garantire la giusta associativita' e la giusta precedenza dell'operatore risultante.

Associativita' e Precedenza
Per capire meglio il problema, riprendiamo l'esempio di Bool. L'operatore user-defined /& dovrebbe avere la stessa associativita' di | ed &, ovvero A /& B /& C dovrebbe essere equivalente a (A /& B) /& C. Inoltre, come dalla specifica iniziale, dovrebbe avere precedenza piu' alta di &, ovvero A /& B & C dovrebbe essere interpretato come (A /& B) & C e non A /& (B&C). A maggior ragione, A /& B | C deve essere interpretato come (A /& B) | C e non come A /& (B | C). Analogamente, A & B /& C dovrebbe essere interpretato come A & (B /& C). Nel nostro caso, l'operatore & prefisso ha precedenza piu' alta di ogni operatore infisso, quindi possiamo essere certi che esso verra' applicato a B e non a B&C oppure a B|C. Tuttavia noi abbiamo due operatori concatenati, e dobbiamo ancora verificare che l'espressione A /& B & C non venga valutata come A / (&B & C ) e che A & B /& C non venga interpretata come (A&B) / &C. Nel primo caso interviene l'associativita' degli operatori: gli operatori infissi (tranne l'assegnazione) associano tutti a sinistra, quindi A /& B & C verra' sempre interpretata come (A / (&B)) & C come desiderato. Nel secondo caso interviene la precedenza dell'operatore /, che e' stato scelto ad-hoc in modo da avere precedenza piu' alta dell'& infisso. Se avessi usato un operatore di priorita' uguale od inferiore, l'espressione precedente sarebbe stata interpretata in modo errato. Infine, l'espressione A /& B /& C viene interpretata come A / (&B) / (&C), ovvero (A / (&B)) / (&C) che e' quanto volevamo. Dobbiamo infine considerare espressioni che coinvolgono l'operatore unario !. Questo associa a destra, come tutti gli operatori unari, quindi A /& ! B diventa giustamente A / (& (!B)) come desiderato. Notiamo quindi che la scelta apparentemente casuale di /& e' in realta' il risultato di una serie di considerazioni indispensabili per garantire un comportamento coerente al nostro nuovo operatore.

Considerazioni
La "meccanica" per aggiungere un operatore e' semplice: ridefinire un operatore prefisso o postfisso per restituire un proxy, ed aggiungere un operatore infisso che prenda il proxy come secondo parametro. Se vogliamo comporre piu' di due operatori, dobbiamo ripetere la stessa sequenza sulla classe proxy. Il proxy deve preoccuparsi di mantenere anche il significato originario dell'operatore prefisso o postfisso, nel caso non venga usato in combinazione ad un altro operatore. Tuttavia garantire la giusta associativita' e precedenza non e' semplicissimo, ed in alcuni casi potrebbe essere del tutto impossibile. Ad esempio, se volessimo un operatore /& con la stessa precedenza di & ci troveremmo in un vicolo cieco, dal momento che & ha una sua classe di precedenza distinta da ogni altra. Analogamente, se volessimo definire un operatore ** (che in effetti rispetta la grammatica data sopra: *infisso *prefisso) per ottenere l'elevazione a potenza, ci troveremmo di fronte a problemi di associativita'. Se scriviamo A / B ** C, questo viene interpretato come (A/B) * (*C), ovvero pow( A/B, C ), mentre di norma si vorrebbe un significato del tipo A / pow( B, C ). Per ottenere un risultato simile dovremmo cambiare sintassi, e cercare un operatore infisso con precedenza piu' alta di / e *. L'unico operatore ridefinibile con tali caratteristiche e' ->*, peraltro usato molto di rado. Tuttavia, a questo punto non ha neppure senso utilizzare un operatore user-defined, quando potremmo usare direttamente ->* per esprimere l'elevazione a potenza.

Estensioni
Vi sono tre possibili estensioni alla tecnica qui presentata. La prima riguarda gli operatori alfabetici, come new e delete. Possiamo aggiungerne dei nuovi, magari prefissi e postfissi oltre che infissi come gli operatori user-defined visti sopra? La risposta e' si, ma occorre unire una tecnica simile a quella vista sopra ad una poco edificante macro. La seconda estensione riguarda operatori user-defined alfabetici sui tipi predefiniti, ad esempio un operator pow che ci consenta di scrivere 3 pow 4. Questa e' una estensione piuttosto semplice della tecnica precedente. La terza riguarda operatori infissi multipli, utili ad esempio per implementare in modo efficiente una somma di vettori e successiva riscalatura (usando un solo loop). Se l'argomento vi interessa, fatevi sentire e ne parlero' su un futuro numero di C++ Informer.

Conclusioni
Al di la' dell'aspetto divertente, andare oltre i limiti convenzionali del linguaggio puo' risultare utile in alcune occasioni. Vi sono molte operazioni comuni in matematica che non hanno un simbolo corrispondente nel set di operatori del C++: ad esempio, se utilizziamo * per il prodotto scalare, cosa utilizziamo per il prodotto vettoriale? Sfruttando in modo opportuno la tecnica qui esposta possiamo estendere il set di operatori per avvicinarci il piu' possibile alla simbologia nativa della struttura algebrica implementata. Come sempre, cercate di non abusare della tecnica: trasformare il C++ in APL non rende il codice piu' facile da leggere.

Bibliografia
[1] Bjarne Stroustrup, "The Design and Evolution of C++", Addison-Wesley, 1994.
[2] Bjarne Stroustrup, "The C++ Programming Language, 3rd Edition", Addison-Wesley, 1997.

Biografia
Carlo Pescio (pescio@eptacom.net) svolge attività di consulenza, progettazione e formazione in ambito internazionale. Ha svolto la funzione di Software Architect in grandi progetti per importanti aziende europee e statunitensi. È autore di numerosi articoli su temi di ingegneria del software, programmazione C++ e tecnologia ad oggetti, apparsi sui principali periodici statunitensi, nonché dei testi "C++ Manuale di Stile" ed "UML Manuale di Stile". Laureato in Scienze dell'Informazione, è membro dell'ACM, dell'IEEE e dell'IEEE Technical Council on Software Engineering.