Lezione 10 – Come depositare gli elementi per un vettore in C++

Offese-per-email-nessuna-diffamazioneI vettori, questi fastidiosi ma inevitabili enti matematici che costituiscono il mio incubo dalla prima volta in cui li incontrati alle superiori, tornano nuovamente dopo la lezione 8 a loro dedicata. Tutto questo perché i vettori, volenti o nolenti, sono uno dei più importanti elementi in C++ poiché ci danno la possibilità di creare una sequenza di elementi in un certo tipo e che può essere raggiunta attraverso un INDEX (o indice); possiamo, inoltre, estendere il vettore utilizzando il comando PUSH_BACK (che aggiunge un elemento alla fine del vettore) e richiedere il numero degli elementi contenuti all’interno del vettore con SIZE (). Ciò significa che ogni qualvolta che ci ritroveremo a lavorare con liste di elementi che essi siano parole o numeri dovremo ricorrere all’uso dei vettori. Và sottolineato, però, che nel caso della stringa standard vi sono simili proprietà che permettono di creare dei simili “contenitori”, come per esempio LIST e MAP, che però vedremo più in là in un’altra lezione. In ogni caso, molto probabilmente, ci fermeremo sui vettori per un bel po’.

Avevamo già visto come funzionano ma tanto per rinfrescare la memoria partiamo dal primo passo fingendo di non poter ricorrere alla libreria dei vettori, ovvero #include “vector”. Immaginiamoci, quindi, di avere un vettore, denominato anni, di grandezza 4; ciò significa che avremo 4 belle caselline in cui andranno ad inserirsi non solo i propri elementi ma anche la loro grandezza. Ovviamente per mettere tutto questo in un programma dovremo prima di tutto definire una classe, appunto il vettore, dopodiché un numero per definirne la grandezza ed uno per contenere i suoi elementi. Come fa notare Stroustrup sul suo libro Programming Principles and Practice Using C++, su cui mi sto basando per mostrare alcuni esercizi semplici e per non saltare da palo in frasca nelle spiegazioni, potremmo scriverlo in questo modo:

class vector {
      int size, age0, age1, age2, age3;
};

Ma, sebbene questo sembri una bazzecola, questo metodo non ci permette di ricorrere ad elementi come push_back() ritrovandoci impossibilitati nell’aggiunta di un elemento in più avendoli fissati alla quantità di quattro. Dobbiamo, quindi, creare maggiore spazio per poterci permettere di aggiungere successivamente altri elementi all’interno del vettore: per farlo ci serve qualcosa che ci dia la possibilità di cambiare il numero di elementi coinvolti il momento in cui abbiamo bisogno di maggiore spazio. Semplificando, dobbiamo avere qualcosa che funga da indirizzo relativo al primo elemento. In C++ il pointer (che assieme all’array, costituisce la nozione di memoria di C++) ci permette questo. La differenza tra pointer ed array è molto semplice: il primo è relativo ad un singolo elemento mentre il secondo si riferisce ad un insieme di più elementi.

Come si è già notato precedentemente, la memoria del computer è una sequenza di bytes, un numero che indica uno specifico luogo nella memoria viene chiamato address (o indirizzo). Il primo numero di memoria si trova sempre all’indirizzo zero, il secondo all’uno, e via dicendo. Qualsiasi cosa noi immettiamo nella memoria del computer ha un numero di locazione. Immaginiamoci, quindi, questa memoria come un grosso rettangolo pieno di caselle bianche e numerate man mano che le riempiamo con degli elementi questi vengono identificati con il numero della casella in cui entrano.

Possiamo anche archiviare e manipolare gli indirizzi. Un singolo oggetto che contiene un numero d’indirizzo di una variabile viene chiamato pointer (puntatore). Una cosa interessante relativa al pointer è che ci permette di accedere al numero della variabile verso cui puntano in modo diretto utilizzando il nome del pointer preceduto dall’operatore *, il comando verrà letto come “valore puntato da”. Per esempio, il comando int*ptr = &var; verrà letto “il valore puntato dall’int (o l’int pointer) contiene l’indirizzo di var”. L’operatore di indirizzo &, chiamato Address-of Operator (&), è un meccanismo che restituisce l’indirizzo di memoria di una variabile e fa parte dello stesso pointer. Quindi se var inizia all’indirizzo 4096, il pointer manterrà il valore a 4096. Ovviamente, non esiste solo il pointer per l’int ma anche per il char, il double, etc.

Ora, utilizzando l’operatore sizeof possiamo sapere quanta memoria, in termini di byte, realmente richiede un int, un char o un double.

//L'operatore sizeof, esempio desunto da "Programming -- Principles and Practice Using C++" by Bjarne Stroustrup
#include "iostream"
#include "string"
using namespace std;
int main()
{
 cout << "La grandezza del char e' " << sizeof(char) << ' ' << '\n';
 cout << "La grandezza dell'int e' " << sizeof(int) << ' ' << '\n';
 int*p = 0;
 cout << "La grandezza di int* e' " << sizeof(int*) << ' ' << " quindi il valore p puntato dall'int e' a sua volta " << sizeof(p) << '\n';
 return 0;
}

Il momento in cui proveremo a correre questo piccolo programma (utilizzando per esempio http://cpp.sh/) esso ci risponderà:

“La grandezza del char è 1
La grandezza dell’int è 4
La grandezza di int* è 8 quindi il valore p puntato dall’int è a sua volta 8”

Nella memoria del computer, utilizzata per chiamare le funzioni, archiviare il codice e le variabili, vi è anche una parte in qualche modo libera che si può raggiungere con l’operatore chiamato new con il comando, per esempio, double*p = new double [4], così da ordinare al computer di allocare quattro doubles nello spazio libero. In altre parole new è una funzione di locazione che crea uno spazio per un nuovo oggetto nella memoria utilizzandone lo spazio libero. L’operatore new, inoltre, restituisce un pointer all’oggetto che ha creato; quindi, se crea più oggetti (ciò che viene chiamato array) restituisce un pointer al primo di questi oggetti; se questo oggetto è di tipo X ovviamente il pointer restituito dal new sarà sempre dello stesso tipo X. Per esempio int* pi = new int andrà a creare uno spazio per un nuovo int mentre int* qi = new int[4] allocherà quattro int, o se preferiamo un array contenente quattro int. Va ricordato, anche, che i pointers non vanno pasticciati assieme se non vogliamo creare errori nel nostro programma quindi pi non è uguale a pd né pd è uguale a pi: infatti, pi si riferisce agli int mentre pd ai doubles.

Come abbiamo detto un pointer punta verso un oggetto nella memoria, l’operatore contents of, chiamato deference operator, ci permette di scrivere e leggere l’oggetto che un pointer p sta indicando. Per esempio, double x = *p leggerà l’oggetto puntato da p mentre *p = 7.7 scriverà all’oggetto puntato sempre da p. Quando utilizziamo accando a p le parentesi quadre magfari con un numero al loro interno, mettiamo [3] questi leggerà o scriverà sul quarto oggetto puntato da p (quarto e non terzo perché, ricordiamolo, la conta parte sempre e comprende lo zero). Questo è il modo più semplice ed efficente per accedere alla memoria di cui abbiamo bisogno per implementare un vettore.

Il problema più grosso con i pointers è che questi non sono in grado di conoscere quanti elementi vi sono nella porzione a cui stanno puntando e per cosa in specifico vengono utilizzate. Ed è proprio questa la grossa differenza che intercorre tra pointers e vettori, questi ultimi infatti sono in grado, al contrario dei primi, di conoscere quanti elementi si trovano in un dato spazio.

Il momento in cui utilizziamo l’operatore new, come abbiamo detto, utilizziamo un free store che, poi, di norma dovrà ritornare libero il momento in cui abbiamo finito di utilizzarlo cosicché possa usufruirne nuovamente in futuro. Per fare ciò utilizziamo l’operatore delete applicandolo ad un pointer che new ci ha restituito liberando la memoria. Quindi, ogni qualvolta vorremo applicarlo utilizzeremo, per esempio, delete p nel caso stessimo utilizzando un oggetto individuale mentre delete [ ] p per liberare un’array.

Ovviamente uno degli errori più comuni è dimenticarsi proprio di usare delete e liberare lo spazio utilizzato. La stessa cosa accade con il comando clean_up () che, in questo caso equivale a delete ma viene usato per cancellare gli elementi del vettore.

Ora sappiamo come depositare gli elementi per un vettore semplicemente distribuendo sufficiente spazio sul free store e raggiungendoli attraverso l’uso di un pointer.

Per rendere un vettore utilizzabile, inoltre, abbiamo bisogno di un modo per scrivere e leggere gli elementi utilizzando, per esempio, le funzioni get () e set() le quali accedono ad essi utilizzando l’operatore [ ] sul pointer dell’elemento voluto.

A questo punto potremo, per esempio, trovare il type void* ovvero “un pointer che indica una memoria di cui il compiler non conosce il type” che viene, di norma, impiegato quando vogliamo trasmettere un address tra pezzi di codice che non conoscono il type l’uno dell’altro. Ovviamente, se il compiler non conosce verso cosa il void* punta dobbiamo dirglielo noi attraverso il codice, quindi:

void* pv1 = new int;                   //va bene perché int* viene convertito a void*
void* pv2 = new double [5];      //va bene perché double* viene convertito a void*
void* pv3 = pv;                           //va bene perché il void* è fatto proprio per copiare
int* pi = static_cast<int>(pv);   //va bene: conversione esplicita

double* pd = pv;                        //errore: non si può convertire un void* in double*
void v;                                         //errore: non ci sono oggetti di type void
void f();                                        //errore: f() non ritorna nulla

Il comando static_cast viene utilizzato molto raramente e solo in caso di vero bisogno e converte esplicitamente dei pointer messi in relazione tra loro come void* e double*. Simili operazioni, ovvero utilizzate davvero sporadicamente, sono reinterpret_cast (utilizzata con type non in relazione come int e double) e const_cast.

Quando abbiamo intenzione di cambiare parametro di una variabile e farlo diventare un valore computato da una funzione abbiamo tre diverse scelte:

  1. int incr_v(intx) { return x+1; }           //computa un nuovo valore e lo ritorna
  2. void incr_p(int* p) { ++* p; }             //oltrepassa un pointer, “de-referenzia” (dereference) ed incrementa il risultato
  3. void incr_r(int& r) { ++r; }                //oltrepassa un riferimento (o reference)

Il segno * è chiamato operatore di dereference: utilizzando tale operatore avremo “il valore puntato da” un pointer. Questo asterisco, però, non va assolutamente confuso con quello relativo alla dichiarazione, o inizializzazione, del pointer il quale segnala solamente che stiamo lavorando, appunto, con un pointer.

Il segno &, invece, è chiamato operatore di reference (o riferimento) il quale può essere letto come “valore puntato verso”, ovvero indirizzato verso una variabile.

Ora, come scegliere quale di queste tre opzioni è più indicata? Ed ecco la risposta che non piace a nessuno sentirsi dare: dipende dalla situazione e dalla natura della funzione con cui stiamo lavorando. Il primo caso è più indicato per piccoli oggetti; la seconda opzione andrà utilizzata preferibilmente per funzioni dove viene utilizzato il “no object” rappresentato da 0; e per tutti le altre possibilità si andrà a pescare l’operatore di riferimento.

Con le spiegazioni in questo articolo ci troviamo ad un livello base molto vicino a quello hardware poiché le operazioni sui pointers di cui abbiamo parlato puntano direttamente ad istruire il computer su come muoversi. Perché interfacciarci con questo tipo di livello quando potremmo occuparci di qualcosa di un po’ più avanzato? Beh, semplicemente perché il momento in cui abbiamo in mano le basi e conosciamo esattamente come ci stiamo muovendo quando sceglieremo di spostarci al livello successivo avremo una maggiore comprensione di ciò verso cui andremo ad approcciarci.

Share the love

Comincia la discussione

Lascia un commento

Il tuo indirizzo email non sarà pubblicato.