Allocazione dinamica della memoria

Quando è dichiarata una variabile, il compilatore riserva la quantità di memoria ad essa necessaria e le associa, ad uso dei riferimenti futuri, il nome scelto dal programmatore. Il compilatore desume dal tipo della variabile, già al momento della dichiarazione, quanti byte devono essere allocati. La stessa cosa avviene quando si dichiara un puntatore, in quanto anch'esso è una variabile, sebbene dal significato particolare: il compilatore   alloca16 o32 bit a seconda che si tratti di un puntatore near, oppure far. Anche per quanto riguarda gli array il discorso non cambia: tipo di dato e numero degli elementi dicono al compilatore quanta memoria è necessaria, durante l'esecuzione del programma, per gestire correttamente l'array; l'obbligo di indicare con una espressione costante il numero di elementi o, alternativamente, di inizializzarli contestualmente alla dichiarazione, conferma che in tutti i casi descritti la memoria è allocata in modo statico.

L'aggettivo "statico" non rappresenta qui la traduzione della parola riservata static (il cui significato è connesso alla visibilità e durata delle variabili), ma indica semplicemente che quanto dichiarato in fase di programmazione non è modificabile durante l'esecuzione del programma stesso; in altre parole essa è un evento compile­time e non run­time.

E' facile però individuare molte situazioni in cui tale metodo di gestione della memoria si rivela inefficiente: se, per esempio, i dati da memorizzare in un array sono acquisiti durante l'esecuzione del programma e il loro numero non è noto a priori, si è costretti a dichiarare un array di dimensioni sovrabbondanti "per sicurezza": le conseguenze sono un probabile spreco di memoria e il perdurare del rischio che il numero di elementi dichiarato possa, in qualche particolare situazione, rivelarsi comunque insufficiente.

Le descritte difficoltà possono essere superate mediante l'allocazione dinamica della memoria, tecnica consistente nel riservare durante l'esecuzione del programma la quantità di memoria necessaria a contenere i dati elaborati, incrementandola o decrementandola quando necessario, e rilasciandola poi al termine delle elaborazioni in corso al fine di renderla nuovamente disponibile per usi futuri.

Gli strumenti mediante i quali il C implementa la gestione dinamica della memoria sono, in primo luogo, i puntatori (ancora loro!), unitamente ad un gruppo di funzioni di libreria dedicate, tra le quali risultano di fondamentale importanza malloc(), realloc() e free(), dichiarate in ALLOC.H (o MALLOC.H, a seconda del compilatore).

La funzione malloc() consente di allocare, cioè riservare ad uno specifico uso, una certa quantità di memoria: essa si incarica di rintracciare (nell'insieme della RAM che il programma è in grado di gestire) un'area sufficientemente ampia e ne restituisce l'indirizzo, cioè il puntatore al primo byte. L'area così riservata non è più disponibile per successive allocazioni (successive chiamate a malloc() la considereranno, da quel momento in poi, "occupata") fino al termine dell'esecuzione del programma o fino a quando essa sia esplicitamente restituita all'insieme della memoria libera mediante la funzione free(). La funzione realloc() consente di modificare le dimensioni di un'area precedentemente allocata da malloc(): nel caso di una richiesta di ampliamento, essa provvede a copiarne altrove in RAM il contenuto, qualora non sia possibile modificarne semplicemente il limite superiore. In altre parole, se non può spostarne il confine, realloc() muove tutto il contenuto dell'area là dove trova spazio sufficiente, riserva la nuova area e libera quella precedentemente occupata.

La logica del marchingegno apparirà più chiara dall'esame di un esempio. Supponiamo di volere calcolare la somma di un certo numero di interi, introdotti a run­time dall'utilizzatore del programma: dopo avere digitato l'intero si preme il tasto RETURN per memorizzarlo; al termine della sequenza di interi è sufficiente premere CTRL­Z (in luogo di un intero) e RETURN ancora una volta perché tutti gli interi immessi e la loro somma siano visualizzati. Di seguito è presentato il codice della funzione sommainteri():

#include <alloc.h>      // prototipi di malloc(), realloc() e free()
#include <stdio.h>      // prototipi di gets() e printf()
#include <stdlib.h>     // prototipo di atoi()

int sommainteri(void)
{
    register i, j;
    int retcode = 0;
    long sum = 0L;
    char inBuf[10];
    int *iPtr, *iPtrBackup;

    if(!(iPtr = (int *)malloc(sizeof(int))))
        return(-1);
    for(i = 0; gets(inBuf); i++) {
        iPtr[i] = atoi(inBuf);
        sum += iPtr[i];
        iPtrBackup = iPtr;
        if(!(iPtr = (int *)realloc(iPtr,sizeof(int)*(i+2)))) {
            retcode = -1;
            iPtr = iPtrBackup;
            break;
        }
    }
    for(j = 0; j < i; j++)
        printf("%d\n",iPtr[j]);
    printf("La somma è: %ld\n",sum);
    free(iPtr);
    return(retcode);
}

La funzione sommainteri() restituisce ­1 in caso di errore, 0 se tutte le operazioni sono state compiute regolarmente.

La chiamata a malloc() in ingresso alla funzione alloca lo spazio necessario a contenere un intero[1] e ne assegna l'indirizzo al puntatore iPtr. Se il valore assegnato è nullo (uno 0 binario), la malloc() ha fallito il proprio obiettivo, ad esempio perché non vi è più sufficiente memoria libera: in tal caso sommainteri() restituisce ­1. Si noti che iPtr non è dihiarato come array, proprio perché non è possibile sapere a priori quanti interi dovranno essere memorizzati: esso è un normale puntatore, che contiene l'indirizzo del primo byte dell'area di memoria individuata da malloc() come idonea a contenere il numero di interi desiderati (per ora uno soltanto). Dunque, la malloc() riceve come parametro un unsigned int, che esprime in byte la dimensione desiderata dell'area di memoria, e restituisce un puntatore al pimo byte dell'area allocata: detto puntatore è di tipo void, pertanto l'operatore di cast evita ambiguità e messaggi di warning. Se non è possibile allocare un'area della dimensione richiesta, malloc() restituisce NULL.

Si entra poi nel primo ciclo for, il quale è iterato sino a che gets() continua a restituire un valore non nullo. La gets() è una funzione di libreria che memorizza in un buffer i tasti digitati e, alla pressione del tasto RETURN, elimina il RETURN stesso sostituendolo con un NULL (e generando così una vera e propria stringa C). La gets(), quando riceve una sequenza CTRL­Z (il carattere che per il DOS significa EOF, End Of File) restituisce NULL, perciò, digitando CTRL­Z in luogo di un intero, l'utilizzatore segnala alla funzione che l'input dei dati è terminato e che può esserne visualizzata la somma.

La stringa memorizzata da gets() in inBuf è convertita in intero dalla atoi(): questo viene, a sua volta, memorizzato nell'i­esimo elemento dell'array indirizzato da iPtr. Poco importa se, come si è detto, iPtr non è dichiarato come array: l'espressione iPtr[i] indica semplicemente l'intero (perché iPtr è un puntatore ad intero) che ha un offset pari a i interi[2] dall'indirizzo contenuto in iPtr. Questo è il "trucco" che ci permette di lavorare con iPtr come se fosse un array. Alla prima iterazione i vale 0, pertanto l'intero è memorizzato nel primo elemento dell'array; alla seconda iterazione i vale 1 e l'intero è memorizzato nel secondo elemento, e via di seguito.

L'intero memorizzato nell'array è sommato alla variabile sum: dal momento che questa è stata inizializzata a 0 contestualmente alla dichiarazione, al termine della fase di input essa contiene la somma di tutti gli interi introdotti.

A questo punto occorre predisporsi ad un'ulteriore iterazione: bisogna "allungare" l'array, per riservare spazio al prossimo intero in arrivo. A ciò provvede la realloc(), che richiede due parametri: il primo è l'indirizzo dell'area da estendere (o contrarre), iPtr nel nostro caso, mentre il secondo è la nuova dimensione, in byte, desiderata per l'area. Qui il risultato restituito dall'operatore sizeof() è molitplicato per i+2: l'operazione è necessaria perché ad ogni iterazione l'array è già costituito di un numero di elementi pari a i+1. Nella prima iterazione, infatti, i vale 0 e l'array contiene già l'elemento allocato da malloc(). Alla realloc() bisogna quindi richiedere un'area di memoria ampia quanto basta a contenere 2 elementi; alla terza iterazione i vale 1, e gli elementi desiderati sono 3, e via di seguito. Come prevedibile, realloc() restituisce il nuovo indirizzo dell'area di memoria[3], ma se non è stato possibile ampliare quella precedentemente allocata neppure "spostandola" altrove, essa restituisce NULL, proprio come malloc(). Ciò spiega perché, prima di chiamare realloc(), il valore di iPtr è assegnato ad un altro puntatore (iPtrBackup): in tal modo, se realloc() fallisce è ancora possibile visualizzare la somma di tutti gli interi immessi sino a quel momento, riassegnando a iPtr il valore precedente alla chiamata. In questo caso, inoltre, sommainteri() deve comunque restituire ­1 (tale valore è infatti assegnato a retcode) ed occorre forzare (break) l'uscita dal ciclo for.

In uscita dal ciclo, la variabile i contiene il numero di interi immessi ed è perciò pari all'indice, aumentato di uno, dell'elemento di iPtr contenente l'ultimo di essi. Nel secondo ciclo for, pertanto, i può essere utilizzata come estremo superiore (escluso) dei valori del contatore.

Dopo avere visualizzato tutti gli elementi dell'array e la loro somma, ma prima di terminare e restituire alla funzione chiamante il valore opportuno, sommainteri() deve liberare la memoria indirizzata da iPtr mediante una chiamata a free(), che riceve come parametro proprio l'indirizzo dell'area da rilasciare (e non restituisce alcun valore). Se non venisse effettuata questa operazione, la RAM indirizzata da iPtr non potrebbe più essere utilizzata per altre elaborazioni: i meccanismi C di gestione dinamica della memoria utilizzano infatti una tabella, non visibile al programmatore, che tiene traccia delle aree occupate tramite il loro indirizzo e la loro dimensione. Detta tabella è unica e globale per l'intero programma: ciò significa che un'area allocata dinamicamente in una funzione resta allocata anche dopo l'uscita da quella funzione; tuttavia essa può essere utilizzata da altre funzioni solo se queste ne conoscono l'indirizzo. E' ancora il caso di sottolineare che l'area rimane allocata anche quando il programma non ne conservi l'indirizzo: è proprio questo il caso di sommainteri(), perché iPtr è una variabile automatica, e come tale cessa di esistere non appena la funzione restituisce il controllo alla chiamante.

Se ne trae un'indicazione tecnica di estrema importanza: la memoria allocata dinamicamente non fa parte dello stack, ma di una porzione di RAM sottoposta a regole di utilizzo differenti, detta heap[4]. L'allocazione dinamica rappresenta perciò un'eccellente soluzione ai problemi di consumo dello stack che possono presentarsi nell'implementazione di algoritmi ricorsivi; si tenga tuttavia presente che in alcuni modelli di memoria stack e heap condividono lo stesso spazio fisico, e quindi utilizzare heap equivale a sottrarre spazio allo stack. La condivisione degli indirizzi è possibile perché lo stack li utilizza dal maggiore verso il minore, mentre nello heap la memoria è sempre allocata a partire dagli indirizzi liberi inferiori. Un'occhiata agli schemi dei modelli di memoria può chiarire questi aspetti, di carattere meramente tecnico.

Quando un'area di memoria allocata dinamicamente deve essere utilizzata al di fuori della funzione che effettua l'allocazione, questa può restituirne l'indirizzo oppure può memorizzarlo in un puntatore appartenente alla classe external[5]. Ecco una nuova versione di sommainteri(), che restituisce il puntatore all'area di memoria se l'allocazione è avvenuta correttamente e NULL se malloc() o realloc() hanno determinato un errore:

#include <alloc.h>      // prototipi di malloc(), realloc() e free()
#include <stdio.h>      // prototipi di gets() e printf()
#include <stdlib.h>     // prototipo di atoi()

int *sommainteri2(void)
{
    register i, j;
    long sum = 0L;
    char inBuf[10];
    int *iPtr, *iPtrBackup, *retPtr;

    if(!(iPtr = (int *)malloc(sizeof(int))))
        return(NULL);
    for(i = 0; gets(inBuf); i++) {
        iPtr[i] = atoi(inBuf);
        sum += iPtr[i];
        iPtrBackup = retPtr = iPtr;
        if(!(iPtr = (int *)realloc(iPtr,sizeof(int)*(i+2)))) {
            retPtr = NULL;
            iPtr = iPtrBackup;
            break;
        }
    }
    for(j = 0; j < i; j++)
        printf("%d\n",iPtr[j]);
    printf("La somma è: %ld\n",sum);
    return(retPtr);
}

Le modifiche di rilievo sono tre: innanzitutto, la funzione è dichiarata di tipo int *, in quanto restituisce un puntatore ad interi e non più un intero; in secondo luogo, per lo stesso motivo, la variabile intera retcode è stata sostituita con un terzo puntatore ad intero, retPtr.

Le terza e più rilevante modifica consiste nell'eliminazione della chiamata a free(): è evidente, del resto, che non avrebbe senso liberare l'area allocata prima ancora di restituirne l'indirizzo. La chiamata a free() può essere effettuata da qualunque altra funzione, purché conosca l'indirizzo restituito da sommainteri2().

Quando sia necessario allocare dinamicamente memoria ed assegnarne l'indirizzo ad un puntatore far o huge, occorre utilizzare farmalloc(), farrealloc() e farfree(), del tutto analoghe a malloc(), realloc() e free() ma adatte a lavorare su  puntatori a32 bit[6]. Ancora una volta bisogna accennare alla logica dei modelli di memoria: quando un programma è compilato in modo tale che tutti i puntatori non esplicitamente dichiarati near siano considerati puntatori a 32 bit, non è necessario utilizzare le funzioni del gruppo di farmalloc(), in quanto malloc(), realloc() e free() lavorano esse stesse su puntatori far.


OK, andiamo avanti a leggere il libro...

Non ci ho capito niente! Ricominciamo...