GLG Programs fa uso di cookie per migliorare l'esperienza di navigazione degli utenti, ma non per tracciarne un profilo. Proseguendo nella navigazione, si accetta implicitamente l'utilizzo dei cookie.
[OK]Note legaliNon mi importa
Logo GLGPrograms Logo GLGPrograms

Basi di programmazione C++

[Icona della Guida] Introduzione alla programmazione C/C++

Appunti di programmazione C e C++ su GNU/Linux

Parte terza

Tipi derivati

Uno degli scopi del linguaggio C++ è aumentare il livello di astrazione dei tipi che possono essere rappresentati. Fino ad ora abbiamo utilizzato solamente i tipi fondamentali (int, double, ecc...) ma niente ci impedisce di costruire dei tipi persona o automobile per meglio rappresentare la realtà che ci circonda.

I tipi di dato messi a disposizione dal linguaggio non sono sufficienti a coprire l'immensa gamma di informazioni che potrebbe essere necessario elaborare. A questo scopo il C++, oltre a implementare le classi, che vedremo più avanti, eredita dal C tre strumenti per costruire tipi personalizzati, detti tipi derivati a partire dai tipi fondamentali.

Si ricordi che, per convenzione, i nomi dei tipi derivati vengono scritti con la prima lettera maiuscola.

Enumerati

Gli enumerati consentono di costruire tipi conoscendo il dominio dei loro elementi. In altre parole, conoscendo tutti i diversi valori sensati che può assumere un elemento di un determinato tipo, è possibile dichiarare una variabile a cui sia permesso di assumere tutti e soli i valori consentiti.

L'esempio classico è quello del semaforo: si vuole dichiarare una variabile che possa assumere solamente i tre colori consentiti di Verde, Giallo e Rosso:

enum Semaforo { Verde, Giallo, Rosso }; /* dichirazione del nuovo tipo Semaforo */

Semaforo ViaMeucciViaMarconi;           /* definizione di una variabile rappresentante il
                                           Semaforo tra via Meucci e via Marconi */

ViaMeucciViaMarconi = Rosso;            /* il semaforo è Rosso */
...
ViaMeucciViaMarconi = Verde;            /* il semaforo è Verde */
...
ViaMeucciViaMarconi = Blu;              /* errore in fase di compilazione, il semaforo non può essere Blu */

Osservazione: si sarebbe potuto realizzare un semaforo anche facendo uso di un semplice intero, assegnando un numero ad ogni colore. Tuttavia, in caso di inserimento di un valore errato, il compilatore non avrebbe segnalato nessun errore, e la gestione dell'errore sarebbe stata completamente a carico del programmatore.

In realtà, il compilatore associa davvero un numero ad ogni nome mnemonico, partendo da 0: è possibile vederlo stampando la variabile:

cout << ViaMeucciViaMarconi; /* '1' se è Giallo */

Osservazione: dopo la dichiarazione, Semaforo è a tutti gli effetti un tipo, come int o char. Può essere utilizzato per dichiarare variabili (più Semafori), array, si possono creare puntatori e riferimenti ad esso, può essere passato a funzioni, eccetera... Tutti questi argomenti verranno affrontati in seguito, ma è bene tenere a mente questa cosa, in quanto viene sottovalutata finché non si è digerita.

Un altro esempio mostrerà altri possibili utilizzi: supponiamo di avere la necessità di memorizzare dei dati per ogni mese dell'anno, in maniera analoga a come abbiamo fatto con le temperature per gli array. Potremmo costruire un programma del genere:

...
temperatura[1] = 4;
temperatura[4] = 20;
temperatura[12] = 6;
...

Questo modo di procedere, apparentemente pulito, può risultare molto pericoloso. Ad esempio, l'espressione temperatura[12] = 6; appare del tutto innocua: Dicembre ha una temperatura media di 6. In realtà questa è una lettura superficiale: con temperatura[12] ci stiamo riferendo ad un ipotetico tredicesimo mese, che non esiste. E, analogamente, con temperatura[1] non ci riferiamo alla temperatura di Gennaio, ma a quella di Febbraio. L'unico modo di scongiurare questi errori del programmatore, è quello di costuire un apposito tipo mese che possa assumere solamente i numeri corrispondenti ai mesi.

Inoltre, sarebbe molto interessante poter assegnare ad ogni numero valido anche un nome mnemonico, in modo da non doversi più preoccupare di calcolare manualmente i vari valori.

Gli enumerati consentono anche di realizzare ciò: viene associato un nome mnemonico ad ogni numero di un sottoinsieme di interi, e vengono utilizzati in maniera trasparente al programmatore per elaborare i dati:

enum { Gennaio = 0, Febbraio = 1, Marzo = 2, Aprile = 3,
       Maggio = 4, Giugno = 5, Luglio = 6, Agosto = 7,
       Settembre = 8, Ottobre = 9, Novembre = 10, Dicembre = 11 };
...
temperatura[Gennaio] = 4;
temperatura[Aprile] = 20;
temperatura[Dicembre] = 6;
...

Naturalmente, si possono utilizzare gli interi che si ritengono più comodi: non è indispensabile che partano dallo 0, né che siano in ordine; se hanno poca importanza è addirittura possibile ometterli tutti, o solo alcuni, come abbiamo fatto nell'esempio del semaforo. Si possono anche assegnare più nomi mnemonici allo stesso valore numerico: sarà cura del programmatore ricercare la soluzione migliore per risolvere il problema.

Strutture

Quando si ha a che fare con una realtà complessa, può essere utile poter raggruppare più tipi fondamentali sotto uno stesso denominatore comune, in una struttura.

Si supponga di dover raccogliere delle informazioni su età e sesso di molte persone: con le strutture, età e sesso possono essere inserite in un unico tipo, che poi può essere replicato in un vettore.

struct Persona
{
  short anno;
  char sesso; /* F oppure M */
};

Abbiamo appena definito la struttura Persona, che si caratterizza per avere un anno di nascita e un sesso. Volendo essere pignoli, la variabile sesso, potendo contenere solo F oppure M, dovrebbe essere precedentemente dichiarata come un enumerato, ma questo lo si lascia come esercizio al lettore.

Definiamo una partecipante al sondaggio e inizializziamo il suo contenuto. Per accedere agli elementi della struttura, si deve far uso dell'operatore . (punto):

Persona alice;
alice.anno = 1900;  /* alice.anno è un intero (corto) */
alice.sesso = 'F';  /* alice.sesso è un char */

In questo esempio, viene mostrata un'applicazione pratica:

/* Memorizzazione dei partecipanti al sondaggio in un vettore */
#include <iostream>
using namespace std;

int main()
{
  enum Sesso { Femmina, Maschio };
  struct Persona
  {
    short anno;
    Sesso sesso;
  };

  const short n = 3;  /* numero di partecipanti */
  Persona partecipante[n];

  /* Lettura dei dati dei partecipanti */
  cout < "Inserire anno di nascita e sesso (M o F) per ogni partecipante,\nseparati da spazio:" < endl;
  char sesso;
  for (int i = 0; i < n; ++i)
  {
    cout << "#" << i << ": ";
    cin >> partecipante[i].anno >> sesso;
    if (sesso == 'm' || sesso == 'M') partecipante[i].sesso = Maschio;
    else partecipante[i].sesso = Femmina;
  }

  /* Stampa dei dati per conferma */
  cout << "Conferma:" << endl;
  for (int i = 0; i < n; ++i)
  {
    cout << "#" << i << ": " << partecipante[i].anno << "\t" << partecipante[i].sesso << endl;
  }
  return 0;
}

Una struttura viene allocata in memoria allocando singolarmente e in maniera consecutiva i vari oggetti che la compongono.

  |    |
  +----+
  |    |
  |    |
  +----+
  |    | ↑ char  ↑  Persona
  |    | ↓       |
  +----+         |
  |    | ↑ short |
  |    | |       |
  +----+ |       |
  |    | |       |
  |    | ↓       ↓
  +----+
  |    |

Unioni

Talvolta si ha necessità di risparmiare memoria, perciò conviene compattare le informazioni nello spazio più piccolo possibile. Simili nella forma alle strutture, le unioni, anziché contenere più oggetti memorizzati in maniera sequenziale, mantengono tutti gli oggetti nella stessa area di memoria, grande quanto il più grande oggetto che devono contenere.

Nell'unione la memoria è quindi condivisa da più oggetti: ne segue che ogni oggetto può essere elaborato solamente quando gli altri non sono in uso, per evitare di leggere o sovrascrivere informazioni fuori contesto.

union Persona
{
  short anno;
  char sesso;
};

Persona alice;
alice.anno = 1900;
cout << alice.anno;   /* 1900 */
alice.sesso = 'F';
cout << alice.sesso;  /* F */
cout << alice.anno;   /* 1862 */

Puntatori

Un puntatore (pointer in inglese) è un particolare tipo di dato che contiene un indirizzo di memoria, cioè l'indirizzo di una cella fisica all'interno della RAM. I puntatori sono utili per accedere a determinate regioni della memoria da qualunque punto del programma. La maggior parte dei linguaggi di programmazione non prevede l'utilizzo di puntatori, ma essi sono un punto di forza del C (e del C++) che lo ha reso particolarmente popolare.

Lavorare con i puntatori apre numerose strade, è divertente ma anche pericoloso e, inizialmente, molto frustante. Si invita a procedere con attenzione, concentrazione e cautela. Si cercherà di fare il possibile per rendere quanto più chiara questa sezione, anche se con un'animazione o un insegnante alla lavagna, probabilmente sarebbe molto più facile.

Inizializzazione

Un puntatore viene definito specificando il tipo puntato seguito da un * (asterisco, stella o star):

int* p;   /* forma corretta, ma prona ad errore */

Il puntatore p così definito contiene un dato che, come abbiamo già visto, è quasi impossibile prevedere. L'indirizzo che contiene è praticamente casuale: potrebbe puntare ad una cella già in uso, ad una cella riservata al sistema operativo, o addirittura ad una cella che non esiste. Utilizzare l'indirizzo contenuto in un puntatore così definito sarebbe pura follia: potremmo sovrascrivere i programmi in memoria, bloccare il sistema operativo e persino danneggiare il nostro hardware. Tuttavia, in tali condizioni di pericolo, tutti i moderni sistemi operativi intervengono arrestando forzatamente il nostro programma e restituendo un errore di segmentazione (segmentation fault).

Per rendere innocuo un puntatore, è sufficiente inizializzarlo a 0: se si dovesse utilizzare un puntatore contenente questo indirizzo speciale, l'istruzione verrebbe semplicemente ignorata, evitando situazioni pericolose. Un modo esteticamente più gradevole di inizializzare un puntatore a 0 è tramite l'uso della costante NULL (che vale esattamente 0):

int* p = NULL;

Adesso assegnamo al nostro puntatore l'indirizzo noto di una variabile. Per ottenere tale indirizzo si utilizza l'operatore & sulla variabile in questione:

int i = 18;
int* p = &i;  /* assegno l'indirizzo in memoria della variabile i al puntatore p */

Se stampiamo il contenuto di p possiamo vedere l'indirizzo della variabile i in notazione esadecimale:

cout << p;  /* 0xabab1234 è un possibile risultato */

Dereferenziazione

Naturalmente, utilizzando il puntatore, è possibile accedere al contenuto della variabile puntata, utilizzando l'operatore di dereferenziazione. Ecco come dereferenziare un puntatore:

cout << *p; /* stampa '18' */

In questa illustrazione, si può osservare una rappresentazione schematica della memoria, rappresentata a celle, che aiuterà a comprendere meglio il concetto di puntatore.

        |    |
        +----+
  0x9B  |0x99| ---+  p
        |    |    |
        +----+    |
  0x9A  |    |    |
        |    |    |
        +----+    |
  0x99  | 18 | <--+  i
        |    |
        +----+
  0x98  |    |

Osservazione: la rappresentazione non è necessariamente esatta. Poiché i è di tipo intero, occuperà quasi certamente 4 byte, cioè 4 celle di memoria, mentre nel disegno ne occupa solamente due (0x99 e 0x9A).

Osservazione: la dimensione di un puntatore è indipendente dal tipo puntato: esso contiene sempre e solo un indirizzo di memoria, perciò è dimensionato in maniera tale da poterla indicizzare completamente. Nei sistemi a 32bit, esso ha dimensione di, appunto, 32bit, cioè 4 byte, mentre nei sistemi a 64bit ha dimensione di 8 byte.

Segue un esempio. Compilarlo, verificarne il funzionamento, comprenderlo e modificarlo a piacere:

#include <iostream>
using namespace std;

int main()
{
  int i = 18;
  int* p = &i;

  cout << "i\t*p\t&i" << endl;

  cout << i << "\t" << *p << "\t" << p << endl;

  i = 20;
  cout << i << "\t" << *p << "\t" << p << endl;

  *p = 22;
  cout << i << "\t" << *p << "\t" << p << endl;
  return 0;
}

Un puntatore è semplicemente una variabile. In quanto tale, è possibile anche definire puntatori ad altri puntatori, semplicemente utilizzando più volte *. Vedremo in seguito qualche esempio.

Al momento, la logica dei puntatori appena spiegata appare del tutto inutile, ma si vedrà presto un loro utilizzo pratico per l'allocazione dinamica della memoria, e, nel capitolo delle funzioni, si comprenderà quanto i puntatori siano essenziali nel linguaggio.

Digressione: scrivere del codice con uno stile omogeneo consente di evitare numerosi errori di programmazione e anche molti mal di testa. Nonostante ciò, persino Stroustrup ha difficoltà a stabilire quale sia la forma esteticamente migliore per definire un puntatore, tanto che su questo (e altri) argomenti ha anche scritto un libro di 480 pagine, perciò non mi vergognerò a confessare apertamente che anche io litigo con me stesso ogni volta che devo definire un puntatore come int* p, come int *p o addirittura come int * p. I veri programmatori C++ scrivono int* p, ma poi si mordono le mani quando devono fare definizioni multiple.

Allocazione dinamica della memoria

Molto spesso non si conosce a tempo di compilazione la dimensione dei dati che un programma deve elaborare; anche se la si conosce, potrebbe comunque risultare necessario allocare e deallocare memoria più volte per risparmiarla. Per questo motivo, è possibile utilizzare a proprio piacere una regione di memoria dedicata allo scopo.

Allocazione automatica

Le variabili definite fino a questo momento sono sempre state allocate automaticamente nello stack, una regione di memoria del nostro programma che si comporta come una pila o lista LIFO (Last In - First Out). In poche parole, le variabili occupano celle di memoria consecutive via via che vengono definite, e successivamente vengono deallocate quando finisce il loro ciclo di vita. Ad esempio, si pensi alle variabili definite nel for come ad una pila di scatole da scarpe: esse prendono posto nello stack sopra le variabili già presenti, e, quando finisce il ciclo, vengono rimosse dalla cima, senza toccare le altre.

Allocazione manuale

Esiste un'altra regione, convenzionalmente chiamata heap, nella quale possono essere allocate celle di memoria su richiesta esplicita del programma. All'allocazione manuale nello heap deve poi seguire una deallocazione manuale quando le variabili non sono più necessarie, altrimenti si rischia di rimanere a corto di memoria (delete).

new

new è una parola chiave e viene usata per allocare variabili nello heap, variabili delle quali viene restituito l'indirizzo. Per allocare una nuova variabile, è sufficiente specificarne il tipo e memorizzare il suo indirizzo in un puntatore:

int* p = new int;

Attenzione: se la memoria è finita e non è possibile definire nuovi elementi, new restituisce NULL.

Una situazione comune molto interessante, che ci ha già creato qualche problema in precedenza, è quella in cui dobbiamo memorizzare un numero sconosciuto di valori all'interno di un array. Grazie a new possiamo allocare un array grande quanto desideriamo semplicemente chiedendolo all'utente:

cin >> numeroValori;
int* temperatura = new int[numeroValori];

Osservazione: l'identificatore (o nome) di un array non è altro che un puntatore al suo primo elemento.

int temperatura[31];
int* temperatura = new int[31];

Sia nel primo che nel secondo caso, temperatura è un puntatore a intero. Nel primo caso, vengono automaticamente allocati 31 interi a partire dall'ultima locazione dello stack; nel secondo caso, è il programmatore che chiede esplicitamente di riservare 31 locazioni di memoria consecutive nello heap. Nel primo caso, la memoria verrà deallocata automaticamente al termine del blocco; nel secondo caso, il programmatore dovrà esplicitamente richiedere la deallocazione delle celle di memoria riservate.

delete

Per deallocare una sola variabile dallo heap è sufficiente utilizzare la parola chiave delete:

delete p;

Per deallocare un intero array, si usa invece delete[ ]:

delete[] temperatura;

Nel seguente esempio, viene chiesto all'utente di inserire il numero di misurazioni che sono state effettuate, dopodiché viene allocato un vettore grande abbastanza da contenerle tutte e viene popolato con le misurazioni lette dal flusso di ingresso. Successivamente, si decide di utilizzare i dati memorizzati per calcolarne la media. Fin qui, sarebbe stato possibile realizzare il compito anche senza allocare manualmente il vettore, ma semplicemente tenendo traccia della sola somma delle misurazioni; però alla fine si decide di stampare un elenco delle misurazioni acquisite per conferma: ciò risulterebbe impossibile se non si fossero precedentemente memorizzate.

#include <iostream>
using namespace std;

int main()
{
  cout << "Quante misurazioni di temperatura? ";
  short n;
  cin >> n;

  if (n > 0)
  {
    int* temperatura = new int[n];

    if (temperatura != NULL)
    {
      int i;
      for (i = 0; i < n; ++i)
      {
        cout << "Temperatura #" << i << "? ";
        cin >> temperatura[i];
      }

      int somma = 0;
      for (i = 0; i < n; ++i)
      {
        somma += temperatura[i];
      }
      cout << "Temperatura media: " << somma / n << endl;

      cout << "Temperature inserite (conferma):" << endl;
      for (i = 0; i < n; ++i)
      {
        cout << "Temperatura #" << i << ": " << temperatura[i] << endl;
      }

      delete[] temperatura;
    }
    else cout << "Memoria esaurita!" << endl;
  }
  else cout << "Inserire un numero positivo!" << endl;

  return 0;
}

Si osservino con attenzione tutti i controlli che vengono effettuati prima di entrare nel vivo del programma: mai fidarsi dell'utente, che potrebbe inserire valori non validi per la dimensione del vettore (non necessariamente in cattiva fede), e nemmeno del computer, che potrebbe aver esaurito la memoria disponibile.

Puntatori costanti e puntatori a costanti

La parola chiave const può essere utilizzata per dichiarare puntatori a costanti, cioè a celle di memoria il cui contenuto non può cambiare, oppure per dichiarare puntatori costanti, cioè il cui contenuto (indirizzo dell'oggetto puntato) non può cambiare.

Nel primo caso, la parola chiave const precede la dichiarazione tipo*, mentre nel secondo caso la segue. Si possono anche combinare insieme.

int i, j;
const int* p = &i;  /* p non può essere dereferenziato per modificare il contenuto di i, */
p = &j;             /* ma successivamente può puntare a j */
int* const q = &i;  /* q non può essere modificato, quindi può essere utilizzato solo per puntare i e non a j, */
*q = 3;             /* ma dereferenziandolo è possibile cambiare il contenuto di i */
const int k;
const int* r = &k;    /* la forma int* r = &k non è ammessa, in quanto, derefenziando il puntatore r,
                         potrebbe essere possibile cambiare il contenuto della costante k */
const int* const s = &i; /* s può puntare solo a i e non a j,
                            e non può comunque cambiarne il valore */

Si consiglia di soffermarsi attentamente sul precedente esempio, anche se a prima vista appare un'accozzaglia di simboli molto criptica. Ci sono cose ben peggiori.

Aritmetica dei puntatori

In C (e naturalmente anche in C++) esiste un insieme di regole atte a dare un senso ad alcune operazioni aritmetiche elementari sui puntatori. Si possono distinguere due casi: operazioni tra un puntatore e un intero e operazioni tra due puntatori.

Operazioni tra un puntatore e un intero

Sommando o sottraendo un numero intero a un puntatore, l'indirizzo che esso contiene non viene incrementato o decrementato di tante unità quante ne sono specificate dal numero; bensì, l'intero indica quanti tipi base sommare o sottrarre. Perciò, sommando n a un tipo* (puntatore a tipo), il suo indirizzo sarà incrementato di n × sizeof(tipo). Ad esempio (esempio per capire, ma non da riprodurre e soprattutto da non dereferenziare ciecamente):

int i;            /* supposto &i == 0x100 e sizeof(int) == 4 */
int* p = &i;
p = p + 2;        /* p = 0x108 e non 0x102 -- Attenzione: non si sa cosa ci sia all'indirizzo 0x108 */

Se utilizzato in maniera cosciente, con questo metodo è possibile accedere agli elementi di un vettore, oltre che con l'operatore [ ], anche attraverso l'aritmetica dei puntatori, sfruttando il fatto che gli elementi occupano celle contigue di memoria. Si ricordi che l'identificatore di un array non è altro che il puntatore al suo primo elemento.

int temperatura[12];
temperatura[3] = 6;
*(temperatura + 3) = 8;
cout << temperatura[3]; /* stampa '8' */

Operazioni tra due puntatori

L'unica operazione consentita tra due puntatori è la differenza e, naturalmente, può essere effettuata solamente tra puntatori allo stesso tipo di oggetti. Contrariamente a quanto ci si potrebbe aspettare, applicando l'operatore di sottrazione a due puntatori non si ha come risultato la differenza degli indirizzi (ossia il reale contenuto del puntatore), ma il numero di elementi del tipo base che risiedono tra i due. Con una semplice formula:

tipo * p, * q;

        indirizzo in p - indirizzo in q
p - q = -------------------------------
                  sizeof(tipo)

Per esempio:

int temperatura[12];
int * p, int * q;
p = &temperatura[10];
q = &temperatura[3];
cout << p - q;        /* stampa '7' */

Suggerimento: quando si lavora con i puntatori è utile utilizzare alcuni strumenti che consentano di evidenziare subito possibili errori: tra i tanti, valgrind è un analizzatore di memoria, alleato dei programmatori, che mostra informazioni su eventuali segmentation fault o su possibili memory leak (esaurimento di memoria). Dopo averlo installato sul proprio sistema, che è GNU/Linux, è sufficiente avviarlo passandogli come parametro il proprio programma eseguibile, ad esempio:

valgrind ./a.out

Liste, pile e code

Sfruttando le potenzialità dell'allocazione dinamica della memoria e dei puntatori, è possibile realizzare delle strutture dati molto utili. Riprendendo uno degli esempi precedenti, si può avere la necessità di memorizzare un numero di temperature sconosciuto a priori persino all'utente, che le inserisce una dopo l'altra finché non sono finite. Una situazione analoga è anche la coda che si fa all'ufficio postale.

Per risolvere questi problemi sono state inventate le liste, delle strutture dati realizzate da una coppia valore - puntatore, in cui ogni elemento non solo contiene l'informazione, ma anche l'indirizzo dell'informazione successiva. L'ultimo dato possiede, come indirizzo del successivo, NULL. Una rappresentazione schematica di una lista è questa: i dati si trovano nello heap nelle locazioni disponibili al momento della loro allocazione (non necessariamente locazioni consecutive).

  +--+--+               +--+--+   +--+--+
  |a | *|               |d | *|-->|e | 0|
  +--+--+               +--+--+   +--+--+
       \                /
        \              /
        +--+--+   +--+--+
        |b | *|-->|c | *|
        +--+--+   +--+--+

Alcuni argomenti necessari alla realizzazione di liste non sono ancora stati affrontati (per esempio, le struct); quando li affronteremo, comunque non realizzeremo liste: esistono classi e funzioni di libreria già pronte che potremo utilizzare a questo scopo. Realizzare una lista, comunque, non sarebbe un cattivo esercizio da affrontare alla fine della guida.

Pile e code non sono altro che nomi convenzionali per identificare liste gestite in maniera diversa: le prime hanno una gestione LIFO Last In - First Out, come la pila dei panni da stirare, mentre le seconde hanno una gestione FIFO First In - First Out, come la coda alle poste. Nelle prime, viene inserito e tolto sempre l'elemento finale (stirato il capo d'abbigliamento più in alto, anche se è l'ultimo ad essere stato caricato), mentre nelle seconde viene inserito il dato in coda ed estratto il dato in testa (l'ultimo cliente si mette in fondo alla fila, mentre quello che è arrivato prima viene servito).

Pagina scritta da Giovan BattistaGiovan Battista

Hai una domanda? Scrivici!
Questa pagina ti è piaciuta? Condividila!
Share on Facebook Share on Google+ Share on linkedin