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.

Segmenti di Memoria di un Processo. Segmento Heap Evidenziato in Rosso
Figura 1: Segmenti di Memoria di un Processo. Segmento Heap Evidenziato in Rosso

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.

Definizione

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>

Definizione

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.

Definizione

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.

Definizione

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:

  1. Non c'è abbastanza memoria disponibile; vi potrebbero essere troppi processi attivi in memoria e il sistema operativo non riesce a soddisfare la richiesta;
  2. 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.
Definizione

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);
}
Nota

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:

Allocazione di Memoria con la funzione malloc
Figura 2: Allocazione di Memoria con la funzione malloc

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:

Assegnamento di un Puntatore a un Altro Puntatore
Figura 3: Assegnamento di un Puntatore a un Altro Puntatore

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 ed ha il seguente prototipo:

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.

Definizione

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.

Nota

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:

  1. Passare un puntatore NULL alla funzione free. Questo è un comportamento indefinito;

    void *p = NULL;
    /* ERRORE: p è NULL */
    free(p);
    
  2. Passare un puntatore non allocato con malloc, calloc o realloc. 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);
    
  3. 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;
Definizione

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 e realloc;
  • 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.