Puntatori, gestione dinamica della memoria e stringhe Parte 8. Come gestire direttamente l’uso della memoria di un programma Corso A: Prof. Stefano Berardi http://www.di.unito.it/~stefano Corso B: Prof. Ugo de’ Liguoro http://www.di.unito.it/~deligu
Un’immagine di una struttura dati di “Celle e Puntatori”
Indice Parte 8: Puntatori Vettori e indirizzi: operatori *,&, sharing e alias. Puntatori e passaggio dei parametri di una funzione. Allocazione dinamica della memoria: new e delete. Stringhe, records e puntatori. La gestione diretta della memoria consente di modificare la dimensione di una struttura dati dinamicamente, cioè durante l’esecuzione di un programma.
1. Vettori e indirizzi Ricordiamo che vettori consentono l’accesso “diretto” (in un solo passo di calcolo) ai propri elementi. L’indirizzo di un elemento di un vettore viene calcolato a partire dal suo indice: per accedere a v[i] chiediamo alla RAM il contenuto dell’indirizzo b+i*d. indirizzo v[i] = b + i × d 678 v v[i] b = indirizzo base = v i = indice (o “spiazzamento”) d = dimensione elemento
L’accesso a un byte in un insieme di N bytes è in un passo 1 000 001 010 011 100 101 110 111 N=23 La RAM fornisce il contenuto della memoria b + i × d in tempo proporzionale a log2(N)
Problemi nell’uso dei vettori Un vettore è identificato in C con una costante di tipo indirizzo. La dimensione di un vettore v viene fissata per sempre al momento della dichiarazione di v. Da evitare quindi, come già spiegato: int v[x]; /* PERICOLOSO: la dimensione di v e’ il valore corrente (e “casuale” se non inizializzato) di x. Inoltre cambiare x non cambia la dim. di v */ Per inserire un nuovo elemento e nel posto i in V[0..a-1] dobbiamo prima slittare di un posto tutti gli elementi dopo i di una posizione verso destra, per esempio con un ciclo, come abbiamo fatto per l’InsertSort: for (j=a;j>i;j--) v[j]=v[j-1]; v[i]=e;
Un nuovo tipo di oggetti: i “puntatori” o indirizzi Un puntatore è una variabile il cui dominio di definizione sono gli indirizzi di memoria. Il tipo di un puntatore p viene indicato con T*, dove T è il tipo contenuti nella memoria di indice p. La costante NULL indica il generico puntatore “sbagliato”, cioé indirizzo di una memoria inesistente. La sintassi di un puntatore è: <tipo>* <var.>; int* p; // puntatore a intero int *p, *q; /* abbreviazione di int* p; int* q;*/
Dereferenziazione (*) L’operatore *p o “dereferenziazione di p” restituisce il contenuto della locazione di memoria il cui indirizzo è p 025fe16 2983 p 025fe16 Se p è l’indirizzo esadecimale 025fe16, allora *p, il contenuto di p, e’ uguale a 2983. Attenzione: se p non è inizializzato allora p è “casuale”, dunque *p è il contenuto di una memoria scelta a caso, spesso inesistente o irraggiungibile. In questo caso far calcolare *p fa cadere il programma. Per es.: calcolare *NULL fa cadere il programma.
Operatore indirizzo (&) L’operatore di & (“indirizzo di”) è una sorta di inverso di *. Se x è una variabile, allora &x è l’indirizzo RAM in cui è memorizzato il valore di x: x 025fe16 2983 &x (indirizzo di x) 025fe16, mentre *&x x 2983 è il contenuto dell’indirizzo di x, e quindi il valore della variabile x.
Esempio di uso di *, & // esempio di dereferenziazione: uso operatore & #include <iostream.h> int main() {int x = 7; int *p1, *p2; // oppure int* p1; int* p2; p1 = &x; p2 = p1; // p1, p2 sono l’indirizzo di x cout << “*p2 =“ << *p2 << endl; // stampa il valore di x cioe’ 7 cout << “p2 = “ << p2 << endl; } /* stampa il valore di p2 cioe’ l’indirizzo di x (in esadecimale) */
Condivisione (o sharing)della stessa locazione di memoria I puntatori possono condividere (in inglese si dice: to share) l’area di memoria cui puntano: int *p, *q; int n = 44; p = q = &n; p q n 44 Ogni modifica del valore di n che avvenga per assegnazione su *p si riflette su n e su *q: p e q sono come dei “sinonimi” (in inglese: “alias”) di n. Questa caratteristica viene sfruttata per modificare i valori originali dei parametri attuali, simulando il passaggio per indirizzo.
Sharing e alias Lo scopo dello sharing (condivisione) di memoria è evitare la duplicazione dei dati, soprattutto per strutture dati molto grandi. In C++ si possono definire “sharing” tra due aree di memoria anche senza i puntatori, usando una operazione int& che costruisce sinonimi (alias): int main () {int n = 44; int& rn = n; /* rn è sinonimo di n, sono la “stessa” variabile con due nomi diversi */ n--; /* se cambio n cambio anche rn perché n, rn sono la “stessa variabile” */ cout << rn << endl; } / *stampa 43, non 44 */
Vettori e puntatori in C++ Dato che in C++ un vettore è una costante di tipo puntatore, si può assegnare un vettore a un puntatore, ma non un vettore a un vettore (sarebbe come assegnare una costante a un’altra costante). int v[100]; int* p; p = v; /* il valore assegnato a p è l’indirizzo del primo elemento di v, ossia p = &v[0] */ Si può usare la notazione con gli indici per i puntatori che rappresentano vettori: p[i] // p[i] equivale a v[i]
Aritmetica dei puntatori (1) L’operazione sizeof(T) calcola, per ogni tipo T, la dimensione in bytes della memoria necessaria per contenere un generico elemento di T. int dim = sizeof(double); /* dim = 8, la dim. in bytes di double */ I puntatori sono tipati: int *p, bool *p, double *p, … : il tipo è essenziale per sapere cosa leggere/scrivere in *p, le locazioni di memoria cui punta p.
Aritmetica dei puntatori (2) L’unità minima per un puntatore di tipo T è lo spazio sizeof(tipo di p) necessario per contenere un elemento di tipo T. In C++ si possono sommare (o sottrarre) interi a un puntatore p: l’effetto è aggiungere o togliere multipli di sizeof(tipo di p). Per esempio: int *p, *q; q=p+10; /* il valore di q è uguale al valore di p+10*sizeof(int) */ A norma di quanto detto, se p punta ad un vettore v deduciamo che p+i rappresenta la variabile v[i]: p+i==&v[i]; /* ovvero: */ *(p+i)==v[i];
2. Puntatori e passaggio dei parametri di una funzione Ripassiamo il passaggio di parametri per valore con un esempio: void f(int n){ n++; } main(){int a = 0; f(a); cout << a;} a La chiamata ad f(a) e’ per valore. Essa modifica la copia n di a da 0 ad 1. La chiamata ad f(a) modifica il valore originale 0 di a: quando stampiamo a otteniamo 0 e non 1. Per convincerci, proviamo a simulare l’esecuzione: la freccia mobile rappresenta il Program Counter.
Passaggio di parametri per valore void f(int n){ n++; } main(){int a = 0; f(a); cout << a;} a n
Passaggio di parametri per valore void f(int n){ n++; } main(){int a = 0; f(a); cout << a;} a 1 n
Passaggio di parametri per valore void f(int n){ n++; } main(){int a = 0; f(a); cout << a;} a
Ripasso: il passaggio di parametri per riferimento Ora un esempio di passaggio di parametri per riferimento. void f(int& n) { n++; } main(){int a = 0; f(a); cout << a;} a La chiamata f(a) ora e’ per riferimento. Essa crea un sinonimo n di a, una variabile cioè di nome diverso, ma con lo stesso indirizzo di a. Modificando n da 0 ad 1, la chiamata modifica il valore originale di a, dato che n ed a sono la stessa cosa.
Passaggio di par. per riferimento void f(int& n) { n++; } main(){int a = 0; f(a); cout << a;} a n
Passaggio di par. per riferimento void f(int& n) { n++; } main(){int a = 0; f(a); cout << a;} 1 a n
Passaggio di par. per riferimento void f(int& n) { n++; } main(){int a = 0; f(a); cout << a;} 1 a
Passaggio di parametri per riferimento con un puntatore Il passaggio per riferimento si può simulare con un puntatore: void f(int* pn) { (*pn)++; } main(){int a = 0; f(&a); cout << a;} a Possiamo usare un puntatore pn nella chiamata f(a) per simulare una chiamata per riferimento. *pn e’ un sinonimo di a. Modificando *pn da 0 ad 1, la chiamata modifica il valore originale di a.
Passaggio di parametri usando puntatore void f(int* pn) { (*pn)++; } main(){int a = 0; f(&a); cout << a;} a pn Notiamo che nella chiamata f(&a) dobbiamo passare l’indirizzo &a di a, e non semplicemente a, e che nel corpo di f dobbiamo fare riferimento al valore *pn puntato da pn, e non a pn stesso.
Passaggio di parametri usando puntatore void f(int* pn) { (*pn)++; } main(){int a = 0; f(&a); cout << a;} 1 a pn
Passaggio di parametri usando puntatore void f(int* pn) { (*pn)++; } main(){int a = 0; f(&a); cout << a;} 1 a
3. Allocazione dinamica della memoria e puntatori Allocazione = destinazione di una certa quantità di memoria (realmente esistente e disponibile) per contenere il valore di una variabile. Tutte le variabili di un programma sono allocate dal programma stesso quando vengono dichiarate Possiamo noi stessi allocare memoria per la variabile *p durante l’esecuzione del programma, con il comando p = new (tipo di p). La memoria allocata da noi si trova in una area detta “memoria dinamica” o “heap”.
Allocazione dinamica della memoria e puntatori L’errore più comune nell’uso dei puntatori è il seguente: calcolare il valore *p puntato da p senza aver prima “allocato” p. In questo caso, calcolando *p possiamo chiedere il contenuto di una memoria inesistente e far cadere il programma. È come far calcolare il valore di *NULL.
Allocazione dinamica: l’istruzione new La memoria allocata da noi si trova in una area detta “memoria dinamica” o “heap”. int *p; p = new int; *p = 2983; p heap Memoria n.025fe16 L’istruzione new int “alloca”, cioe’ assegna al puntatore p, l’indirizzo di uno spazio di memoria realmente esistente, non in uso, e sufficente per contenere un intero. Solo a questo punto *p può essere letta e assegnata come una variabile di tipo intero: farlo prima può far cadere il programma.
Allocazione dinamica: assegnazione di *p int *p; p = new int; *p = 2983; heap Memoria n. 025fe16 p 025fe16 *p può essere assegnata solo dopo che assegnamo a p l’indirizzo di una cella realmente esistente e raggiungibile.
Allocazione dinamica: lettura e scrittura di *p int *p; p = new int; *p = 2983; heap Memoria n. 025fe16 2983 p 025fe16 *p può essere assegnata solo dopo che assegnamo a p l’indirizzo di una cella realmente esistente e raggiungibile.
Allocazione dinamica: esempi di cosa fare e di cosa non fare float* p; *p = 3.14159; /* ERRORE: p non è allocato. Assegnare p significa scrivere su una memoria di indirizzo “casuale”, spesso inesistente e questo ha effetti imprevedibili */ float x = 3.14159; float* p = &x /* OK: p usa l’allocazione già fatta per x */ float* p = new float; *p = 3.14159; /* OK: p è già allocato con la new */
Prima che ci fosse la “new”: l’istruzione “malloc” La funzione malloc (oggi meno usata rispetto alla new) ci richiede di conoscere la dimensione n in bytes dell’area di memoria di cui abbiamo bisogno, e ci restituisce un puntatore a un’area di memoria ``libera’’ (non in uso) della dimensione n richiesta, NULL se un’area di dimensione n libera non esiste. Una istruzione p = new Tipo; si esprimeva usando la funzione malloc Tipo *p; p = (Tipo*) malloc (sizeof(Tipo));
Prima che ci fosse la “new”: l’istruzione “malloc” Tipo *p; p = (Tipo*) malloc (sizeof(Tipo)); “malloc” alloca un’area di memoria con la dimensione in byte che gli chiediamo. In questo caso la dimensione dipende da Tipo ed è calcolata da sizeof. In caso di successo l’istruzione assegna il tipo Tipo all’area di memoria, e l’indirizzo dell’area al puntatore p. Se non c’è più memoria disponibile, malloc restituisce NULL, il puntatore a una memoria inesistente, che sarà il valore di p.
I puntatori e l’allocazione dinamica di un vettore Per “allocare dinamicamente un vettore v” intendiamo: posporre il momento in cui definiamo la lunghezza di v. Non è semplice: occorre dichiarare v come puntatore, e conoscere: Il tipo degli elementi di v (per es., int); il numero lun di questi elementi durante l’esecuzione. int* v, int lun; …………………; lun = 100; ……………………; v = new int[lun]; /* attenti a non calcolare v[0] prima di allocare *v: cade il programma */
I puntatori e l’allocazione dinamica di un vettore L’istruzione v = new int[lun]; alloca un vettore di lun interi, dove lun è una variabile già assegnata. Una volta allocato, il vettore non è più modificabile (ma possiamo sempre costruirne un altro e assegnarlo a v).
4. Un altro uso dei puntatori: le variabili di stringa Le stringhe sono vettori di caratteri, contenenti un carattere speciale ‘\0’, non stampabile, detto terminatore. La lunghezza di una stringa è il numero di caratteri nel vettore precedenti il primo ‘\0’. Per esempio sono stringhe le seguenti char s[] = “CIAO”; char t[MAXLUN]; char *u = “Salve mondo”; C I A O ‘\0’ … stringa s
4. Un altro uso dei puntatori: le variabili di stringa In C++ esiste un tipo String, definito come char* : typedef char* String; “typedef TipoComposto nome” definisce un nuovo tipo “nome” uguale a ”TipoComposto”
Allocazione di spazio di memoria per stringhe Se non allochiamo una stringa, può capitarci di leggere/scrivere su un indirizzo inesistente, irraggiungibile o già occupato. Per es. con char* s; cin >> s; chiediamo di inserire una stringa in un indirizzo s non allocato e probabilmente inesistente, e possiamo far cadere il programma.
Allocazione di spazio di memoria per stringhe Per evitare errori come char* s; cin >> s; è possibile, per esempio, costruire una funzione di allocazione di memoria per le stringhe, che dato un intero “len” fornisce l’indirizzo di una stringa che può contenere fino a “len” caratteri. String stralloc(int len) { String s = new char[len + 1]; return s;} // scriviamo len + 1 per far posto a ‘\0’ Ora possiamo scrivere char* s=stralloc(100); cin >> s;
Operazioni sulle stringhe: lunghezza e copia di una stringa int strlen (String s) {int n = 0; while (s[n] != ‘\0’) {++n;} return n;} // POST.restituisce la lunghezza di s int strcpy (String dest, String source) // PREC.lunghezza dest >= lunghezza source {int n=0; while (source[n] != ‘\0’) {dest[n]=source[n]; ++n} } // POST. copia source su dest.
Records (ripasso) Un record è una tupla di valori di tipi possibilmente diversi, corrispondente in matematica a un elemento di un prodotto cartesiano, rappresentato da celle contigue a cui accediamo attraverso etichette: struct <nome struttura> {<tipo1> <etichetta campo1>; ... <tipok> <etichetta campok>;} r r.num Un esempio già visto : il tipo dei razionali r.denum struct Rational {int num; int denum;} Se r ha tipo Rational, le due componenti di r si scrivono: r.num, r.denum e si indicano come nella figura a lato
Puntatori a record (o strutture) Come per i tipi di base e per i vettori, si possono definire dei puntatori a record e poi allocarli: typedef struct Rational {int num; int denum} *Pointer; Pointer p = new Rational; //p puntatore a Rational Se p è un puntatore a Rational, allora *p è un elemento di Rational, di numeratore e denominatore (*p).num, (*p).denum. Il C++ indica (*p).num, (*p).denum con le abbreviazioni: p->num, p->denum *p elemento di Rational p->num p p->denum
Un modo per definire un tipo “Vettore” Possiamo definire un unico tipo Vettore per vettori di qualsiasi lunghezza su un tipo dato (per es. Interi). “Vettore” è il tipo dei puntatori a un record “VecRec” di due campi, il primo, lun, per la lunghezza del vettore, e il secondo, vec, per l’indirizzo del vettore vero e proprio. typedef struct VecRec{int lun; int*vec;}* Vettore; //Vettore = puntatori a un record VecRec
Una rappresentazione a “celle e puntatori” per il tipo Vettore v è un primo puntatore a un record di due elementi: il primo elemento v->lun è la lunghezza di un vettore W, il secondo, v->vec è l’indirizzo del primo elemento di W. n = v->lun W = v->vec W[0] W[n-1] W = v->vec ha tipo int*, è un secondo puntatore, che punta al primo elemento W[0] di un vettore W di n elementi. La struttura “Vettore” ha n+3 celle e 2 puntatori.
Come allocare un nuovo oggetto di tipo Vettore Supponiamo di volere un nuovo oggetto di tipo Vettore, di lunghezza n. Per predisporre uno spazio di memoria adatto a contenerlo, dobbiamo usare due volte la “new”, per definire i due puntatori (le due “frecce”) che compaiono nella rappresentazione della pagina precedente, e le celle a cui puntato tali frecce. A tal fine nel prossimo lucido definiamo una funzione: Vettore VettAlloc (int n) {… }
Come allocare un nuovo oggetto di tipo Vettore Vettore VettAlloc (int n) { Vettore v; v = new VecRec; /*new 1: alloco spazio per le 2 celle “lun”, “vec” di un record di tipo VecRec. */ v->lun = n; /* assegno n alla prima cella del record */ v->vec = new int[n]; /*new 2: alloco lo spazio per n celle di interi */ return v; }
Un esempio più complesso: il tipo “Matrice” (matrici dim. qualsiasi) struct MatrRec {int righe, colonne; int **vecrighe;}; typedef MatrRec* Matrice; Questa definizione costruisce la complessa struttura che vedete disegnata qui sotto: M:Matrice M p = r righe di c elementi ciascuna: (int*)* p M*:MatrRec W0 =p[0] Wr-1 =p[r-1] W0[0] … W0[c-1] r = M->righe c=M->colonne r p=M->vecrighe Wr-1[0] … Wr-1[c-1]
Un esempio più complesso: il tipo “Matrice” (matrici dim. qualsiasi) Per costruire uno spazio di memoria adatto a contenere una matrice, dobbiamo procedere cella per cella, e usare r+2 volte la “new”, per definire gli r+2 puntatori e le (r*c+r+4) celle del disegno, partendo dalla sola cella che contiene M. M:Matrice M p = r righe di c elementi ciascuna: (int*)* p *M:MatrRec W0 =p[0] Wr-1 =p[r-1] W0[0] … W0[c-1] r = M->righe c=M->colonne r p=M->vecrighe Wr-1[0] … Wr-1[c-1]
Come allocare un nuovo oggetto di tipo Matrice Matrice NuovaMatrice (int r, int c) { Matrice M; int i; M = new MatrRec; /* prima new: il record *M */ M->righe = r; M->colonne = c; M->vecrighe = new int*[r]; /* seconda new: il vettore p delle r righe della matrice */ for (i = 0; i < r; i++) M->vecrighe[i] = new int[c]; /* ultime r new: le r righe p[0], …, p[r-1] della matrice */ return M;} /* valore di ritorno = indirizzo del record per una matrice r x c, con gli elementi della matrice ancora “indefiniti” */
Stampa di un oggetto di tipo Matrice (M->vecrighe) è il vettore delle righe, la riga numero I è (M->vecrighe)[i], e infine (M->vecrighe)[i][j] è l’elemento num. j della riga num. i. Il numero delle righe è uguale a (M->righe) e il num. delle colonne è (M->colonne). Possiamo assegnare tutti gli elementi di M con il ciclo: for(int i=0;i<M->righe;++i) for(int j=0;j<M->colonne;++j) (M->vecrighe)[i][j] = (i+1)*(j+1);
Stampa di un oggetto di tipo Matrice Possiamo stampare tutti gli elementi di M con il ciclo: for(int i=0;i<M->righe;++i) for(int j=0;j<M->colonne;++j) {cout << "(M->vecrighe)[" << i << "][" << j << "] =" << (M->vecrighe)[i][j] << endl;}
Deallocazione: come riciclare una cella di memoria che non serve più La memoria dinamica allocata ma non più in uso può essere resa riciclabile per risparmiare spazio. A volte riciclare può essere indispensabile, quando si sono allocate così tante strutture dati che non resta più spazio per costruirne di nuove. Quando ricicliamo una cella di memoria, il compilatore la considera di nuovo “libera” e può riutilizzarla per costruire una nuova struttura. Il comando per riciclare una cella di cui sia noto l’indirizzo è delete: delete <puntatore>
Deallocazione: come riciclare una struttura dati Per deallocare un vettore del C non occorre ricordarne la dimensione, ci basta ricordarne il nome e scrivere: delete [] <nome vettore> Per deallocare una struttura complessa definita da noi, per esempio di tipo Matrice, occorrono tante chiamate di delete quante sono state quelle di new. Le delete vanno fatte percorrendo la struttura in ordine opposto rispetto al percorso fatto per costruirla: eliminiamo prima le ultime memorie allocate e continuiamo fino ad eliminare le prime.
Esempio: Deallocazione di Matrice void DeallocaMatrice (Matrice m) { int i; //(1) deallochiamo un vettore-riga dopo l’altro for(i = 0; i < m->righe; i++) delete [] m->vecrighe[i]; //(2) deallochiamo l’elenco dei vettori-riga delete [] m->vecrighe; // (3) deallochiamo l’indirizzo del record Matrice delete m;}