Questo perché il C++ ha due modi per memorizzare le stringhe, uno ereditato dal C, ed uno introdotto dallo stesso C++.
Vediamo prima il metodo più vecchio, quello C-style:
ricorderete bene che tra i vari tipi del C, ce n'è uno utilizzato per memorizzare i caratteri. Mi riferisco al tipo char.
In effetti l'utilità di questo tipo è limitata, potendo una variabile di tipo char contenere solo un singolo carattere.
Tuttavia nel C il tipo char viene utilizzato anche per lavorare con le stringhe di testo.
Com'è possibile se abbiamo appena detto che una variabile char può contenere un singolo carattere?
E' possibile tramite l'utilizzo degli array, proprio quelli che abbiamo visto nel post precedente.
In C una stringa è rappresentata come un array di caratteri, dove ogni elemento dell'array contiene un singolo carattere.
Prima di mostrare un esempio dovete sapere che in C ogni stringa termina con un carattere speciale, detto carattere null, e rappresentato con il simbolo: \0
Questo è il modo in cui il compilatore capisce che quella successione di caratteri va trattata come una stringa, ed identifica nel carattere null la fine della stringa stessa.
Vediamo un esempio:
char complimento[5] = { 'B', 'E', 'L', 'L', 'A' };
char saluto[5] = { 'C', 'I', 'A', 'O', '\0' };
In entrambi i casi abbiamo un array di 5 elementi.
In entrambi i casi inizializziamo i 5 elementi memorizzando in ogni elemento dell'array un singolo carattere.
Ad esempio complimento[2] conterrà il carattere 'L', mentre saluto[0] conterrà il carattere 'C' (potete verificarlo utilizzando cout per stampare il contenuto di un singolo elemento).
In entrambi i casi abbiamo una successione di caratteri, ma solo nel secondo caso si tratta di una stringa. Infatti solo nel secondo caso è presente il carattere null che termina la stringa.
Se infatti proviamo a stampare il contenuto dell'array saluto[5] tramite cout, a schermo verrà mostrata la scritta CIAO.
Mentre se proviamo a fare la stessa cosa con l'array complimento[5], il programma stamperà la parola BELLA ma invece di fermarsi all'ultimo carattere continuerà a stampare quello che trova in memoria nelle locazioni successive a quella in cui è memorizzato il carattere 'A', e proseguirà sinché non incontrerà in memoria un carattere null \0
Infatti nel momento della dichiarazione, un array alloca lo spazio necessario a contenere tot valori di quel tipo, e lo spazio allocato è contiguo. Vediamo meglio cosa significa questo.
int numeri[4]; //dichiara un array di 4 interi
La riga qui sopra dichiara un array i 4 elementi interi.
Abbiamo detto che durante la dichiarazione di una variabile, e quindi anche di un array, il compilatore riserva in memoria lo spazio necessario a contenere il tipo di dato di quella variabile.
Ad esempio:
int variabile1;
long variabile2;
variabile1 è una variabile di tipo int, e quindi il compilatore allocherà una memoria di 16 bit, e quando assegneremo un valore alla variabile, quel dato verrà salvato nella memoria allocata a cui la variabile fa riferimento dal momento della sua dichiarazione in poi.
variabile2 invece è una variabile di tipo long, che come ricorderete dice al compilatore di riservare uno spazio in memoria di 32 bit. Infatti ricorderete che il tipo long esiste per permettere di memorizzare numeri molto grandi, per i quali non bastano 16 bit.
Ora, cosa avviene durante la dichiarazione del nostro array di 4 interi?
int numeri[4];
Abbiamo detto che il tipo int fa sì che il compilatore riservi ad una variabile lo spazio di 16 bit.
Ma nel nostro array non abbiamo una singola variabile, ma 4 elementi di un array di interi.
Perciò il compilatore riserva 16 bit * 4 = 64 bit di spazio in memoria.
Se consideriamo che 1 byte è formato da 8 bit, e una comune memoria RAM è formata da locazioni di 1 byte ciascuno, possiamo concludere che un array di 4 elementi interi richiede l'allocazione di uno spazio di 8 byte, e cioé di 8 locazioni: 2 locazioni per ogni elemento dell'array.
Non confondete il termine locazione di memoria, con il termine allocazione:
le locazioni di memoria sono le parti logiche che compongono la memoria RAM, mentre allocare uno spazio in memoria significa riservare un tot di spazio per una variabile.
Possiamo quindi dire che la dichiarazione di un array di 4 interi alloca uno spazio di 8 locazioni.
Ma soprattutto, ed arriviamo alla questione più importante, lo spazio allocato per gli elementi di un array è contiguo, e cioé le locazioni di memoria a cui punta ogni elemento dell'array sono una successiva all'altra.
Questa è la RAM, formata da tante locazioni di memoria, di 1 byte (8 bit) ciascuna:
| LOCAZIONE 1 | LOCAZIONE 2 | LOCAZIONE 3 | LOCAZIONE 4 | ecc..Ricordiamo che in informatica 1 bit è l'unità di misura minima dell'informazione, e può assumere solo i valori 0 e 1. In informatica ogni cosa è rappresentata sotto forma di bits.
| 00000000 | 00000001 | 00000010 | 00000011 |
| 8 bit | 8 bit | 8 bit | 8 bit |
Proviamo a dichiarare un array a intero di 2 elementi:
int mioArray[2]; //dichiarazione di un array a intero di due elementi
Assegniamo ora ad entrambi gli elementi dell'array un valore:
mioArray[0] = 666; //primo elemento dell'array
mioArray[1] = 418; //secondo elemento dell'array
Se una variabile int richiede l'allocazione di 16 bit di memoria, allora un array a int di due elementi richiederà l'allocazione di 32 bit (16 bit * 2 elementi interi).
Se, come abbiamo detto, ogni locazione di memoria è formata da 8 bit, allora ogni elemento dell'array occuperà due locazioni di memoria:
| LOCAZIONE 1 | LOCAZIONE 2 | LOCAZIONE 3 | LOCAZIONE 4 | ecc..Come si può vedere dall'illustrazione, ogni elemento dell'array occupa due locazioni di memoria, equivalenti a 16 bit. Questo perché si tratta di un array a int.
| 00000000 | 00000001 | 00000010 | 00000011 |
| 8 bit | 8 bit | 8 bit | 8 bit |
| mioArray[0] | mioArray[1] |
| 666 | 418 |
Se si fosse trattato di un array a long ogni elemento avrebbe occupato 32 bit, e quindi quattro locazioni di memoria:
long altroArray[2]; //dichiarazione di un array long di due elementi
altroArray[0] = 152463; //assegniamo un valore al primo elemento dell'array
Vediamo cosa avverrebbe in memoria:
| LOCAZIONE 1 | LOCAZIONE 2 | LOCAZIONE 3 | LOCAZIONE 4 | ecc..Come possiamo vedere dall'illustrazione, ora un singolo elemento dell'array occupa ben quattro locazioni di 8 bit ciascuna, e cioé ben 32 bit. Questo perché abbiamo dichiarato un array di tipo long.
| 00000000 | 00000001 | 00000010 | 00000011 |
| 8 bit | 8 bit | 8 bit | 8 bit |
| altroArray[0] |
| 152463 |
So che questa parte può risultare noiosa ed inutilmente complicata, ma è necessaria sia per capire il funzionamento delle stringhe, e soprattutto quando arriveremo a trattare i puntatori avremo già un'infarinatura generale sul funzionamento della memoria RAM.
Torniamo ora al nostro array di caratteri.
Sappiamo che un singolo elemento di tipo char occupa 8 bit in memoria. Questo perché i caratteri, rappresentati da un codice numerico detto ASCII, possono assumere un valore che va da 0 a massimo 255 e, guarda caso, 255 in binario si scrive 11111111 (8 cifre binarie, e cioé 8 bit).
Quindi possiamo ricavarne facilmente che ogni singolo elemento char occupa solo 1 byte (8 bit), e quindi una sola locazione di memoria.
Questo è il nostro array di char che forma una stringa:
char saluto[5] = { 'C', 'I', 'A', 'O', '\0' }; //notare il simbolo null alla fine della stringa
In memoria avviene questo:
| LOCAZIONE 1 | LOCAZIONE 2 | LOCAZIONE 3 | LOCAZIONE 4 | LOCAZIONE 5 | LOCAZIONE 6 |Come potete vedere ogni singolo elemento dell'array punta ad una locazione di memoria di 8 bit, e quindi ogni singola locazione contiene un carattere della stringa.
| 00000000 | 00000001 | 00000010 | 00000011 | 00000100 | 00000101 |
| 8 bit | 8 bit | 8 bit | 8 bit | 8 bit | 8 bit |
| saluto[0] | saluto[1] | saluto[2] | saluto[3] | saluto[4] | |
| C | I | A | O | \0 | |
Ma la cosa più importante è che le locazioni sono l'una immediatamente dopo l'altra.
Il programma, durante l'esecuzione, sa che ogni carattere occupa 8 bit in memoria, e quando arriva ad eseguire l'istruzione:
cout << saluto;
Controlla la locazione di memoria a cui punta il primo elemento dell'array, stampa il contenuto di quella locazione (cioé il carattere C), dopodiché si muove di 8 bit in avanti nella memoria perché gli abbiamo detto noi di farlo, nel momento in cui abbiamo dichiarato l'array utilizzando la parola char, e incontra il secondo carattere (I), si muove di altri 8 bit in avanti ed incontra il cararattere A, poi di altri 8 bit ed incontra il carattere O, ed infine di altri 8 bit ed incontra il carattere null, e capisce che la stringa è terminata, così passa all'esecuzione dell'istruzione successiva.
Invece nel primo caso, e cioé:
char complimento[5] = { 'B', 'E', 'L', 'L', 'A' };
Non abbiamo inserito il carattere null \0, perciò il programma, muovendosi di 8 bit in 8 bit, stampa in successione i cinque caratteri, ma arrivato all'ultimo non capisce che si deve fermare, e prosegue di 8 bit in 8 bit nelle locazioni successive della memoria, dove può essere contenuto di tutto, e continuerà a stampare caratteri casuali man mano che avanza, sinché prima o poi non incontra un carattere null presente da qualche parte in memoria.
E' importante capire il modo in cui il programma capisce di quanti bit spostarsi, perché ci servirà in seguito per comprendere al meglio l'aritmetica dei puntatori.
Nel nostro caso si sposta di 8 bit in 8 bit perché l'array è stato dichiarato di tipo char.
Ma se avessimo dichiarato un array a int, il programma si sarebbe spostato di 16 bit in 16 bit, mentre se avessimo dichiarato un array a long si sarebbe spostato di 32 bit in 32 bit.
Infatti tutto ciò che fa il programma durante la sua esecuzione è quello di conoscere il nome dell'array, e il suo tipo.
Non ha idea di quanti elementi contenga l'array:
gli basta sapere la locazione di memoria del primo elemento, per capire da dove iniziare a leggere, e questo lo capisce grazie al nome dell'array (quando parleremo dei puntatori capiremo com'è possibile questo);
e gli basta sapere il tipo dell'array, grazie al quale capisce di quanti bit si deve muovere per accedere agli elementi successivi.
Ad esempio, prendiamo il seguente array:
long mioArray[5];
Il nome dell'array senza indice, e cioé mioArray, contiene l'indirizzo della prima locazione di memoria, ipotizziamo per comodità che si tratti della prima locazione della memoria RAM.
Il tipo dell'array invece dice al programma quanti bit occupa ogni singolo elemento dell'array; in questo caso 32 bit, essendo di tipo long, che equivale a 4 locazioni di memoria.
Quando nel nostro programma scriveremo mioArray[0], il programma saprà che il contenuto di quell'elemento dell'array è nei primi 32 bit di memoria, e cioé nella memoria che va dalla locazione 1 alla locazione 4.
Quando ad esempio scriviamo mioArray[1] il programma, partendo dalla locazione 1, sa che si deve muovere in avanti di 32 bit, e così raggiunge la locazione 5 ed accede al dato contenuto a partire da questa locazione sino alla locazione 8 (e cioé nei prossimi 32 bit).
Se scriviamo mioArray[6], che ricordiamo è il settimo elemento dell'array, il programma partirà dalla locazione di memoria del primo elemento (ricordiamo che questo è possibile perché il nome dell'array senza indice, mioArray, punta alla locazione del primo elemento), e si muoverà in avanti di 32 bit per sette volte.
Questo è uno dei motivi per cui il C++ è considerato un linguaggio di programmazione rischioso. E cioé perché non conosce il numero degli elementi di un array, e si limita a spostarsi in avanti di tot byte in base al tipo dell'array, senza sapere quando l'array termina: abbiamo visto cosa succede nel caso di una stringa senza carattere null.
Un programmatore sbadato potrebbe inizializzare un array di 100 elementi, e poi per sbaglio cercare di accedere all'elemento [200]. Il compilatore non lo ferma perché non sa che l'array termina al centesimo elemento. Questo significa che il programmatore accederà ad un'area di memoria che può contenere di tutto, potenzialmente anche parti vitali del programma.
int arrayRischioso[100];
arrayRischioso[200] = 156;
Cosa succede nella seconda riga? Il programma, partendo dalla locazione di memoria a cui punta il primo elemento dell'array, si muove in avanti di 16 bit (si tratta di un array a int) per 200 volte, raggiungendo una locazione di memoria che non appartiene all'array e, modificandola, chissà quali dati va a compromettere!
Bene, la parte più difficile è stata affrontata, se avete capito questo siete a cavallo!
Ancora un paio di cose prima di concludere questa prima parte riguardante le stringhe in C.
Durante l'inizializzazione di un array di char, non è necessario che i caratteri siano uguali al numero di elementi dell'array, quel che conta è che non vadano oltre:
char arrayCorretto[50] = { 'G', 'I', 'U', 'S', 'T', 'O', '\0' };
char arrayErrato[3] = { 'S', 'B', 'A', 'G', 'L', 'I', 'A', 'T', 'O', '\0' };
Il primo esempio è corretto, infatti l'array è di 50 elementi, e con la nostra stringa ne occupiamo solo sette, cioé sei più il carattere null (non dimenticate mai di metterlo).
Il secondo esempio è errato perché la nostra stringa è formata da dieci caratteri, ma l'array ha solo tre elementi!
Da notare anche come, durante l'inizializzazione, ogni carattere è racchiuso tra singoli apici.
I singoli apici stanno ad indicare che si tratta di un carattere ASCII, mentre i doppi apici indicano che si tratta di una stringa.
Infatti la riga di codice seguente restituisce un errore:
char varCarattere = "P"; //ERRORE! Le virgolette doppie non vanno bene per i char!
Esiste un modo più semplice di inizializzare una stringa utilizzando gli array:
char stringa1[10] = "bene"; //il carattere \0 è implicito
char stringa2[] = "ancora meglio"; //anche qui
In entrambi i casi è il compilatore ad inserire per noi il carattere null, con la differenza che nel primo caso dichiariamo 10 elementi e ne utilizziamo solo 5 (4 più 1 occupato da \0), mentre nel secondo caso è il compilatore a calcolare automaticamente il numero di elementi necessari a contenere la successione di caratteri che formano la stringa "ancora meglio".
Una volta inizializzata, una stringa di array può essere modificata:
char stringa3[] = "CANE";
Questa dichiarazione seguita dall'inizializzazione crea un array di 5 elmenti char, contenenti in successione i caratteri 'C', 'A', 'N', 'E', '\0'.
stringa3[2] = 'S';
L'operazione precedente modifica il terzo elemento dell'array, e cioè quello che conteneva il carattere 'N'.
Se provate a stampare il contenuto di stringa3 tramite cout vedrete che non verrà mostrata la stringa CANE ma la stringa CASE.
Esistono delle funzioni del C per lavorare con le stringhe, tuttavia il C++ introduce un nuovo tipo string, molto più intuitivo da usare, e dal momento che il nostro scopo è imparare il C++ e non il C, useremo il secondo tipo, e non avrebbe senso trattare delle funzioni che non utilizzeremo mai.
Allora perché un post così lungo e complicato su una funzionalità ereditata dal C per una semplice questione di retrocompatibilità, che non useremo mai?
Perché è un ottimo pretesto per iniziare a capire come funzionano gli array ad un livello più vicino all'hardware, e per capire in generale come viene gestita la memoria dai programmi in C++.
Quanto imparato oggi vi aiuterà a capire meglio il funzionamento dei puntatori, l'argomento più complicato del C++. Una volta capiti quelli (e se avete capito bene il contenuto di questo post siete già a buona strada) il resto del C++ vi sembrerà una passeggiata.
La gestione della memoria è infatti l'unico argomento a basso livello che tratteremo (in parte l'abbiamo già fatto qui), dopodiché il resto sarà molto più vicino ad una logica umana.
Questo perché, come abbiamo detto nei primi post, il C++ è un linguaggio di alto livello, con alcuni aspetti di basso livello.
Quando arriveremo a parlare della programmazione ad oggetti sarà necessario utilizzare un metodo di ragionamento più vicino alla logica che alla matematica, e allora tirerete un sospiro di sollievo.
Tuttavia è fondamentale capire bene le basi, ossia le variabili e gli array. Non è necessario sapere come funzionano gli array a livello hardware anche se aiuta a capire i meccanismi che stanno dietro all'esecuzione di un programma, ma è fondamentale sapere bene come lavorare con le variabili e con gli array da un punto di vista pratico.
Per questo è necessario che vi esercitiate bene, sperimentate, scrivete dei piccoli programmi che utilizzano variabili e array, che mostrano delle scritte sullo schermo e che ricevono dell'input da tastiera. Provate e riprovate, sbagliate e osate, perché queste sono le fondamenta del linguaggio, e dovete conoscerle così come conoscete l'italiano o la matematica elementare.
Ci vediamo nel prossimo post con le stringhe in stile C++.
Nessun commento:
Posta un commento