Dr. Carlo Pescio
Enumerati come Tipi Distinti in C++

Pubblicato su Computer Programming No. 48


I tipi enumerati del C++ presentano alcuni problemi a livello di type system: in questo articolo vedremo come definire dei tipi enumerati che si comportano come veri e propri tipi distinti.

Introduzione

L'uso dei tipi enumerati per definire costanti omogenee, appartenenti quindi alla stessa categoria, è una delle buone pratiche di programmazione supportate dal linguaggio Pascal ed in seguito introdotte nell'ANSI C e nel C++. Raggruppare costanti come (ad esempio) left, right, up, down in un unico tipo "direzione" consente di migliorare la struttura dichiarativa del codice, come evidenziato ad esempio in [1]. Più recentemente, in [2] è stato sollevato un problema degli enumerati del C++ rispetto a quelli del Pascal: per compatibilità con l'ANSI C, il C++ consente l'assegnazione ed il confronto tra valori appartenenti a tipi enumerati diversi; ad esempio, il seguente frammento di codice è perfettamente legale in C++:

enum Color 
  { RED, GREEN, BLUE } ;
enum Direction 
  { UP, DOWN, LEFT, RIGHT } ;

Color c = UP ;

Purtroppo ciò riduce di molto l'utilità degli enumerati, in quanto il compilatore non è in grado di rilevare utilizzi concettualmente erronei come quello su riportato; in [2], l'autore conclude dicendo che non esistono tecniche pratiche per superare il problema, lasciando quindi ai programmatori la responsabilità di verificare attentamente il codice scritto: una conclusione un pò sconsolante ma, come vedremo, fortunatamente errata.

A dire il vero, inizialmente ho giudicato il problema seccante ma di scarso rilievo pratico, sino a che, alcuni mesi fa, mi è capitato di perdere un paio d'ore all'interno del debugger, cercando di trovare l'origine di un bug in una libreria che stavo utilizzando; alla fine, ho scoperto che si trattava proprio di un errore nell'uso degli enumerati. In particolare, il programmatore aveva definito un tipo enumerato lato (di una finestra rettangolare) avente tra i valori LEFT, ed un tipo enumerato direzione, avente tra i valori Left (notare la differenza nel solo case degli identificatori) ed aveva assegnato una direzione ad un lato. Più che incolpare il programmatore, simili errori devono farci riflettere sulla possibilità di migliorare i suoi strumenti, in modo tale che simili errori siano identificati in fase di compilazione; dopo qualche ora di lavoro, avevo pronta una macro per dichiarare in C++ tipi enumerati (o meglio classi-enumerate) che si comportano come veri e propri tipi distinti, esattamente come in Pascal.

Strategia

La tecnica utilizzata per trasformare i tipi enumerati del C++ in enumerati "alla Pascal" si basa sulla composizione di tre sotto-strategie, che analizzeremo separatamente in quanto segue: ognuna di esse ha infatti una sua utilità anche al di fuori di questo caso specifico.

  1. Come primo passo, definiremo una classe che si comporta esattamente come il tipo enumerato; essendo però una classe, saremo successivamente in grado di modificarne il comportamento.

  1. Come secondo passo, definiremo in modo opportuno gli operatori che vogliamo escludere, facendo sì che venga generato un errore di compilazione se tentassimo di usarli.

  1. Infine, vedremo come automatizzare i due passi precedenti usando una macro, in modo tale che la dichiarazione di una classe-enumerato non sia troppo scomoda rispetto a quella di un normale enumerato del C++.

I passi (2) e (3) si riveleranno i più difficili, ma certamente non impossibili da superare.

Classe wrapper

Trasformare un enumerato in una classe è relativamente semplice: invece di scrivere:

enum Color { RED, GREEN, BLUE } ;

definiamo un tipo enumerato "nascosto" ed una classe avente un membro di tipo nascosto, e costruttori ed operatori di conversione tali da simulare l'esatto comportamento di un tipo enumerato. Possiamo anche fornire tutti gli operatori di comparazione che riteniamo opportuni per il tipo enumerato "migliorato", in questo caso quelli disponibili anche in Pascal; utilizzando funzioni inline, ed evitando di definire funzioni virtuali, non avremo alcun overhead dal punto di vista dello spazio occupato da una istanza della classe, né dal punto di vista delle prestazioni. Vediamo un esempio, istanziato sul caso del listato precedente (la macro che definiremo al punto (3) renderà il tutto parametrico):

enum hidden_Color_ 
  { RED, GREEN, BLUE } ;
class Color
  {	
  public :
    Color() {} ;
    Color( hidden_Color_ h )
      { e = h ; }
    const Color & 
    operator =( hidden_Color_ h )
      { e = h; return( *this ) ; }
    operator hidden_Color_() 
      { return( e ) ; }
    int operator ==( Color n )
      { return( e == n.e ) ; }
    int operator !=( Color n )
      { return( e != n.e ) ; }
    int operator <=( Color n )
      { return( e <= n.e ) ; }
    int operator >=( Color n )
      { return( e >= n.e ) ; }
    int operator  <( Color n )
      { return( e < n.e ) ; }
    int operator  >( Color n )
      { return( e > n.e ) ; }
  private :
    hidden_Color_ e ;
  } ;

Possiamo ora usare la classe Color in quasi tutti i contesti in cui avremmo utilizzato l'enumerato corrispondente, salvo che la classe così definita è già "più sicura" dell'enumerato originale: non possiamo infatti assegnare tra loro oggetti appartenenti a due classi-enumerato diverse, in quanto il costruttore è definito solo sull'enumerato nascosto, e non sul tipo int. Quindi, un costrutto illecito che in molti compilatori genera al più un warning è già stato trasformato in un errore di compilazione; vedremo più avanti che tipo di messaggio possiamo attenderci dal compilatore. Notiamo anche che il tipo enumerato "nascosto" non può essere incapsulato nella classe, in quanto vogliamo avere accesso alle sue componenti RED, GREEN, BLUE senza dover usare l'operatore di accesso ::, che renderebbe visibile la trasformazione dell'enumerato in classe.

Generare errori

Nel già citato [1] ho presentato una semplice tecnica per generare errori di compilazione quando le nostre classi vengono utilizzate secondo schemi che sappiamo riconoscere a priori come invalidi: definire le funzioni od operatori come privati, e lasciarli indefiniti. Purtroppo in questo caso la tecnica sarebbe solo parzialmente efficace: definire un operator <( int ) privato renderebbe ad esempio invalida la comparazione colore < 3, ma permetterebbe comunque la comparazione 3 < colore, dal momento che il primo operando è in questo caso di tipo int. Sappiamo che per definire un operatore < che operi su (int, Color) dobbiamo definirlo esternamente alla classe: tuttavia, in questo caso l'operatore è pubblico per definizione, e non possiamo quindi applicare la tecnica di cui sopra, la quale richiede che l'operatore sia un membro della classe per poterlo definire privato.

Esiste però un'altra tecnica per generare errori di compilazione: fare i modo che l'operatore restituisca un risultato il cui tipo sia non-instanziabile. Un esempio di classe non instanziabile (vedere [1] per maggiori dettagli) è il seguente:

class enum_error
  {
  ~enum_error() ; 
  //distruttore privato
  } ;

Definendo quindi un operatore di comparazione come segue:

enum_error operator <( int, Color ) ;

il compilatore genererà un messaggio di errore contenente la parola enum_error ogni volta che tenteremo di comparare un intero con un colore. Naturalmente, dobbiamo definire tale operatore anche con i tipi degli operandi scambiati, e fare lo stesso per tutti gli operatori aritmetici, logici, e di comparazione che non desideriamo siano applicabili al nostro tipo enumerato "sicuro". Gli operatori da escludere, considerando le permutazioni sugli argomenti, sono oltre la trentina, e questo ci porta all'ultimo problema: scrivere tutto questo codice manualmente, ogni volta che dobbiamo definire un tipo enumerato, è un inutile spreco di tempo e può far sembrare modesto il vantaggio ottenuto (che invece è significativo). Occorre trovare un modo per ottenere la generazione automatica del codice, utilizzando una dichiarazione quanto più simile possibile alla sintassi originale del tipo enumerato; vedremo al prossimo punto come ottenere questo risultato.

Macro-magie

Il preprocessore del C++, tanto deprecato nelle norme di buona programmazione, si rivelerà questa volta molto prezioso: definendo un'opportuna macro, saremo infatti in grado di nascondere l'implementazione della classe associata al tipo enumerato, e di mantenere così una sintassi molto vicina a quella originale. Osserviamo che questo tipo di macro espansione è uno dei pochi casi in cui il preprocessore rimane insostituibile, tranne forse tentare l'utilizzo di un template (un ottimo esercizio per il lettore con un pò di tempo libero).

Il codice visto sembra facilmente parametrizzabile: dato il nome del tipo (es. Color) è facile ottenere il nome del tipo nascosto, usando l'operatore ## del preprocessore per concatenare "hidden" e l'underscore finale. Il rimanente codice si ottiene banalmente con una macro espansione; invece, ci troviamo di fronte un problema in fase di passaggio dei parametri, in quanto il preprocessore non è esente dalle sue idiosincrasie, che dovremo necessariamente tentare di scavalcare; la definizione più naturale per la macro potrebbe infatti essere quella del seguente listato:

#define ENUM( name, decl ) \
... usa name e decl

// chiamata come:
ENUM( Color, { RED, GREEN, BLUE } ) ;

Sfortunatamente il preprocessore non gestisce le parentesi graffe come vorremmo nella lista degli argomenti, che pertanto diventano, nella chiamata di cui sopra, "Color", "{ RED", "GREEN", "BLUE }"; incidentalmente il preprocessore gestisce correttamente le parentesi tonde, ovvero un argomento del tipo ( RED, GREEN, BLUE ) verrebbe in effetti interpretato come un unico argomento, ma all'interno della nostra macro ci troveremmo con l'impossibile compito di trasformare delle parentesi tonde in graffe in fase di preprocessing.

Chi si sente particolarmente avventuroso, e dispone del sorgente del suo preprocessore (ad esempio chi utilizza i compilatori GNU) potrebbe tentare di modificare il preprocessore stesso per gestire le parentesi graffe come le tonde: si tratterebbe ovviamente di una modifica fuori-standard, che tuttavia difficilmente pregiudicherebbe la compilazione di codice esistente. Pur essendo educativa e tutto sommato non molto difficile, una soluzione simile è improponibile per chiunque usi un compilatore commerciale, di cui non è disponibile il sorgente: in questo caso, dobbiamo trovare una soluzione all'interno dello standard, anche a costo di una sintassi meno naturale.

Il problema in questo caso è che abbiamo bisogno sia del nome che del corpo della dichiarazione dell'enumerato per costruire la dichiarazione della classe; tuttavia, molti compilatori accettano un forward reference all'enumerato, come segue:

enum hidden_Color_  ;
// forward reference

class Color
  {
  // usa hidden_Color_ ;
  } ;

enum hidden_Color_ 
  { RED, GREEN, BLUE } ;

Questo utilizzo non è totalmente standard: il forward reference è ammesso dal futuro standard ISO, ma l'uso diretto dell'enumerato prima della sua definizione non lo è. In realtà, un gran numero di compilatori accettano codice del genere come legale, in quanto la dimensione degli enumerati viene fissata a priori e non hanno bisogno di vederne la definizione per poterli utilizzare come left value; tra i compilatori che accettano l'uso di cui sopra troviamo il Borland C++ ed il Microsoft Visual C++, mentre ad esempio lo GNU C++ sotto SunOS su Sun SparcStation segnala un errore di compilazione.

Se il vostro compilatore accetta la sintassi su esposta, potete utilizzare la macro data al listato 1, che permette di definire un enumerato "migliorato" con la seguente sintassi:

ENUM( Color ) { RED, GREEN, BLUE } ;

come vedete, la sintassi è molto vicina a quella originale, ma il tipo enumerato così definito è intrinsecamente più sicuro di quello standard del C++.

Se viceversa il vostro compilatore è più restrittivo, o se desiderate la massima portabilità del codice, occorre accettare un ulteriore compromesso, ed utilizzare una sintassi meno naturale, come:

DEFINE_ENUM( Color ) 
  { RED, GREEN, BLUE } 
END_ENUM( Color ) ;

In questo caso (vedere listato 2) è necessario passare due volte il nome del tipo, la prima per permettere la definizione dell'enumerato "nascosto", la seconda per definire la classe che prende il posto dell'enumerato. In entrambi i listati ho aggiunto la gestione dell'operatore << per completare l'analogia con gli enumerati del C++.

Vale la pena di notare che uno dei principali problemi delle macro, ovvero l'impossibilità di eseguire passo passo il codice all'interno del debugger, in questo caso è poco significativo, in quanto difficilmente vorremo tracciare le operazioni definite dalla macro in questione.

Risultati ottenuti

La classe-enumerato che abbiamo ottenuto è in grado di generare errori a compile time ogni volta che la si utilizzi erroneamente, esattamente come accade per gli enumerati del Pascal. Possiamo vedere un esempio di cosa sia lecito o meno nel listato seguente, che usa la forma sintattica più semplice:

// dichiarazioni
ENUM( Color ) 
  { RED, GREEN, BLUE } ;
ENUM( Direction ) 
  { UP, DOWN, LEFT, RIGHT } ;

int main()
  {
  // si usano come i normali
  // tipi enumerati
  Color red = RED ;
  Direction dir = LEFT ;
  Color col ;
  col = red ;
  col = GREEN ;
  Color col1 = red ;
  switch( col )
    {
    case RED :
      ;
    case BLUE :
      ;
    }

  int a = dir ;
  // ok, "ord" implicito
  int b = ( red < col ) ;
  // ok, comparazione interna
  // allo stesso tipo

  dir = RED ;
  // errore (1), tipi distinti
  dir = red ;
  // errore (2), tipi distinti
  int c = dir < red ;
  // errore (3), comparazione
  // tra due tipi
  int d = 3 < dir ;
  // errore (4), comparazione
  // int - enum
  int e = dir < 3 ;
  // errore (5), comparazione
  // enum - int
  int f = ( dir & 3 ) ;
  // errore (6), operazione
  // bitwise su enum
  int g = dir + 3 ;
  // errore (7), aritmetica
  // su enumerati
  int h = ( dir && 3 ) ;
  // errore (8), operatori
  // logici su enum

  return( 0 ) ;
  }

Naturalmente, i messaggi di errore non saranno estremamente accurati: ricordiamo che i normali messaggi di errore sono gestiti da opportuno codice all'interno del compilatore, mentre nel nostro caso li stiamo forzando con una tecnica di programmazione. Vediamo alcuni esempi specifici: se cerchiamo di assegnare un valore appartenente ad un tipo ad una variabile di altro tipo (errore 1 nel listato di cui sopra), otteniamo i seguenti messaggi:

Borland C++:
Could not find a match for 'Direction::operator =(hidden_Color_)'

Microsoft C++:
binary '=' : no operator defined which takes a right-hand operand of type 'enum hidden_Color_'

il messaggio è molto chiaro, anche se fa riferimento al tipo nascosto, ed evidenzia che stiamo cercando di assegnare un colore ad una direzione; va detto che il compilatore Borland emette un messaggio più comprensibile del Microsoft, una qualità che molto spesso passa inosservata nelle varie prove comparative ma che, viceversa, è molto importante per il programmatore.

Ancora più chiaro è il messaggio ottenuto nel caso dell'errore (2):

Borland C++:
Could not find a match for 'Direction::operator =(Color)'

Microsoft C++:
binary '=' : no operator defined which takes a right-hand operand of type 'class Color'

In questa situazione non viene menzionato il tipo nascosto, ma direttamente i due tipi incompatibili Direction e Color. L'errore (3) genera invece un messaggio di ambiguità non risolvibile, purtroppo ben poco comprensibile:

Borland C++:
Ambiguity between 'operator <(int,Color)' and 'Direction::operator hidden_Direction_()'

Microsoft C++:
'<' : 3 overloads have similar conversions

Nuovamente, il compilatore Borland si rivela un pò più generoso di particolari, e permette di capire che stiamo cercando di comparare una direzione (che potrebbe avere un cast implicito ad int) ed un colore; il messaggio del Microsoft è un pò criptico, ma dovrebbe essere sufficiente ad attirare la nostra attenzione su una linea sospetta.

Più comprensibili del precedente sono invece i messaggi generati per le comparazioni tra tipi non omogenei: in questi casi, dovremo comunque fare affidamento sulla presenza del termine enum_error all'interno del messaggio per capire che si tratta di un errore sugli enumerati. Ad esempio, gli errori (4) (5) (6) (7) (8) generano messaggi di questo tipo:

Borland C++:
Destructor for 'enum_error' is not accessible in function main()

Microsoft C++:
'enum_error::~enum_error ' : cannot access private member declared in class 'enum_error '

Nuovamente, non ci troviamo di fronte ai messaggi di errore più leggibili che ci siano mai capitati, ma è sicuramente meglio spendere qualche minuto cercando di capirne la causa al momento della compilazione, piuttosto che spendere delle ore a capire la causa di un malfunzionamento a run-time.

Miglioramenti

Al di là dei messaggi di errore non estremamente chiari (ma un messaggio un pò criptico è meglio di nessun messaggio) l'unico problema della classe-enumerato presentata è che non si può convertire un intero in un enumerato, usando una sintassi del tipo:

Color c( 1 ) ;

In realtà un simile costruttore è facilmente implementabile se si ha a disposizione un compilatore che gestisce la keyword explicit, che evita l'uso implicito del costruttore da parte del compilatore: viceversa, tutti i nostri sforzi verrebbero resi vani dalla presenza di tale costruttore.

Alternativamente, si potrebbe optare per una funzione Val( int ) analogamente a quella del Pascal; il lettore che volesse estendere la macro in tal senso non avrà sicuramente problemi. In entrambi i casi, vale la pena di notare che tali funzioni di conversione sono inerentemente poco sicure, dato che non possiamo verificare e run-time che esista un enumerato corrispondente a tale valore (ricordiamo che gli enumerati del C++ non hanno necessariamente valori consecutivi), per cui tutto sommato l'assenza di tali funzioni non costituisce un problema molto serio nell'ottica di una programmazione attenta e rigorosa.

Ricordiamo infine che i tipi dichiarati attraverso la macro ENUM (o DEFINE_ENUM) sono vere e proprie classi, e di conseguenza possiamo derivare da esse nuove classi, ad esempio per ridefinire l'input/output o qualunque funzione aggiuntiva si riveli utile in seguito.

Conclusioni

Il C++ si rivela ancora una volta un linguaggio estremamente flessibile, che permette di superare le sue stesse lacune attraverso l'uso di opportune tecniche di programmazione. In particolare, per quanto non esenti da una certa cripticità nei messaggi di errore, i tipi enumerati presentati in questo articolo rappresentano un sensibile miglioramento rispetto ai tipi primitivi del linguaggio, senza penalizzazioni nei confronti dello spazio occupato o delle prestazioni a run-time.

Reader's Map
Molti visitatori che hanno letto
questo articolo hanno letto anche:

Bibliografia

[1] Carlo Pescio: "C++ Manuale di Stile", Edizioni Infomedia, 1995.

[2] Dan Saks, "Say it in code", Software Development Vol. 3 No. 12, December 1995.

Biografia
Carlo Pescio svolge attività di consulenza in ambito internazionale sulle metodologie di sviluppo Object Oriented e di programmazione in ambiente Windows. È laureato in Scienze dell'Informazione, membro dell'Institute of Electrical and Electronics Engineers Computer Society, dei Comitati Tecnici IEEE su Linguaggi Formali, Software Engineering e Medicina Computazionale, dell'ACM e dell'Academy of Sciences di New York. Può essere contattato tramite la redazione o su Internet come pescio@programmers.net