Allocazione Dinamica della Memoria in Linguaggio C
L'allocazione dinamica della memoria è una tecnica fondamentale in linguaggio C che permette di gestire la memoria in modo flessibile ed efficiente. A differenza delle variabili statiche, che hanno una dimensione fissa determinata a tempo di compilazione, l'allocazione dinamica consente di richiedere memoria durante l'esecuzione del programma. Questo è particolarmente utile quando non si conosce a priori la quantità di memoria necessaria, come nel caso di strutture dati che possono crescere o diminuire in dimensione.
In questa lezione, esploreremo le principali funzioni fornite dalla libreria standard del C per l'allocazione dinamica della memoria: malloc
, calloc
, realloc
e free
. Vedremo come utilizzare queste funzioni per allocare, ridimensionare e liberare la memoria, evitando problemi comuni come i memory leak e i dangling pointer.
Allocare la memoria in maniera dinamica
Nelle lezioni precedenti, abbiamo studiato le stringhe, gli array e le strutture dati in linguaggio C.
Tutti questi tipi di dato hanno, normalmente, una dimensione fissa.
Ad esempio, gli array, una volta dichiarati, non possono cambiare il numero di elementi che contengono. L'unico modo per modificare la dimensione è cambiare il codice e ricompilare.
Il C99 ha introdotto gli array a lunghezza variabile, ma la loro lunghezza è determinata a tempo di esecuzione e rimane fissa per il resto del tempo.
Analogamente le stringhe, una volta dichiarate, non possono cambiare la loro lunghezza. Questo perché esse sono a tutti gli effetti degli array di caratteri.
In poche parole, le strutture dati a lunghezza fissa, come stringhe, array e struct
, rappresentano un problema. Lo sviluppatore deve prevedere in anticipo una dimensione massima che non può essere modificata.
Si possono, allora, verificare due casi:
- La dimensione scelta è troppo grande e si spreca memoria;
- La dimensione scelta è troppo piccola e si rischia di sovrascrivere la memoria.
Prendiamo, ad esempio, un programma che deve gestire una lista di studenti che partecipano ad un corso. Se si prevede che il corso abbia al massimo 100 studenti, si potrebbe dichiarare un array di 100 elementi.
Tuttavia, potrebbe verificarsi il caso in cui il corso abbia più di 100 studenti. In questo caso, il programma non sarebbe in grado di gestire la situazione. Dovremmo modificare il codice e ricompilare.
In generale, quindi, non possiamo sempre essere sicuri della dimensione di una struttura dati a priori.
Fortunatamente, il linguaggio C ci permette di allocare la memoria in maniera dinamica. Questo significa che possiamo richiedere al sistema operativo di riservare una certa quantità di memoria in fase di esecuzione.
Heap
In generale, come funziona il meccanismo di allocazione dinamica della memoria in linguaggio C?
In precedenza, abbiamo visto che quando viene eseguito un processo corrispondente ad un programma scritto in C, il sistema operativo riserva quattro aree di memoria al processo, chiamate segmenti.
Abbiamo già studiato i primi tre:
- Segmento Testo: una porzione di memoria a dimensione fissa che contiene il codice macchina del programma;
- Segmento Dati: una porzione di memoria a dimensione fissa che contiene le variabili globali e statiche del programma;
- Segmento Stack: una porzione di memoria a dimensione variabile che contiene le variabili locali delle funzioni, i loro argomenti e i dati relativi alla chiamata delle funzioni. In altre parole, serve per contenere gli Stack Frame delle funzioni.
Rimane un quarto segmento: il Segmento Heap, chiamato anche semplicemente Heap del processo.
Il nome Heap deriva dall'inglese e vuol dire mucchio, cumulo. Questo perché, in questo segmento, vengono allocate le strutture dati di dimensione variabile, che possono essere viste come un mucchio di dati.
La sua dimensione è variabile e può crescere o diminuire durante l'esecuzione del programma.
Quando si vuole allocare una struttura dati sullo Heap bisogna invocare delle funzioni di libreria che permettono di richiedere al sistema operativo una certa quantità di memoria.
Segmento Heap
Il Segmento Heap è una porzione di memoria variabile che contiene le strutture dati allocate dinamicamente durante l'esecuzione di un programma.
Le strutture dati allocate sullo Heap sono accessibili da tutto il programma e la loro durata di vita è indipendente dalla funzione che le ha allocate.
La peculiarità del segmento Heap, tuttavia, è che a differenza delle variabili e dei dati allocati negli altri segmenti, la durata di vita delle strutture dati allocate sullo Heap deve essere gestita manualmente.
In altre parole, è compito dello sviluppatore scegliere quando allocare e, soprattutto, quando rilasciare la memoria allocata.
Questo è un dettaglio fondamentale. Infatti, se non liberiamo la memoria non più necessaria al programma e, nel frattempo, continuiamo ad allocarne di nuova, si potrebbe verificare un fenomeno chiamato memory leak che porta all'esaurimento della memoria disponibile.
Funzioni per l'allocazione dinamica
Vediamo, adesso, quali sono le funzioni fornite dalla libreria standard del C per allocare la memoria.
Per utilizzarle, bisogna includere il file header <stdlib.h>
Funzione malloc
La funzione malloc
consente di allocare blocchi di memoria. il nome sta per memory allocation.
La firma della funzione è la seguente:
#include <stdlib.h>
void *malloc(size_t size);
La funzione malloc
richiede in ingresso il numero di byte da allocare e restituisce un puntatore di tipo void *
alla memoria allocata.
Il tipo di dato size_t
è, in sostanza, un intero senza segno in grado di rappresentare la dimensione di un oggetto in byte. All'atto pratico è un alias per unsigned int
o unsigned long
.
Quando si ottiene un puntatore dalla funzione malloc
, si deve fare un cast esplicito al tipo di dato desiderato, anche se il linguaggio C non lo impone.
Inoltre, la funzione malloc
non ripulisce la memoria. Quindi la memoria che otteniamo con essa potrebbe contenere dati spazzatura derivanti da precedenti occupazioni.
Funzione calloc
La funzione calloc
permette l'allocazione di blocchi contigui di memoria. il nome sta per contiguous allocation.
La firma della funzione è la seguente:
#include <stdlib.h>
void *calloc(size_t num, size_t size);
La funzione calloc
richiede in ingresso il numero di elementi da allocare e la dimensione di ciascun elemento in byte. Restituisce un puntatore di tipo void *
alla memoria allocata.
A differenza di malloc
, la funzione calloc
inizializza la memoria allocata a zero.
Il suo impiego è utile quando si vuole essere sicuri che la memoria allocata sia inizializzata a zero e quando si vogliono allocare array di elementi o strutture dati.
Funzione realloc
La funzione realloc
permette il ridimensionamento di un blocco o area di memoria precedentemente allocata. Il nome sta per reallocation.
La firma della funzione è la seguente:
#include <stdlib.h>
void *realloc(void *ptr, size_t size);
La funzione realloc
richiede in ingresso un puntatore alla memoria da ridimensionare e la nuova dimensione in byte. Restituisce un puntatore di tipo void *
alla memoria ridimensionata.
Se la memoria allocata è sufficiente, la funzione realloc
la ridimensiona. Altrimenti, alloca una nuova area di memoria, copia i dati dalla vecchia area alla nuova e libera la vecchia area.
Se il puntatore passato è NULL
, la funzione realloc
si comporta come malloc
e alloca una nuova area di memoria.
Di queste tre funzioni, la funzione malloc
è quella più adoperata perché è più veloce. La funzione calloc
inizializza la memoria allocata a zero, operazione che può essere evitata se non necessaria.
Ciascuna di queste funzioni restituisce sempre puntatore di tipo void *
. Questo perché non possono sapere in anticipo quali tipi di dati si stiano allocando.
Analizzeremo in dettaglio queste funzioni nelle prossime lezioni. Per il momento studiamo un semplice esempio.
Esempio di uso della funzione malloc
Proviamo ad usare la funzione malloc
per allocare un'area di memoria di 1024 byte, ossia un kilobyte.
void *p;
p = malloc(1024);
In questo caso, p
è un puntatore di tipo void *
che punta all'area di memoria allocata.
Una cosa da tenere bene a mente è che quando si usa la funzione malloc
, si deve sempre controllare se la memoria è stata allocata correttamente.
Infatti, l'allocazione di memoria potrebbe fallire. Esistono vari motivi per cui ciò può accadere, ad esempio:
- Non c'è abbastanza memoria disponibile; vi potrebbero essere troppi processi attivi in memoria e il sistema operativo non riesce a soddisfare la richiesta;
- Si è tentato di allocare una quantità di memoria troppo grande. Sistemi operativi come Linux e Windows non permettono ad un processo in esecuzione di allocare grandi quantità di memoria. Questo per evitare che un processo possa monopolizzare la memoria del sistema. Il problema può essere aggirato modificando le impostazioni del sistema operativo.
Valore di Ritorno delle Funzioni di Allocazione della Memoria
In caso di fallimento, le funzioni di allocazione della memoria restituiscono un puntatore NULL
.
In ogni caso, bisogna sempre controllare se la memoria è stata allocata correttamente. Questa operazione è semplice, in quanto le tre funzioni di sopra, inclusa la malloc
, restituiscono un puntatore NULL
in caso di fallimento.
void *p;
p = malloc(1024);
/* Controlla se la memoria è stata allocata correttamente */
if (p == NULL) {
printf("Errore: memoria non allocata\n");
exit(1);
}
Controllare sempre il valore restituito dalle funzioni di allocazione della memoria
Bisogna sempre controllare se il puntatore restituito dalle funzioni di allocazione della memoria è NULL
. Se lo è, significa che l'allocazione è fallita e bisogna gestire l'errore.
Deallocazione della memoria
La funzione malloc
e le altre funzioni per l'allocazione della memoria ottengono la memoria dallo Heap del processo.
Esse potrebbero fallire, come abbiamo visto, e restituire un puntatore NULL
in caso di errore.
Inoltre, potrebbe accadere che il programma potrebbe allocare aree di memoria e perderne traccia. Questo fenomeno è chiamato memory leak.
Per capire meglio questo problema, consideriamo l'esempio che segue:
void *p;
void *q;
p = malloc(1024);
q = malloc(2048);
/* ... */
p = q;
In questo esempio, stiamo allocando due blocchi di memoria. Un primo di 1024 byte lo assegnamo al puntatore p
, mentre un secondo di 2048 byte lo assegnamo al puntatore q
.
Dopo le due allocazioni, la situazione in memoria è la seguente:
Successivamente, però, effettuiamo un assegnamento del valore del puntatore q
al puntatore p
:
p = q;
Adesso, entrambe i puntatori puntano all'area di memoria di 2048 byte allocata con la seconda chiamata a malloc
:
Come si può vedere, non esiste più nessun puntatore che punta al primo blocco di memoria di 1024 byte. Da questo momento in poi il programma non sarà più in grado di accedervi nuovamente. Questo è un esempio di memory leak in quanto quell'area di memoria è ormai sprecata.
In gergo tecnico, un'area o blocco di memoria non più accessibile dal programma prende il nome di garbage o spazzatura. Un programma che lascia aree di memoria non più accessibili è un programma che soffre di memory leak.
Alcuni linguaggi di programmazione, come Java e C#, forniscono un meccanismo chiamato garbage collector, in italiano netturbino, che si occupa di rilevare e liberare le aree di memoria non più accessibili. Si dice che questi linguaggi forniscono una gestione automatica della memoria.
In C non è così. Anzi, ogni programmatore è responsabile del riciclo della propria spazzatura. In C la gestione della memoria è manuale.
Per farlo bisogna adoperare la funzione di libreria free
.
La funzione free
Lo scopo della funzione free
è quello di liberare la memoria precedentemente allocata con malloc
, calloc
o realloc
.
Anch'essa è definita nell'header
void free(void *ptr);
L'uso della funzione free
è molto semplice. Basta passare il puntatore al blocco di memoria di cui non si ha più bisogno.
Ritornando all'esempio di sopra, possiamo risolvere il problema del memory leak in questo modo:
void *p;
void *q;
p = malloc(1024);
q = malloc(2048);
/* ... */
free(p);
p = q;
La chiamata alla funzione free
prima dell'assegnamento di q
a p
libera l'area di memoria di 1024 byte. In questo modo, quel blocco di memoria è nuovamente disponibile per successive allocazioni.
Funzione free
La funzione free
libera la memoria precedentemente allocata con malloc
, calloc
o `realloc.
Il prototipo della funzione è il seguente:
#include <stdlib.h>
void free(void *ptr);
La funzione free
richiede in ingresso un puntatore alla memoria da liberare.
Nell'usare la funzione free
, tuttavia, bisogna prestare cautela.
Attenzione al puntatore passato alla funzione free
Quando si usa la funzione free
bisogna evitare i seguenti tre errori che potrebbero portare a comportamenti indefiniti:
-
Passare un puntatore
NULL
alla funzionefree
. Questo è un comportamento indefinito;void *p = NULL; /* ERRORE: p è NULL */ free(p);
-
Passare un puntatore non allocato con
malloc
,calloc
orealloc
. Anche in questo caso si ha un comportamento indefinito;int a = 10; void *p = &a; /* ERRORE: p non è stato allocato con malloc, calloc o realloc */ free(p);
-
Passare un puntatore già liberato con
free
. Questo è un errore comune che può portare a un crash del programma.void *p = malloc(1024); free(p); /* ERRORE: p è già stato liberato */ free(p);
Quindi, quando si usa la funzione free
bisogna assicurarsi che il puntatore passato sia valido e non sia stato già liberato.
Il problema del Dangling Pointer
La funzione free
ci permette di liberare la memoria allocata con malloc
, calloc
o realloc
ed eventualmente di riutilizzarla.
Tuttavia, il suo utilizzo ci porta ad un secondo problema noto con il nome di Dangling Pointer o Puntatore Appeso.
Infatti, la funzione free
dealloca la memoria puntata dal puntatore passato come argomento, ma non modifica il valore del puntatore stesso.
Se si tenta di usare un puntatore che punta ad un'area di memoria precedentemente liberata, si potrebbe incorrere in un comportamento indefinito.
Ad esempio:
int *p = malloc(sizeof(int) * 10);
free(p);
/* ERRORE: p è un Dangling Pointer */
*p = 10;
In questo caso, p
è un puntatore che punta ad un'area di memoria di 10 interi allocata con malloc
. Dopo la chiamata a free
, p
diventa un Dangling Pointer. Provare ad accedere all'area di memoria puntata da p
causa un comportamento indefinito e potrebbe avere conseguenze disastrose.
Non esiste una soluzione univoca a questo problema. Tuttavia, una pratica comune è quella di assegnare il valore NULL
al puntatore dopo averlo liberato. In questo modo si può testare facilmente il fatto che il puntatore non sia più valido.
int *p = malloc(sizeof(int) * 10);
free(p);
p = NULL;
Puntatore Appeso (Dangling Pointer)
Un Puntatore Appeso è un puntatore che punta ad un'area di memoria precedentemente liberata attraverso la funzione free
.
L'uso di un puntatore appeso può portare a comportamenti indefiniti e a crash del programma.
Conclusione
In questa lezione abbiamo introdotto il concetto di allocazione dinamica della memoria in linguaggio C.
In particolare abbiamo studiato che:
- Il linguaggio C permette di allocare la memoria in maniera dinamica attraverso le funzioni
malloc
,calloc
erealloc
; - La memoria allocata dinamicamente è ottenuta dal Segmento Heap del processo;
- La durata di vita delle strutture dati allocate dinamicamente deve essere gestita manualmente;
- La funzione
free
permette di liberare la memoria precedentemente allocata; - Bisogna prestare attenzione a non creare memory leak e a non usare puntatori appesi.
Nella prossima lezione vedremo come sfruttare le funzioni introdotte in questa lezione per allocare dinamicamente le stringhe.