Puntatori, struct e memoria dinamica in C
In questa pagina 7
In C non esistono classi né interfacce (vedi Liste, pile e codeRipasso degli ADT elementari: lista index-based (array) e position-based (lista doppiamente concatenata con sentinelle), pila LIFO, coda FIFO, deque, iteratori; interfacce, implementazioni e costi; array estendibile e coda circolare.Liste, pile e code →): un ADT si realizza con struct e funzioni, e la memoria si gestisce a mano. L'esame ha una parte di programmazione in C su liste, alberi e heap (vedi Liste concatenate in CLista singolarmente concatenata in C con nodo struct e testa passata per riferimento (Nodo **); addHead, addTail ricorsiva e iterativa, pop, inversione in loco, liberazione; coda con puntatori a testa e coda; ricorsione sulle liste; costi.Liste concatenate in C → e Alberi e heap in CAlbero binario in C con nodo struct e figli left/right; funzioni ricorsive (conteggio, altezza, visita inorder, liberazione in postorder); inserimento in un albero binario di ricerca con Nodo **; min-heap su array con indici da 1 (insert, removeMin, bottomUp).Alberi e heap in C →). Questa nota riprende e approfondisce quanto visto in Fondamenti di Informatica: Puntatori in CUn puntatore contiene un indirizzo di memoria; operatori & e *, NULL, puntatori come parametri per modificare variabili del chiamante, aritmetica dei puntatori, legame tra array e puntatori, const, puntatori a puntatori.Puntatori in C →, Strutture (struct) in Cstruct per raggruppare campi di tipo diverso, typedef, accesso con punto e freccia, struct come parametri e valori di ritorno, array di struct, struct allocate dinamicamente e struct autoreferenziali.Strutture (struct) in C →, Array e stringhe in CArray di dimensione fissa in memoria contigua, inizializzazione, nessun controllo sugli indici, passaggio a funzioni con la lunghezza, matrici; stringhe come array di char terminati da '\0' e funzioni di string.h.Array e stringhe in C →, Funzioni in CDefinizione e prototipo di una funzione C, tipo di ritorno e void, passaggio dei parametri sempre per valore, variabili locali, globali e static, file header e compilazione separata, funzioni ricorsive.Funzioni in C → e Gestione della memoria in CSegmenti di memoria di un processo (codice, dati statici, stack, heap); durata delle variabili; allocazione dinamica con malloc, calloc, realloc e free; errori classici: memory leak, dangling pointer, double free, buffer overflow; strumenti di controllo.Gestione della memoria in C →; per chi viene da Python il confronto è in Dal Python al CStruttura di un programma C, compilazione con gcc (preprocessore, compilatore, linker), main e valore di ritorno, dichiarazioni con tipo statico, printf di base; corrispondenze con Python.Dal Python al C →.
Puntatori
Definizione (puntatore). Un puntatore è una variabile che contiene un indirizzo di memoria.
&xè l'indirizzo dix,*pè il valore puntato dap(dereferenziazione),NULLè il puntatore che non punta a nulla.
Un indirizzo è il numero d'ordine di una cella della memoria dell'elaboratore (la memoria è una sequenza di byte numerati: vedi Struttura di un elaboratoreModello di von Neumann: CPU, memoria centrale, bus e periferiche; ciclo fetch-decode-execute e gerarchia di memoria.Struttura di un elaboratore →). Un puntatore è quindi un intero che si interpreta come "posizione in memoria"; il tipo int * ricorda al compilatore che in quella posizione si trova un int.
Esempio. Con int x = 5; int *p = &x; *p = 7; si ottiene x == 7. Passo per passo, supponendo che x sia all'indirizzo (valore indicativo):
| istruzione | x |
p |
commento |
|---|---|---|---|
int x = 5; |
5 | (non definito) | la cella contiene |
int *p = &x; |
5 | 1000 | &x è l'indirizzo , p lo memorizza |
*p = 7; |
7 | 1000 | si scrive nella cella a cui p punta, cioè in x |
Proprietà (passaggio dei parametri per valore). C passa i parametri per valore: la funzione riceve una copia. Per modificare una variabile del chiamante si passa il suo indirizzo.
Esempio. Con void incrementa(int *p) { (*p)++; } e int n = 5; incrementa(&n); si ha n == 6.
void scambia(int *a, int *b) {
int t = *a;
*a = *b;
*b = t;
}
/* int x = 3, y = 7; scambia(&x, &y); -> x = 7, y = 3 */Traccia di scambia(&x, &y): a contiene l'indirizzo di x, b quello di y. Si salva il valore di x in una variabile temporanea (t = *a dà ), si copia il valore di y in x (*a = *b dà ) e si scrive il vecchio valore di x in y (*b = t dà ). La variabile t serve: senza di essa *a = *b cancellerebbe il prima di averlo salvato. Se scambia ricevesse int a, int b per valore, scambierebbe due copie e x, y resterebbero e .
Una funzione che deve restituire più valori ne restituisce uno e scrive gli altri tramite puntatori:
int massimo(const int v[], int n, int *pos) { /* const: la funzione non modifica v */
int m = v[0];
*pos = 0;
for (int i = 1; i < n; i++)
if (v[i] > m) { m = v[i]; *pos = i; }
return m;
}Array, aritmetica dei puntatori e stringhe
Proprietà (array e aritmetica dei puntatori). Un array è un blocco contiguo; il suo nome si comporta come puntatore al primo elemento, quindi
v[i]equivale a*(v + i)(la somma avanza diielementi, non diibyte). Un array passato a una funzione arriva come puntatore: la lunghezza non si conserva e va passata a parte (int n). Nessun controllo sui limiti:v[n]è un errore silenzioso.
Esempio. Con int v[3] = {10, 20, 30}; si ha *(v + 2) == v[2] == 30. Se v fosse all'indirizzo e sizeof(int) fosse , gli elementi occuperebbero i byte –, –, – e v + 2 varrebbe : il compilatore moltiplica i per la dimensione del tipo puntato. Per questo v[i] costa (un'addizione e una lettura), la stessa assunzione del modello RAMProblema computazionale come insieme di coppie (istanza, soluzione); algoritmo e modello di calcolo RAM; pseudocodice; taglia di un'istanza; ADT e struttura dati concreta; esempio svolto con ricerca lineare e binaria in un array ordinato.Problemi computazionali e algoritmi →. Altri dettagli sugli array sono in Array e stringhe in CArray di dimensione fissa in memoria contigua, inizializzazione, nessun controllo sugli indici, passaggio a funzioni con la lunghezza, matrici; stringhe come array di char terminati da '\0' e funzioni di string.h.Array e stringhe in C →.
Definizione (stringa in C). Una stringa è un array di
charterminato da'\0':char nome[64]contiene al più 63 caratteri più il terminatore. Si copia constrcpy, si confronta constrcmp, mai con=o==.
Esempio. strlen("ciao") vale 4, ma la stringa occupa sizeof("ciao") == 5 byte per il terminatore '\0': in memoria ci sono i cinque char 'c', 'i', 'a', 'o', '\0'. I caratteri sono memorizzati come interi piccoli secondo una codifica (vedi Codifiche binarie e informazione non numericaBit, byte e multipli (potenze di 2 e di 10); codici BCD e Gray; caratteri ASCII, Unicode e UTF-8; ordine dei byte (little e big endian); bit di parità e codice di Hamming per rilevare e correggere errori.Codifiche binarie e informazione non numerica →) e '\0' è il valore . strlen conta i caratteri fino al primo '\0', quindi costa e non . == tra due stringhe confronterebbe i due indirizzi, non il contenuto.
Struct e typedef
typedef struct {
char nome[64];
int voto;
} Studente;
Studente s;
strcpy(s.nome, "Anna");
s.voto = 28;
Studente *ps = &s;
ps->voto++; /* equivale a (*ps).voto++ */Definizione (operatori . e ->).
.si usa su una struct,->su un puntatore a struct. Una struct che contiene un puntatore a se stessa permette le strutture collegate:typedef struct nodo { int val; struct nodo *next; /* dentro la definizione serve il nome "struct nodo" */ } Nodo;
Esempio. Con Studente s; s.voto = 28; Studente *ps = &s; ps->voto++; si ottiene s.voto == 29.
Memoria dinamica
Definizione (memoria dinamica con malloc e free). Le variabili locali vivono nello stack e spariscono quando la funzione termina;
mallocalloca nello heap un blocco che dura finché non lo si libera confree.
Esempio. int *a = malloc(4 * sizeof(int)); riserva 16 byte nello heap; dopo a[i] = i * i si ha a[3] == 9, e free(a); li libera.
int n = 4;
int *a = malloc(n * sizeof(int)); /* n interi; restituisce NULL se fallisce */
if (a == NULL) return 1;
for (int i = 0; i < n; i++) a[i] = i * i;
free(a); /* ogni malloc ha il suo free */
a = NULL; /* per non lasciare un puntatore pendente */Su questa macchina sizeof(int) vale e sizeof(Studente) ( byte per nome più per voto): usare sempre sizeof invece di numeri fissi. Il compilatore può aggiungere byte di riempimento (padding) per allineare i campi agli indirizzi multipli della loro dimensione; qui non servono perché è già multiplo di , ma in generale sizeof di una struct può superare la somma dei campi.
La differenza tra stack e heap è la durata: una variabile locale nasce quando la funzione viene chiamata e muore al return (il suo record di attivazione viene tolto dallo stack, come in Algoritmi ricorsiviAlgoritmi ricorsivi come induzione eseguita; albero della ricorsione e record di attivazione nello stack; esempi ReverseArray, LinearSum, Power in tempo logaritmico, Fibonacci ricorsivo (esponenziale) e con memoizzazione; analisi della complessità con l'albero della ricorsione e correttezza per induzione.Algoritmi ricorsivi → e Stack e chiamata di funzioni in ARMChiamata con BL e ritorno con BX LR; convenzione di chiamata AAPCS (argomenti in r0-r3, risultato in r0, registri da preservare r4-r11); stack discendente pieno con PUSH e POP; record di attivazione; funzioni foglia e non foglia; esempio ricorsivo del fattoriale con evoluzione dello stack.Stack e chiamata di funzioni in ARM →); un blocco di malloc resta valido finché non lo si libera. Per questo una funzione può restituire l'indirizzo di un blocco allocato con malloc ma non l'indirizzo di una sua variabile locale. Il funzionamento è approfondito in Gestione della memoria in CSegmenti di memoria di un processo (codice, dati statici, stack, heap); durata delle variabili; allocazione dinamica con malloc, calloc, realloc e free; errori classici: memory leak, dangling pointer, double free, buffer overflow; strumenti di controllo.Gestione della memoria in C →.
Puntatore a puntatore
Proprietà (puntatore a puntatore). Un puntatore passato per valore è una copia: assegnarlo dentro la funzione non cambia quello del chiamante. Per modificarlo (tipicamente la testa di una lista o la radice di un albero) si passa il suo indirizzo, cioè un
Nodo **.
Esempio. Con void azzeraGiusto(int **p) { *p = NULL; }, la chiamata azzeraGiusto(&a) lascia a == NULL.
void azzeraSbagliato(int *p) { p = NULL; } /* a resta com'era */
void azzeraGiusto(int **p) { *p = NULL; } /* chiamata: azzeraGiusto(&a) */Il test ha mostrato: dopo azzeraSbagliato(a), a != NULL; dopo azzeraGiusto(&a), a == NULL.
Perché. In azzeraSbagliato(a) il parametro p è una copia dell'indirizzo contenuto in a: la funzione assegna NULL alla copia e al return la copia sparisce, mentre a nel chiamante non è stata toccata. In azzeraGiusto(&a) il parametro p contiene l'indirizzo della variabile a stessa (un puntatore a puntatore, tipo int **); *p = NULL scrive NULL proprio in a. È la stessa regola di scambia: per cambiare una variabile se ne passa l'indirizzo; qui la variabile è a sua volta un puntatore, quindi serve un livello di * in più.
Compilare
gcc -std=c99 -Wall -Wextra -o programma programma.c (e -lm per la libreria matematica). Un Makefile automatizza il comando; man funzione mostra la documentazione, ammessa all'esame. Gli avvisi vanno letti: -Wall segnala variabili non inizializzate e formati di printf sbagliati. Le fasi dalla sorgente all'eseguibile (preprocessore, compilazione, assemblaggio, collegamento) sono in Dal programma C all'eseguibile ARMCatena di traduzione compilatore, assemblatore, linker e loader; file oggetto, tabella dei simboli e rilocazione; collegamento statico e dinamico; organizzazione della memoria di un processo; chiamare funzioni assembly da C e viceversa, con esempio.Dal programma C all'eseguibile ARM →; per cercare gli errori sono utili le tecniche di Debugging e testingTipi di errore, metodo per localizzare un bug, lettura del traceback, stampe di controllo, debugger (VS Code, pdb, gdb), assert e strumenti per la memoria in C; test di unità, casi limite e partizioni, doctest e unittest.Debugging e testing →.
Errori comuni
- Dereferenziare
NULLo un puntatore non inizializzato (crash). - Puntatore pendente: usare un blocco dopo
free, o restituire l'indirizzo di una variabile locale. - Perdita di memoria: perdere l'unico puntatore a un blocco allocato (
p = NULLsenzafree) o dimenticare ilfree. - Passare
Nodo *dove serveNodo **e vedere la testa invariata. - Errori di uno sugli array (
i <= ninvece dii < n) e stringhe senza spazio per'\0'. - Confrontare stringhe con
==.
Versione ripasso
- Puntatore: contiene un indirizzo.
&xè l'indirizzo,*pil valore puntato,NULLnon punta a nulla. Esempio:int x = 5; int *p = &x; *p = 7;lasciax == 7. - Passaggio per valore: C copia i parametri. Per modificare una variabile del chiamante si passa
&x:scambia(int *a, int *b)usa una variabile temporanea,t = *a; *a = *b; *b = t;. Conintper valore,xeyresterebbero invariati. Più risultati: uno colreturn, gli altri via puntatore (esempiomassimo(v, n, &pos)). - Array:
v[i]equivale a*(v+i), con avanzamento diielementi (non diibyte). Negli argomenti l'array arriva come puntatore: la lunghezza va passata a parte. Nessun controllo sui limiti:v[n]è un errore silenzioso. Conint v[3]={10,20,30}si ha*(v+2) == 30. - Stringa: array di
charterminato da'\0';strlen("ciao")vale , mentresizeof("ciao")è . Si confronta constrcmp, mai con==(che confronta indirizzi).strlencosta . - Struct:
typedef struct {...} T;,.su una struct,->su un puntatore a struct:ps->voto++. Struct autoreferenziale:struct nodo *next(vedi Liste concatenate in CLista singolarmente concatenata in C con nodo struct e testa passata per riferimento (Nodo **); addHead, addTail ricorsiva e iterativa, pop, inversione in loco, liberazione; coda con puntatori a testa e coda; ricorsione sulle liste; costi.Liste concatenate in C →). - Memoria dinamica:
malloc(n * sizeof(int))restituisceNULLse fallisce, quindi va controllato; ognimallocha unfree, seguito daa = NULLper non lasciare un puntatore pendente. Stack = variabili locali, che muoiono alreturn; heap = blocchi dimalloc, validi finché non si libera. Una funzione non deve restituire l'indirizzo di una variabile locale. - Puntatore a puntatore: un puntatore passato per valore è una copia.
azzeraSbagliato(int *p) { p = NULL; }non cambiaa;azzeraGiusto(int **p) { *p = NULL; }, chiamato comeazzeraGiusto(&a), lo azzera. Per la testa di una lista si usaNodo **. - Dimensioni: su questa macchina
sizeof(int)vale esizeof(Studente)vale ( pernomepiù pervoto); conviene usare sempresizeof, perché il compilatore può aggiungere padding. - Compilare:
gcc -std=c99 -Wall -Wextra -o programma programma.c, con-lmper la matematica;manè ammesso all'esame. - Errori tipici: dereferenziare
NULLo un puntatore non inizializzato; usare memoria dopofree; perdere l'unico puntatore a un blocco (perdita di memoria);Nodo *invece diNodo **;i <= ninvece dii < n;==sulle stringhe.