Tipi definiti dall'utente

 


 

Il termine "tipo astratto", usato in contrapposizione ai tipi nativi del linguaggio, non é molto appropriato: il C++ consente al programmatore di definire nuovi tipi, estendendo così le capacità effettive del linguaggio; ma, una volta definiti, questi tipi sono molto "concreti" e sono trattati esattamente come i tipi nativi. Per questo motivo, la tendenza "moderna" è di identificare i tipi non nativi con il termine: "tipi definiti dall'utente" e di confinare l'aggettivo "astratto" a una precisa sottocategoria di questi (di cui parleremo più avanti). Tuttavia noi continueremo, per comodità, a usare la "vecchia" terminologia.

In questo capitolo parleremo dei tipi astratti comuni sia al C che al C++, usando però la nomenclatura (oggetti, istanze ecc...) del C++.

 


 

Concetti di oggetto e istanza

 

Il termine oggetto é sostanzialmente sinonimo del termine variabile. Benché questo termine si usi soprattutto in relazione a tipi astratti (come strutture o classi), noi possiamo generalizzare il concetto, definendo oggetto una variabile di qualunque tipo, non solo formalmente definita, ma anche già creata e operante.

E' noto infatti che l'istruzione di definizione di una variabile non si limita a dichiarare il suo tipo, ma crea fisicamente la variabile stessa, allocando la memoria necessaria (nella terminologia C++ si dice che la variabile viene "costruita"): pertanto la definizione di una variabile comporta la "costruzione" di un oggetto.

Il termine istanza é quasi simile al termine oggetto; se ne differenzia in quanto sottolinea l'appartenenza dell'oggetto a un dato tipo (istanza di ... "qualcosa"). Per esempio, la dichiarazione/definizione:
                 int  ivar ;
                                    costruisce l'oggetto ivar, istanza del tipo int.

Esiste anche il verbo: istanziare (o instanziare) un certo tipo, che significa creare un'istanza di quel tipo.

 


 

Typedef

 

L'istruzione introdotta dalla parola-chiave typedef definisce un sinonimo di un tipo esistente, cioè non crea un nuovo tipo, ma un nuovo identificatore di un tipo (nativo o astratto) precedentemente definito.

Es.:           typedef unsigned long int* pul ;
definisce il nuovo identificatore di tipo pul, che potrà essere usato, nelle successive dichiarazioni (all'interno dello stesso ambito), per costruire oggetti di tipo puntatore a unsigned long:
unsigned long a; pul ogg1 = &a; pul parray[100];  ecc...

   
L'uso di typedef permette di semplificare dichiarazioni lunghe di variabili dello stesso tipo. Per esempio, supponiamo di dover dichiarare molti array, tutti dello stesso tipo e della stessa dimensione:
double a1[100]; double a2[100]; double a3[100];  ecc...

usando typedef la semplificazione é evidente:
typedef double a[100]; a a1; a a2; a a3;  ecc...

Un caso in cui si evidenzia in modo eclatante l'utilità di typedef è quello in cui si devono dichiarare più funzioni con lo stesso puntatore a funzione come argomento.
Es.: typedef  bool (*tpfunz)(const int&, int&, const char*, int&, char*&, int&);
in questo caso tpfunz è il nome di un tipo puntatore a funzione e può essere sostituito nelle dichiarazioni delle funzioni chiamanti al posto dell'intera stringa di cui sopra:
   void fsel1(tpfunz);     int fsel2(tpfunz);   double fsel3(tpfunz);     ecc....

infine, nelle definizioni delle funzioni chiamanti bisogna specificare un argomento di "tipo" tpfunz e usare questo per le chiamate. Es:
   void fsel1(tpfunz pfunz)  { ... if(pfunz(4,a,"Ciao",b,pc,m)) .... }


Un altro utilizzo di typedef è quello di confinare in unico luogo i riferimenti diretti a un tipo. Per esempio, se il programma lavora in una macchina in cui il tipo int corrisponde a 32 bit e noi poniamo:
     typedef  int int32;
avendo cura poi di attribuire il tipo int32 a tutte le variabili intere che vogliamo a 32 bit, possiamo portare il programma su una macchina a 16 bit ridefinendo solamente int32 :
    typedef  long int32;

[p36]

 


 

Strutture

 

Come gli array, in C++ (e in C) le strutture sono gruppi di dati; a differenza dagli array, i singoli componenti di una struttura possono essere di tipo diverso.

Esempio di definizione di una struttura:

struct anagrafico
{         
char nome[20];
int anni;
char indirizzo[30];
} ;

Dopo la parola-chiave struct segue l'identificatore della struttura, detto anche marcatore o tag, e, fra parentesi graffe, l'elenco dei componenti della struttura, detti membri; ogni membro é dichiarato come una normale variabile (è una semplice dichiarazione, non una definizione, e pertanto non comporta la creazione dell'oggetto corrispondente) e può essere di qualunque tipo (anche array o puntatore o una stessa struttura). Dopo la parentesi graffa di chiusura, è obbligatoria la presenza del punto e virgola (diversamente dai blocchi delle funzioni).

In C++ (e non in C) la definizione di una struttura comporta la creazione di un nuovo tipo, il cui nome coincide con il tag della struttura. Pertanto, riprendendo l'esempio, anagrafico è a pieno titolo un tipo (come int o double), con la sola differenza che si tratta di un tipo astratto, non nativo del linguaggio.

Per questo motivo l'enunciato di una struttura è una definizione e non una semplice dichiarazione: crea un'entità (il nuovo tipo) e ne descrive il contenuto. Ma, diversamente dalle definizioni delle variabili, non alloca memoria, cioè non crea oggetti. Perchè ciò avvenga, il nuovo tipo deve essere istanziato, esattamente come succede per i tipi nativi. Riprendendo l'esempio, l'istruzione di definizione:

anagrafico   ana1, ana2, ana3 ;

costruisce gli oggetti ana1, ana2 e ana3, istanze del tipo anagrafico. Solo adesso viene allocata memoria, per ogni oggetto in quantità pari alla somma delle memorie che competono ai singoli membri della struttura (l'operazione sizeof(anagrafico), oppure sizeof(ana1) ecc..., restituisce il numero dei bytes allocati ad ogni istanza di anagrafico).

La collocazione ideale  della definizione di una struttura é in un header-file: conviene infatti separarla dalle sue istanze, in quanto la definizione deve essere (di solito) accessibile dappertutto, mentre le istanze sono normalmente locali e quindi limitate dal loro ambito di visibilità. Potrebbe però sorgere un problema: se un programma è suddiviso in più files sorgente e tutti includono lo stesso header-file contenente la definizione di una struttura, dopo l'azione del preprocessore risulteranno diverse translation unit con la stessa definizione e quindi sembrerebbe violata la "regola della definizione unica" (o ODR, dall'inglese one-definition-rule). In realtà, per la definizione dei tipi astratti (e di altre entità del linguaggio, come i template, che vedremo più avanti), la ODR si esprime in modo meno restrittivo rispetto al caso della definizione di variabili e funzioni (non inline): in questi casi, due definizioni sono ancora ritenute esemplari della stessa, unica, definizione, se e solo se:

  1. appaiono in differenti translation units ,

  2. sono identiche nei rispettivi elementi lessicali,

  3. il significato dei rispettivi elementi lessicali è lo stesso in entrambe le translation units

e tali condizioni sono senz'altro verificate se due files sorgente includono lo stesso header-file (purchè in uno dei due non si alteri il significato dei nomi con typedef o #define !).

 


 

Operatore   .

 

La grande utilità delle strutture consiste nel fatto che i nomi delle sue istanze possono essere usati direttamente come operandi in molte operazioni o come argomenti nelle chiamate di funzioni, consentendo un notevole risparmio, soprattutto quando il numero di membri é elevato.

In alcune operazioni, tuttavia, é necessario accedere a un membro individualmente. Ciò é possibile grazie all'operatore binario   .   di accesso al singolo membro: questo operatore ha come left-operand il nome dell'oggetto e come right-operand quello del membro.
Es.:          ana2.indirizzo

Come altri operatori che svolgono compiti analoghi (per esempio l'operatore [ ] di accesso al singolo elemento di un array), anche l'operatore . può restituire sia un r-value (lettura di un dato) che un l-value (inserimento di un dato).

Es.: int a = ana1.anni; inizializza a con il valore del membro anni dell'oggetto ana1
ana3.anni = 27; inserisce 27 nel membro anni dell'oggetto ana3

 


 

Puntatori a strutture - Operatore ->

 

Come tutti i tipi del C++ (e del C), anche i tipi astratti, e in particolare le strutture, hanno i propri puntatori. Per esempio (notare le differenze):

                       int* p_anni = &ana1.anni;
                   anagrafico* p_anag = &ana1;

nel primo caso definisce un normale puntatore a int, che inizializza con l'indirizzo del membro anni dell'oggetto ana1; nel secondo caso definisce un puntatore al tipo-struttura anagrafico, che inizializza con l'indirizzo dell'oggetto ana1.

Per accedere a un membro di un oggetto (istanza di una struttura) di cui é dato il puntatore, bisogna eseguire un'operazione di deref. . Riprendendo l'esempio precedente, si potrebbe pensare che la forma corretta dell'operazione sia:
                 *p_anag.anni
e invece non lo é, in quanto l'operatore . ha la precedenza sull'operatore di deref. e quindi il compilatore darebbe messaggio di errore, interpretando p_anag.anni come un indirizzo da dereferenziare (l'interpretazione sarebbe giusta se esistesse un oggetto di nome p_anag con un membro di nome anni definito puntatore a int, e invece esiste un puntatore di nome p_anag a un oggetto con un membro di nome anni definito int).

Perché il risultato sia corretto bisognerebbe inserire la deref. del puntatore fra parentesi, cioè:
             (*p_anag).anni
il C++ (come il C) consente di evitare questa "fatica" mettendo a disposizione un altro operatore, che restituisce un identico risultato:
             p_anag->anni

In generale l'operatore -> permette di accedere a un membro (indicato dal right-operand) di un oggetto, istanza di una struttura, il cui indirizzo é dato nel left-operand (ovviamente anche questo operatore può restituire sia un r-value che un l-value).

 


 

Unioni

 

Le unioni sono identiche alle strutture (sono introdotte dalla parola-chiave union al posto di struct), eccetto nel fatto che i membri di ogni loro istanza occupano la stessa area di memoria.

In pratica un'unione consente di utilizzare un solo membro per ogni oggetto (anche se i membri definiti sono più d'uno) e servono quando può essere comodo selezionare ogni volta il membro più appropriato, in base alle necessità.

L'occupazione di memoria  di un'unione coincide con quella del membro di dimensioni maggiori.

 


 

Array di strutture

 

Abbiamo visto negli esempi che i membri di una struttura possono essere array. Anche le istanze di una struttura possono essere array.

Es.:   definizione: struct tipo_stud { char nome[20]; int voto[50];} ;
costruzione oggetti:     tipo_stud studente[40];
accesso: studente[5].voto[10] = 30;
(lo studente n.5 ha preso 30 nella prova n.10 !)

 


 

Dichiarazione di strutture e membri di tipo struttura

 

I membri di una struttura possono essere a loro volta di tipo  struttura. Esiste però il problema di fare riconoscere tale struttura al compilatore. Le soluzione più semplice è definire la struttura a cui appartiene il membro prima della struttura che contiene il membro (così il compilatore é in grado di riconoscerne il tipo). Tuttavia capita non di rado che la stessa struttura a cui appartiene il membro contenga informazioni che la collegano alla struttura principale: in questi casi viene a determinarsi la cosidetta "dipendenza circolare", apparentemente senza soluzione.

In realtà il C++ offre una soluzione semplicissima: dichiarare la struttura prima di definirla! La dichiarazione di una struttura consiste in una istruzione in cui appaiono esclusivamente la parola-chiave struct e l'identificatore della struttura.

Es.:               struct     data ;

chiaramente si tratta di una dichiarazione-non-definizione (questo è il terzo caso che incontriamo, dopo le dichiarazioni di variabili con le specificatore extern e le dichiarazioni di funzioni), nel senso che non rende ancora la struttura utilizzabile, ma è sufficiente affinchè il compilatore accetti data come tipo di una struttura definita successivamente.

Allora il problema è risolto ? No ! Perchè no ? Perchè il compilatore ha un'altra esigenza oltre quella di riconoscere i tipi: deve essere anche in grado di calcolare le dimensioni di una struttura e non lo può fare se questa contiene membri di strutture non definite. Solo nel caso che i membri in questione siano puntatori questo problema non sussiste, in quanto le dimensioni di un puntatore sono fisse e indipendenti dal tipo della variabile puntata.

Pertanto, la dipendenza circolare fra membri di strutture diverse può essere spezzata solo se almeno in una struttura i membri  coinvolti sono puntatori.

Per esempio, una sequenza corretta potrebbe essere:
struct    data ;   dichiarazione anticipata della struttura data
struct persona { char nome[20]; data* pnascita;} ; definizione della struttura principale persona con un membro puntatore  a  data
struct data { int giorno; int mese; int anno; persona caio; } ; definizione della struttura data con un membro di tipo persona

in questo modo il membro pnascita della struttura persona è riconosciuto come puntatore  al tipo data prima ancora che la struttura data sia definita.

Con lo stesso ragionamento si può dimostrare che è possibile dichiarare dei membri di una struttura come puntatori alla struttura stessa (per esempio, quando si devono costruire delle liste concatenate). In questo caso, poi, la dichiarazione anticipata non serve in quanto il compilatore conosce già il nome della struttura che appare all'inizio della sua definizione.

Nota:  

La dipendenza circolare si può avere anche fra le funzioni (una funzione A che chiama una funzione B che chiama una funzione C  che a sua volta chiama la funzione A). Ma in questi casi le dichiarazioni contengono già tutte le informazioni necessarie e quindi il problema si risolve semplicemente dichiarando A prima di definire (nell'ordine) C, B e la stessa A.

 
Per accedere a un membro di una la struttura al cui tipo appartiene il membro di un certo oggetto, é necessario ripetere due volte l'operazione con l'operatore . (e/o con l'operatore -> se il membro è un puntatore). Seguitando con lo stesso esempio :
        costruzione oggetto:    

persona tizio; (da qualche altra parte bisogna anche creare un oggetto di tipo data e assegnare il suo indirizzo a tizio.pnascita)

accesso: tizio.pnascita->anno = 1957;

come si può notare dall'esempio, il numero 1957 é stato inserito nel membro anno dell'oggetto il cui indirizzo si trova nel membro puntatore pnascita dell'istanza tizio della struttura persona.

[p37][p37]

 


 

Strutture di tipo bit field

 

Le strutture di tipo bit field permettono di riservare ad ogni membro un determinato numero di bit di memoria, consentendo notevoli risparmi; il tipo di ogni membro deve essere unsigned int.

Es.:       struct  bit   { unsigned int ma:2; unsigned int mb:1; } ;

la presenza dei due punti, seguita dal numero di bit riservati, identifica la definizione di una struttura di tipo bit field.

 


 

Tipi enumerati

 

Con la parola-chiave enum si definiscono i tipi enumerati, le cui istanze possono assumere solo i valori specificati in un elenco.

Es.:           enum feriale { Lun, Mar, Mer, Gio, Ven } ;

dove: feriale è il nome del tipo enumerato e le costanti fra parentesi graffe sono i valori possibili (detti enumeratori).

In realtà agli enumeratori sono assegnati numeri interi, a partire da 0 e con incrementi di 1, come se si usassero le direttive:
              #define
Lun 0         #define Mar 1          ecc...

Volendo assegnare numeri diversi (comunque sempre interi), bisogna specificarlo.

Es.:           enum dati  { primo, secondo=12, terzo } ;

in questo caso alla costante primo è assegnato 0, a secondo è assegnato 12 e a terzo è assegnato 13. Comunque l'uso degli enumeratori, anzichè quello diretto delle costanti numeriche corrispondenti, è utile in quanto permette di scrivere codice più chiaro ed più esplicativo di ciò che si vuole fare.

Analogamente al tag di una struttura, il nome di un tipo enumerato é assunto, in C++ come un nuovo tipo del linguaggio.
Es.:            feriale oggi = Mar ;

costruisce l'oggetto oggi, istanza del tipo enumerato  feriale e lo inizializza con il valore dell'enumeratore Mar.

Un oggetto di tipo enumerato può assumere valori anche diversi da quelli specificati nella definizione. L'intervallo di validità (detto dominio) di un tipo enumerato contiene tutti i valori dei propri enumeratori arrotondati alla minima potenza di 2 maggiore o uguale al massimo enumeratore meno 1. Il dominio comincia da 0 se il minimio enumeratore non è negativo; altrimenti è il valore maggiore tra le potenze di due negative minori o uguali del minimo enumeratore (si uguagliano poi minimo e massimo scegliendo il più grande in valore assoluto). In ogni caso il dominio non può superare il range del tipo int.
Es.:       enum en1 { bello, brutto } ;           dominio  0:1
enum en2 { a=3, b=10 } ; dominio  0:15
enum en3 { a=-38, b=850 } ; dominio  -1024:1023

come si può notare, il numero complessivo degli enumeratori possibili è sempre una potenza di 2.

Per inizializzare un oggetto di tipo enumerato con un valore intero (anche diverso dalle costanti incluse nella definizione, purchè compreso nel dominio) è obbligatorio il casting.
Es.:     en2 oggetto1 = (en2)14 ;     OK, 14 è compreso nel dominio
en2 oggetto2 = (en2)20 ; risultato indefinito, 20 non è compreso nel dominio
en2 oggetto3 = 3 ; errore: conversione implicita non ammessa

   
Gli enumeratori sono ammessi nelle operazioni fra numeri interi e, in questi casi, sono converititi implicitamente in int.

 


 

Torna all'Indice