Corso C. Lezione 4. ANSI C: Puntatori. Strutture. In questa lezione finiamo di esaminare l'ANSI C introducendo gli ultimi concetti che vale la pena affrontare in un corso C base: puntatori e strutture. I puntatori sono un argomento piuttosto delicato e complesso, pero' sono, per contro, lo strumento che rende il linguaggio C molto flessibile e potente. Le strutture invece permettono la definizione di nuovi tipi di dato, componendo i tipi primitivi in "strutture" (per l'appunto...) piu' complesse. Ma procediamo con ordine... PUNTATORI. ---------- Quando dichiariamo una variabile in un programma C, il compilatore alloca una zona di memoria sufficientemente grande per contenere tale variabile. Questo meccanismo di allocazione e' automatico, non dobbiamo quindi preoccuparci di riservare lo spazio per le variabili che dichiariamo in quanto ci pensa il compilatore stesso. Prima di procedere facciamo una breve parentesi sul funzionamento della memoria per rinfrescare alcuni concetti utilizzati in questa lezione. - Concetti di base su come il processore gestisce la memoria. Ogni cella di memoria ha un indirizzo. L'indirizzo non e' altro che un indice (un numero) utilizzato dal processore per indirizzare (accedere) a una determinata cella. Piu' o meno cio' che avviene per gli array. Gli indirizzi in un processore a 32-bit sono grandi (pensate un po'...) 32 bit, quindi un tale processore puo' indirizzare 2^32 == 4294967296 celle di memoria, cioe' 4 GigaByte esatti (essendo ogni cella di memoria grande un byte). Consideriamo il seguente pezzo di programma: int main() { int a; char b; b = 'A'; a = 100; => .... La situazione in memoria, una volta che l'esecuzione ha raggiunto il punto indicato dalla freccia, puo' essere modelizzata col seguente schema: | | +-------+ a 1800 | | + + 1801 | | + 100 + 1802 | | + + 1803 | | +-------+ b 1804 | 'A' | +-------+ | | La variabile a e' stata allocata a partire dall'indirizzo 1800 (questo e' un numero inventato giusto per fare un esempio, in realta' cambia ad ogni esecuzione del programma e assume valori molto piu' alti). Siccome a e' di tipo int occupera 4 byte (consecutivi). La variabile b e' stata allocata all'indirizzo 1804 e occupa un solo byte perche' e' di tipo char (ricordate il comando sizeof?). - (fine della parentesi sulla memoria) Detto questo e' arrivato finalmente il momento di dire cos'e' e come si dichiara un puntatore in C. Un puntatore e' sostanzialmente una variabile che contiene l'indirizzo di un'altra variabile. Per dichiarare un puntatore si utilizza la sintassi: TIPO *nome; Se voglio dichiarare un puntatore a un int e voglio chiamarlo punt scrivero': int *punt; chiaramente al posto di int posso metterci il tipo che mi pare. L'importanza di dover specificare il tipo sara' chiara fra poco. E' importante comunque tenere SEMPRE presente che un puntatore e' tipizzato, cioe' punta ad una variabile di cui si conosce gia' il TIPO. Vediamo cosa succede in memoria quando dichiaro un puntatore: int main() { char v; char *p; v = 'A'; p = &v; => .... | | +-------+ v 1800 | 'A' | +-------+ | | | | | | +-------+ p 1844 | | 1845 | | 1846 | 1800 | 1847 | | +-------+ | | La variabile v occupa un byte a partire da 1800. La variabile p, essendo un puntatore, occupa 4 byte (==32bit cioe' la dimensione di un indirizzo di memoria) a partire da 1844. Poi a v viene assegnato il valore 'A' e fin qui nulla di trascendentale. Ma cosa succede quando eseguo il comando "p = &v"? (probabilmente qualcuno di voi guardando lo schema avra' gia' capito... ;) - Operatore &. L'operatore "&" restituisce l'indirizzo di memoria in cui e' allocato l'oggetto che gli indichiamo subito dopo. Quindi il risultato dell'espressione "&v", nel nostro esempio, sara' 1800, cioe' l'indirizzo di memoria in cui e' stata allocata la variabile v. Questo valore viene copiato dentro p, proprio come se fosse una comune variabile. Facciamo un altro passo avanti: int main() { char v; char *p; v = 'A'; p = &v; *p = 'C'; printf( "%c\n", v ); => .... | | +-------+ v 1800 | 'C' | +-------+ | | | | | | +-------+ p 1844 | | 1845 | | 1846 | 1800 | 1847 | | +-------+ | | E lo stesso programma di prima ma con in piu' il comando "*p = 'C'". Cosa fa questo misterioso comando? - Operatore *. L'operatore "*" e' l'operatore di dereferenziamento del C (niente paura per il paralone... in realta' i concetti che ci sono sotto sono molto semplici). Quest'operatore si applica alle variabili di tipo puntatore e quando scrivo "*p" significa che sto considerando la variabile "puntata da p", detto in altre parole, con "*p" indico la variabile il cui indirizzo e' contenuto in p. Nel nostro esempio p contiene il valore 1800 (cioe' l'indirizzo di v che ho assegnato con "p = &v"). Scrivendo "*p = 'C'", assegno il valore 'C' alla variabile puntata da p, cioe' alla variabile il cui indirizzo e' contenuto in p, cioe' alla variabile v. (un consiglio: se non vi e' chiaro il passaggio NON andate avanti ma rileggetelo fino a quando non avrete capito tutto perfettamente) Notare che p e' un puntatore a un char (perche' e' stato dichiarato con "char *p") quindi quando dereferenzio p scrivendo "*p = 'C'" il compilatore sa che andra' a scrivere in una zona di memoria occupata da una variabile char, quindi una variabile che e' grande esattamente sizeof(char) (cioe' 1) byte di memoria. Se avessi scritto ERRONEAMENTE una cosa di questo tipo: char v; int *p; p = &v; <-- questa riga genera un warning ma il *p = 10; programma viene compilato ugualmente. mi sarei ritrovato con la seguente situazione in memoria: | | +-------+ v 1800 | | ! ==> ? 1801 | 10 | ! ==> ? 1802 | | ! ==> ? 1803 | | +-------+ | | +-------+ p 1844 | | 1845 | | 1846 | 1800 | 1847 | | +-------+ | | In altre parole il programma e' andato a scrivere il valore 10 codificato come un int (e quindi grande 4 byte) a partire dall'indirizzo di v, cioe' 1800. Quest'operazione pero' non e' corretta dato che v e' un char e occupa un solo byte di memoria. Il compilatore NON e' in grado di accorgersi dell'errore e sovrascrive le celle di memoria che seguono la variabile v. Potrebbe capitare che queste celle di memoria contengano informazioni di sistema molto importanti ;)... Morale della favola: quando dichiarate un puntatore di tipo X dovete farlo puntare sempre e solo a variabili di tipo X. - Passaggio di parametri per riferimento. Vediamo ora uno degli utilizzi pratici dei puntatori. Nella parte relativa alle funzioni abbiamo detto che tutti i parametri passati quando chiamo una funzione vengono "copiati" nelle variabili locali della funzione stessa. Quindi qualsiasi eventuale modifica eseguita sui parametri all'interno della funzione NON ha effetto all'esterno. Per esempio: void increment( int a ) 2---> { 3---> a++; } int main() { 1---> int c=10; 4---> increment(c); return(0); } Vediamo cosa succede man mano che vengono eseguite le varie istruzioni: Dopo 1 Dopo 2 Dopo 3 Dopo 4 | | | | | | | | | | +-------+ +-------+ | | a 1800 | | | | | | | | 1801 | | | | | | | | 1802 | | | 10 | | 11 | | | 1803 | | | | | | | | | | +-------+ +-------+ | | | | | | | | | | +-------+ +-------+ +-------+ +-------+ c 1844 | | | | | | | | 1845 | | | | | | | | 1846 | 10 | | 10 | | 10 | | 10 | 1847 | | | | | | | | +-------+ +-------+ +-------+ +-------+ | | | | | | | | All'inizio viene allocata la variabile c e gli viene assegnato il valore 10. Quando chiamo la funzione increment il processore alloca la nuova variabile a e copia il contenuto di c al suo interno (Passo 2). Viene eseguito il corpo della funzione e la variabile a viene incrementata, il suo valore e' ora 11 (Passo 3). Siccome la funzione e' terminata viene disallocata la variabile a e l'esecuzione contiuna nel main (Passo 4). Come possiamo vedere alla fine la variabile a nel main non e' stata incrementata come invece avremmo voluto. Come si fa ad aggirare questa apparente limitazione? Con i puntatori chiaramente... ecco come: void increment( int *a ) 2---> { 3---> *a++; } int main() { 1---> int c=10; 4---> increment(&c); return(0); } Questa volta la funzione prende come parametro un puntatore a int e infatti nel main non passo la variabile c ma il suo indirizzo. Vediamo cosa succede man mano che vengono eseguite le varie istruzioni: Dopo 1 Dopo 2 Dopo 3 Dopo 4 | | | | | | | | | | +-------+ +-------+ | | a 1800 | | | | | | | | 1801 | | | | | | | | 1802 | | | 1844 | | 1844 | | | 1803 | | | | | | | | | | +-------+ +-------+ | | | | | | | | | | +-------+ +-------+ +-------+ +-------+ c 1844 | | | | | | | | 1845 | | | | | | | | 1846 | 10 | | 10 | | 11 | | 11 | 1847 | | | | | | | | +-------+ +-------+ +-------+ +-------+ | | | | | | | | Come prima viene inizializzata la variabile c col valore 10 (Passo 1). Viene poi richiamata la funzione, questa volta pero' non passiamo il valore di c ma bensi' il suo indirizzo (&c). Possiamo fare questo perche' la funzione adesso non accetta piu' un intero, ma un puntatore a intero (Passo 2). A questo punto viene incrementata la variabile puntata da a (quindi la variabile che si trova all'indirizzo 1844) cioe' la variabile c (Passo 3). La funzione termina, viene disallocata la variabile a e l'esecuzione riprende dal main (Passo 4). Come possiamo notare questa volta la variabile c e' stata effettivamente modificata perche' e' stato passato per "copia" l'indirizzo di c, NON il suo valore. (anche qui: se non avete capito perfettamente rileggete tutti i ragionamenti perche' piu' si va avanti piu' la faccenda si complica...) - Aritmetica dei puntatori. Abbiamo detto che un puntatore e' una variabile che contiene un indirizzo. Su questi indirizzi possiamo fare delle operazioni aritmetiche dato che i puntatori vengono trattati esattamente come se fossero dei valori interi. Vediamo un esempio: int main() { char arr[5]; char *p; p = &arr[0]; 1 => *p = 'C'; 2 => p++; 3 => *p = 'D'; 4 => *(p+1) = 'E'; .... Vediamo anche in questo caso cosa avviene passo per passo... Dopo 1 Dopo 2 Dopo 3 Dopo 4 | | | | | | | | +-------+ +-------+ +-------+ +-------| arr[0] 1800 | 'C' | | 'C' | | 'C' | | 'C' | arr[1] 1801 | | | | | 'D' | | 'D' | arr[2] 1802 | | | | | | | 'E' | arr[3] 1803 | | | | | | | | arr[4] 1804 | | | | | | | | +-------+ +-------+ +-------+ +-------| | | | | | | | | +-------+ +-------+ +-------+ +-------+ p 1844 | | | | | | | | 1845 | | | | | | | | 1846 | 1800 | | 1801 | | 1801 | | 1801 | 1847 | | | | | | | | +-------+ +-------+ +-------+ +-------+ | | | | | | | | Prima di tutto dichiaro un array di 5 char. Poi un puntatore a char e gli assegno l'indirizzo del primo elemento dell'array. Accedo a quest'ultimo attraverso il puntatore appena inizializzato (Passo 1). Incremento il puntatore. Facendo questo l'indirizzo in p diventa 1801, quindi p da ora in poi puntera' al secondo elemento di arr (Passo 2). Scrivo nuovamente nell'elemento di arr puntato da p che questa volta e' arr[1] (Passo 3). Il comando che viene adesso e' piu' interessante: "*(p+1) = 'E'" praticamnete dico al compilatore di considerare l'elemento puntato dall'indirizzo in p piu' 1. Essendo l'indirizzo in p uguale a 1801 andro' a scrivere nella locazione 1801+1 == 1802 che e' il terzo elemento di arr (Passo 4). Facciamo alcune considerazioni. Supponiamo di dichiarare le seguenti variabili: char arr[10]; char *punt; punt = &arr[0]; Adesso punt punta al primo elemento dell'array. Alla luce di quanto detto prima, le seguenti scritture non dovrebbero porvi particolari problemi: Il comando... ...e' equivalente a... ---------------------------------------------- *punt = 'A' arr[0] = 'A' *(punt+0) = 'A' arr[0] = 'A' *(punt+1) = 'A' arr[1] = 'A' in generale: *(punt+n) = 'A' arr[n] = 'A' La tabella qui sopra mette chiaramente in evidenza uno stretto legame fra i puntatori e gli array. In effetti quando dichiariamo un array possiamo utilizzarlo esattamente come se fosse un puntatore: char arr[100]; 1 => arr[40] = 'A'; 2 => *(arr+40) = 'A'; Le righe 1 e 2 sono perfettamente equivalenti e sono entrambe scritture valide. Ma allora non c'e' nessuna differenza fra un puntatore e un'array? In realta' una differenza c'e' e sta nel fatto che non posso assegnare un nuovo indirizzo ad un array. char arr[100]; char p; => arr = &p; La linea indicata dalla freccia genera un errore. L'indirizzo di arr e' quindi una costante e non posso modificarlo. Questo avviene perche' dichiarando un'array il compilatore alloca solo lo spazio di memoria per contenerlo quindi non esiste nessuna cella di memoria che contiene l'indirizzo dell'array, cioe' non esiste nessun puntatore a quell'array, per es.: | | +-------+ 1800 | | 1801 | | char arr[5]; ----> 1802 | | char *p; --+ 1803 | | p=arr; | 1804 | | | +-------+ +-> 1805 | 1800 | +-------+ | | In questo caso l'istruzione "p=arr" e' valida perche' esiste una cella di memoria atta a contenere l'indirizzo di arr (la 1805). In quest'altro caso: | | +-------+ 1800 | | 1801 | | char arr[5]; ----> 1802 | | arr=++; 1803 | | 1804 | | +-------+ | | l'istruzione "arr++" non e' valida perche' tento di modificare l'indirizzo "a cui punta arr", ma dato che arr non e' un puntatore ma un array non c'e' nessuna cella di memoria che contiene il suo indirizzo. Comunque il fatto che non possiamo modificare il valore di arr non ci impedisce di usare su di lui il dereferenziamento come se fosse un puntatore. - Allocazione dinamica della memoria. Fino ad ora abbiamo visto che la memoria viene allocata "staticamente" dal compilatore. Questo significa che dobbiamo dire al compilatore (quindi nel codice sorgente) quanta memoria abbiamo intenzione di utilizzare nel nostro programma. Vediamo ora come fare un'allocazione "dinamica" della memoria, cioe' un tipo di allocazione in cui non si sa a compile-time (cioe' a priori) la quantita' di memoria utilizzata, ma solo a run-time (cioe' mentre il programma viene eseguito). Per allocare un blocco di memoria a run-time si utilizza la funzione malloc(), cosi' definita: void *malloc( size_t size ); la funzione accetta un parametro che e' la dimensione (in byte) del blocco di memoria di cui abbiamo bisogno e restituisce un puntatore a quella zona di memoria (praticamente restituisce l'indirizzo del primo byte allocato). Perche' la funzione restituisce un puntatore a void? Vi ricordo che i puntatori sono tipizzati e la malloc puo' sapere cosa vogliamo scrivere nella memoria che ci riserva. Sta al programmatore convertire opportunamente il puntatore generico (void*) in un puntatore specifico per il tipo di dato che vogliamo memorizzare (per esempio in int* o char*). Per fare questo utilizziamo l'operatore di cast del C. Vediamo un esempio: int main() { 1 => char *p; 2 => p = (char*) malloc( 5 ); 3 => *(p+1) = 'A'; 4 => p[2] = 'C'; Ecco un flash della memoria passo dopo passo: Dopo 1 Dopo 2 Dopo 3 Dopo 4 | | | | | | | | | | +-------+ +-------+ +-------| | | 1810 | | | | | | | | 1811 | | | 'A' | | 'A' | | | 1812 | | | | | 'C' | | | 1813 | | | | | | | | 1814 | | | | | | | | +-------+ +-------+ +-------| | | | | | | | | +-------+ +-------+ +-------+ +-------+ p 1844 | | | | | | | | 1845 | | | | | | | | 1846 | | | 1810 | | 1810 | | 1810 | 1847 | | | | | | | | +-------+ +-------+ +-------+ +-------+ | | | | | | | | Inizialmente dichiaro un puntatore a char (passo 1). Poi richiamo la malloc per allocarmi 5 byte di memoria. La malloc riserva questi 5 byte a partire da 1810 e mi comunica questo indirizzo restituendomi un puntatore a void*. Converto questo puntatore in un char* con il cast "(char*)" e lo assegno a p (passo 2). Ora ho il mio spazio di memoria pronto per essere utilizzato. Al passo 3 utilizzo p per scrivere nella seconda cella allocata. Al passo 4 utilizzo p per scrivere nella terza cella allocata, ma questa volta sfrutto la notazione in stile array che risulta molto piu' intuitiva (come potete vedere sono praticamente intercambiabili...). Se avessi voluto allocare 5 int al posto di 5 char, avrei dovuto dire alla malloc di riservare NON 5 byte, ma 20. Questo perche' ogni intero e' grande 4 byte e non 1 come i char. Ora, nessuno mi vieta di scrivere direttamente il valore 20 all'interno di malloc, pero' questo pregiudicherebbe il funzionamento del mio programma in sistemi che non sono a 32-bit e hanno una dimensione diversa per il tipo int. La soluzione piu' elegante si ottiene con l'utilizzo del comando sizeof(), in questo modo: p = (int*) malloc( 20 ); diventa: p = (int*) malloc( 5*sizeof(int) ); in questo modo sono sicuro che mi verra' allocato esattamente lo spazio per contenere 5 variabili di tipo int. La memoria allocata DEVE essere deallocata quando non serve piu'. Questo viene fatto con la funzione free(). La funzione free ha un solo parametro che e' l'indirizzo dell'area di memoria da deallocare. Nell'esempio di prima un free(p); avrebbe disaloccato l'area di memoria allocata in precedenza rendendola disponibile ad altri processi che ne facessero richiesta. Tutta la memoria allocata viene disallocata automaticamente quando l'esecuzione del programma termina. Questo e' un comportamento corretto da parte del sistema operativo, in quanto possiamo permetterci di "dimenticarci" di disallocare la memoria prima della fine del programma... pero' (c'e' sempre un pero') questo non e' un buon modo di procedere da parte del programmatore. Un programma scritto correttamente dovrebbe liberare tutta la memoria che utilizza SEMPRE anche se e' l'ultima cosa che fa (pensate un po' cosa succederebbe se per es. un server web non liberasse la memoria che utilizza per servire le varie richieste... dopo un numero sufficiente di pagine web servite avrebbe "divorato" tutta la memoria disponibile inchiodando la macchina). E' quindi importantissimo ricordarsi di questo aspetto. Vediamo ora un esempio completo di utilizzo della memoria dinamica e dei puntatori: --------- File: ex17-memd.c -------- 1: #include 2: #include 3: 4: int main() 5: { 6: int n,i,media; 7: int *dati; 8: 9: printf( "Quanti numeri: " ); 10: scanf( "%d", &n ); 11: 12: dati = (int *) malloc( n*sizeof(int) ); 13: 14: for ( i=0; i