Allocazione dinamica della memoria

Nel post precedente abbiamo detto che nel C++ esistono tre modi di gestire la memoria.

Nel primo metodo detto automatico, e cioé quello di default, una variabile esiste solo all'interno del blocco in cui è definita, e non è accessibile dall'esterno.

Nel secondo metodo detto statico, una variabile esiste per tutto il programma ed è accessibile da qualsiasi punto.

Nel primo metodo viene utilizzata un'area di memoria detta stack, alla quale il programma può accedere in modo relativamente veloce. Tuttavia il fatto che una variabile sia accessibile solo all'interno del suo blocco ne limita l'uso.

Una variabile statica invece, esiste per tutto il programma ed è accessibile ovunque. Tuttavia non sempre questo è un bene, ci sono variabili che necessitano di essere locali ad un determinato blocco.
L'ideale sarebbe poter decidere noi quando (o dove) una variabile inizia ad esistere e quando invece viene distrutta.

Ma il maggior difetto delle variabili automatiche e statiche è che devono essere create durante la compilazione, e quindi dobbiamo sapere in anticipo (durante la scrittura del programma) quante variabili creare, ma questo non sempre è possibile.

Mi spiego meglio con un esempio. Continuiamo il nostro ipotetico videogioco. Abbiamo deciso di munire il nostro personaggio di pistola, per difendersi dai nemici.
La pistola spara dei proiettili. In una situazione reale useremmo una classe proiettile e la utilizzeremmo per creare i nostri oggetti, e cioé i vari proiettili che vengono sparati di volta in volta.
Ma non avendo ancora parlato della programmazione ad oggetti ci limiteremo ad utilizzare una struttura di tipo proiettile, l'importante è capire il concetto di allocazione dinamica, quello che interessa a noi ora.

Ricordate come si crea una struttura? Creiamo una struttura che chiameremo Proiettile, con dei membri come la velocità del proiettile e i danni che causa ai nemici (per semplicità ci limiteremo a queste caratteristiche):
struct Proiettile {
float velocità;
int danno;
};
Nel videogioco esistono vari tipi di pistola, ognuna con i propri proiettili di danno e velocità differenti.

Ora, ipotizziamo che il giocatore abbia una pistola che utilizza dei proiettili di un determinato tipo. Dovremmo creare una funzione per questa fantomatica pistola, ma non avendo ancora trattato le funzioni, ci limiteremo a scrivere dentro la funzione main().

Fingiamo di essere all'interno di questa funzione, dobbiamo scrivere la sezione che si occupa di creare i proiettili man mano che vengono sparati e di distruggerli all'impatto con il nemico o con una parete.
Ma come facciamo a sapere quanti proiettili creare? In altre parole, come facciamo a sapere quante variabili di tipo Proiettile creare? Non lo sappiamo perché vengono creati dinamicamente ogni volta che il giocatore preme un pulsante della tastiera, e vengono distrutti altrettanto dinamicamente, in base a cosa incontrano durante il loro percorso.

Una cosa del genere sarebbe inutile:

Proiettile proiettile1, proiettile2, proiettile3;

//oppure:

Proiettile proiettili[100];

Ed è qui che ci viene in aiuto l'allocazione dinamica della memoria.
La memoria allocata dinamicamente viene creata in un'area detta heap, che è leggermente meno performante dello stack, ma l'utilità di questo tipo di gestione della memoria rende trascurabili le prestazioni.

Per allocare un'area di memoria in modo dinamico, si utilizza la parola chiave new, mentre per distruggerla si utilizza la parola chiave delete.
Dal momento che l'area di memoria viene allocata dinamicamente, non ha una variabile associata ad essa.
Questo significa che non viene creata al momento della compilazione (compile time) tramite un'istruzione simile a quelle dell'esempio precedente.
Perciò non abbiamo un proiettile1, proiettile2 e così via, a cui poterci riferire.
Questa memoria viene allocata durante l'esecuzione del programma (run time) in base a degli eventi che non possiamo prevedere durante la scrittura del codice. Non possiamo sapere quando il giocatore premerà il tasto spara, e quante volte lo premerà.

Allora come facciamo ad accedere all'area di memoria allocata?
Indovinate? Proprio grazie a loro, i puntatori!
Infatti l'operatore new, non si limita ad allocare un'area di memoria, ma restituisce l'indirizzo di memoria della locazione dove inizia quest'area.

Facciamo una prova, e come al solito vedrete che è più semplice a farsi che a dirsi (scrivetelo all'interno della funzione main).
Ma prima, facciamo un esempio dove creiamo una variabile di tipo Proiettile nel modo normale (e cioé una variabile automatica).

Sappiamo che ogni tipo richiede l'allocazione di una certa quantità di memoria, in base a quanto spazio richiede il dato da memorizzare. Così un int allocherà 32 bit, un char 8 bit ecc..

E' altrettanto chiaro che anche la creazione di una variabile di tipo Proiettile occuperà una certa quantità di memoria, in base alla struttura a cui si rifà.

Ok, procediamo alla creazione di una variabile del tipo Proiettile, definito da noi all'inizio del post grazie a una struttura e verifichiamo, grazie all'operatore sizeof, quanto è grande la memoria allocata per contenere un dato di tipo Proiettile:

Proiettile varPr; //creiamo una variabile di tipo Proiettile

cout << sizeof(varPr); //vediamo quanta memoria occupa

Il programma stamperà 8, che sta per 8 locazioni di memoria, e cioé 8 byte.
Andate all'inizio e vedete da cosa è composta la nostra struttura Proiettile.
Ebbenesì, è composta da un float, che occupa 32 bit, e da un int, che occupa anch'esso 32 bit, che sommati danno 64 bit = 8 byte!

Perciò possiamo concludere che una variabile appartenente ad un tipo definito tramite una struttura richiede l'allocazione di una quantità di memoria uguale alla somma delle quantità richieste dai singoli tipi che compongono la struttura.

Nel nostro caso varPr appartiene ad un tipo definito tramite la struttura Proiettile, e richiede l'allocazione di una quantità di memoria uguale alla somma delle quantità richieste da float velocità e int danno, che sono i singoli tipi che compongono Proiettile.

Ovviamente più grande è una struttura, e cioé più membri contiene, più spazio verrà allocato durante la dichiarazione di una variabile del tipo definito tramite quella struttura.

Ma torniamo a noi, e vediamo come avviene l'allocazione dinamica della memoria.
Riprendiamo l'esempio precedente, ma questa volta utilizziamo l'operatore new:

Proiettile* puntPr; //crea un puntatore al tipo Proiettile
puntPr = new Proiettile; //alloca una nuova area di memoria

Nella prima riga dichiariamo un puntatore e lo facciamo puntare al tipo Proiettile.
Questo serve, come ormai saprete sino alla nausea, per dire al puntatore quanto sarà grande l'area di memoria a cui farà riferimento (abbiamo verificato che si tratta di 64 bit, dal momento che la struttura Proiettile è formata da un membro di tipo int (32 bit) più un membro di tipo float (32 bit)).

Nella seconda riga, l'operatore new alloca nello heap un'area di memoria di una grandezza tale da poter contenere i dati del tipo Proiettile (abbiamo visto, ripeto, che il tipo Proiettile richiede una memoria di 64 bit).

Dopo aver allocato l'area di memoria necessaria, l'operatore new restituisce l'indirizzo di memoria della locazione di memoria in cui inizia l'area appena allocata.

Basta fare un cout << puntPr per conoscere tale indirizzo.
Nel mio caso si tratta dell'indirizzo 1ae4010.

Questo significa che da ora in poi esiste un area di memoria di 64 bit che inizia all'indirizzo 1ae4010, a cui punta il puntatore puntPr.
Il nostro puntatore è l'unico riferimento che abbiamo a quest'area di memoria, quindi ogni volta che vorremo memorizzare o recuperare un dato in tale area, dovremo utilizzare il puntatore.

Sempre ipotizzando di lavorare alla funzione che definisce il comportamento di una pistola all'interno del gioco, ad esempio una pistola laser, dobbiamo far sì che la funzione si occupi di impostare ogni proiettile al valore giusto.
Non avendo una variabile a disposizione per poter associare tali valori ai membri di ogni proiettile, dobbiamo per forza utilizzare il puntatore.

Ricordiamo che utilizzando il nome del puntatore si accede all'indirizzo di memoria da esso puntato, mentre se vogliamo accedere al contenuto della memoria che inizia a tale indirizzo, dobbiamo utilizzare l'operatore di referenziazione * .
Ricorderete anche che per accedere ad un membro della struttura si utilizza l'operatore punto ( . ) :

(*puntPr).danno = 500; //è una pistola laser, fa un casino di danno
(*puntPr).velocità = 712.48;

Come potete vedere, un puntatore a struttura, a differenza delle comuni variabili a struttura, ha bisogno di essere racchiuso tra parentesi tonde.
Tuttavia esiste un metodo più immediato di utilizzare i puntatori a struttura:

puntPr->danno = 500;
puntPr->velocità = 712.48;

Si tratta dell'operatore freccia -> . Come potete vedere è molto più comodo, e non richiede l'utilizzo dell'asterisco * prima del nome del puntatore, perché basta l'operatore freccia per capire che si sta accedendo al dato presente nella memoria puntata, e non all'indirizzo di tale memoria.

Naturalmente è anche possibile dichiarare il puntatore ed inizializzarlo direttamente:

Proiettile* puntPr = new Proiettile;

La riga di codice non fa altro che dichiarare un puntatore a Proiettile, allocare tramite new un'area di memoria grande quanto Proiettile, ed inizializzare il puntatore assegnandogli l'indirizzo di memoria della locazione in cui inizia l'area di memoria allocata da new.

Allo stesso modo in cui si alloca la memoria dinamica, è possibile deallocarla, tramite l'operatore delete.
Ad esempio ipotiziamo che la nostra funzione fpistolaLaser abbia una sezione in cui verifica se il proiettile è andato in collisione con un nemico o con una parete, se questa condizione è vera (scommetto che questo vi fa venire in mente le variabili booleane :) ), il proiettile non esiste più perché si è frantumato, e quindi la funzione dealloca lo spazio ormai non più necessario, nel modo seguente:

delete puntPr;

Semplice come bere un bicchier d'acqua.

Naturalmente è possibile allocare dinamicamente anche i tipi forniti dal C++, nel qual caso il puntatore viene utilizzato come un normale puntatore a variabile:

int* pInt = new int;

*pInt = 56;

In questo modo abbiamo due vantaggi:

Il primo è che l'area di memoria allocata dinamicamente non è collegata ad una variabile, e quindi non smette di esistere fuori dal blocco in cui è stata creata.

Il secondo è che l'area di memoria viene allocata dinamicamente, e quindi è il nostro codice ad allocare l'area quando necessario e a deallocarla quando non è più necessario, in modo automatizzato.

Al momento l'utilità dell'allocazione dinamica della memoria vi sembrerà limitata, ma quando arriveremo a trattare la programmazione ad oggetti ed inizieremo a scrivere qualcosa di interessante, capirete che è fondamentale.

Nessun commento:

Posta un commento