venerdì 6 febbraio 2015

Modern C++ - Memoria statica e dinamica

Nel precedente post, abbiamo visto cosa sono i puntatori e come si possono utilizzare per accedere alle celle di memoria contenenti le informazioni utilizzate all'interno di un programma.

Oggi vediamo come funziona il meccanismo di creazione dei dati all'interno della memoria di un computer, utilizzando il C++. I puntatori assumono un ruolo fondamentale in questo contesto, facilitando il compito di tener traccia degli indirizzi delle celle di memoria nelle quali vengono creati i dati.

Innanzitutto, occorre dire che in C e C++ esistono due tipi di memoria: lo stack e l'heap, noto anche come free-store, come Stroustrup stesso lo definisce.

Stack


Lo Stack e' la porzione fissa di memoria, in termini di bytes o multipli di bytes, assegnata ad un programma. Quando una funziona viene eseguita, un blocco di questo stack viene "prenotato" al fine di poter creare variabili che sono utilizzate all'interno della funzione stessa. Quando la funzione termina la sua esecuzione, il blocco prenotato viene liberato e puo' esser utilizzato per eventuali successive esecuzioni della funzione stessa. Immaginate lo stack come una pila di piatti: un piatto corrisponde alla porzione di memoria prenotata da una funzione e il piatto stesso puo' contenere del cibo (ovvero i dati usati nella funzione). I piatti vengono accatastati uno sull'altro, seguendo l'ordine di utilizzo delle funzioni, e vengono liberati in ordine inverso, ovvero l'ultimo piatto aggiunto e' il primo ad esser rimosso. Usando una terminologia piu' tecnica, lo stack segue un ordinamento di tipo LIFO (Last In, First Out).
Caratteristica fondamentale dello stack e' quella di contenere strutture di dati di dimensione fissa: durante l'utilizzo di un programma, la dimensione dei dati non puo' crescere.
Si pensi a dichiarazioni di questo tipo:

int x{10}; //1 int x 4 bytes = 4 bytes
const char stringa[] = "Ciao!"; //5 char x 1 byte = 5 bytes
double elementi[200];   //200 double x 8 bytes = 1600 bytes

Creando le variabili in questo modo, ovvero assegnando loro una dimensione predefinita (tipo il vettore elementi che contiene 200 dati), fa si' che esse vengano immagazzinate nello stack e che esse non possano esser modificate nella dimensione (ad esempio, non possiamo aggiungere o rimuovere elementi dal vettore oppure dalla stringa, bensi' siamo costretti a creare nuove variabili in futuro). Questo tipo di allocazione viene definito allocazione statica della memoria. Lo stack costituisce un'area ad accesso rapido e in generale si tende a preferirlo all'heap poiche' offre prestazioni maggiori in termini di velocita' e spesso le informazioni contenute nello stack vengono immagazzinate nella cache della CPU se esse vengono utilizzate frequentemente. Lo svantaggio diretto deriva dal fatto che una variabile non puo' esser modificata nelle dimensioni: cio' significa che occorre definire a priori la dimensione dei vettori o delle stringhe e questo risulta esser spiacevole nel caso in cui parte di tali contenitori venga inutilizzato oppure risultino essere insufficienti a contenere informazioni (si pensi ad esempio ad un vettore di 10 elementi, ma l'utente inserisce da tastiera 11 elementi: dove va' a finire l'11esimo elemento?).


Heap


L'Heap e' la memoria utilizzata per l'allocazione dinamica delle variabili. Diversamente dallo stack, nell'heap si puo' prenotare e liberare un blocco di memoria in qualsiasi momento e la sua dimensione puo' esser definita a run-time, ovvero durante l'esecuzione del programma stesso. Ovviamente, il prezzo da pagare rispetto allo stack e' una gestione piu' complessa richiesta per tracciare i blocchi di memoria allocati o liberati, ma e' qui che intervengono i puntatori.
Al fine di allocare un nuovo blocco di memoria nell'heap, si utilizza l'operatore new mentre per liberare tale blocco si utilizza l'operatore delete. Buona norma prevede che per ogni nuovo blocco di memoria prenotato mediante new, ci sia un corrispondente uso dell'operatore delete.
Questo concetto vale per il linguaggio C e per il C++ classico. Per il Modern C++, la situazione e' stata notevolmente semplificata; lo vediamo con un esempio:

Nell'esempio potete notare i 2 diversi approcci di stile. Nella prima funzione, chiamata classic_cpp, eseguo l'allocazione dinamica mediante i cosidetti naked pointers, ovvero utilizzando l'operazione new senza alcuna protezione: cio' significa che utilizzo tale operazione senza aver la garanzia di poter allocare effettivamente una regione di memoria. Infatti, nel caso in cui l'operazione new fallisca (ad esempio se si richiede la creazione di un array di 100 milioni di elementi, in dipendenza del computer e della RAM a disposizione), il puntatore risulta invalido e ogni futuro tentativo di accesso a tale zona di memoria risulta non valido. La regola d'oro e' sempre la stessa: ad ogni new corrisponde un delete e buona prassi e' quella di assegnare il valore NULL o 0 (zero) al puntatore, questo nel caso del C++ old style: questo permette di usare i puntatori nelle condizioni:
if(p != NULL)
{
   //bla bla
}

Nel caso del Modern C++, in soccorso vengono i cosidetti smart pointers, che consentono una gestione facilitata dell'allocazione dinamica e che automaticamente liberano la porzione di memoria allocata: l'operazione new questa volta e' avvolta (wrapped) e protetta all'interno dello smart pointer il quale puo' lanciare un errore (una eccezione) nel caso in cui la memoria richiesta non e' disponibile e l'operazione di delete viene eseguita al suo interno, senza alcuna esplicita richiesta dello sviluppatore.
Lo smart pointer ha peculiari funzioni, tra cui .get() oppure ->, che restituiscono un riferimento (la classica &) al tipo di dato e .reset() che permette di azzerare il contenuto dello smart pointer. Per accedere al valore effettivo della variabile, si continua ad usare l'operatore di dereferenziazione *, come nel listato di esempio.
Gli smart pointers sono di due tipi: unique_ptr e shared_ptr. Uno unique_ptr rappresenta l'unico custode dell'area di memoria allocata e non puo' esser copiato: quando un unique_ptr viene distrutto, l'area di memoria allocata viene rilasciata automaticamente. La regol base e' che ci puo' esser un solo unique_ptr per ogni risorsa.
Lo shared_ptr invece permette la condivisione di tale area di memoria tra piu' puntatori, grazie ad un contatore interno: ogni copia dello shared_ptr fa si che questo contatore venga incrementato di 1 unita', mentre ogni distruzione di uno shared_ptr decrementa tale contatore di 1. Quando questo contatore arriva a 0, ovvero nessun shared_ptr utilizza tale area di memoria, allora l'area di memoria viene liberata automaticamente.
In breve:
  1. unique_ptr si usa quando la risorsa non deve esser condivisa tra piu' puntatori
  2. shared_ptr si usa quando si vuol condividere la risorsa tra piu' puntatori, che modificano la risorsa stessa
In Modern C++, quindi, e' preferibile utilizzare gli smart pointers per via di questa facilitazione nel meccanismo di gestione della memoria, senza dover richiedere esplicitamente l'operazione di delete, la quale, se dimenticata, porta al cosidetto leak, ovvero ad uno spreco di memoria. Sfortunatamente (o forse fortunatamente?) in C++ non esiste il garbage collector come in Java, quindi tocca allo sviluppatore tener traccia della memoria utilizzata.
Un altro fondamentale appunto e' il seguente: e' preferibile utilizzare strutture dinamiche come vector oppure string per contenere un numero variabile di elementi, che siano caratteri o numeri: gli array C-style non solo risultano dannosi nel caso di un errato accesso ad essi, ma portano ad un consumo di memoria maggiore, se non vengono utilizzati del tutto.

Perdonatemi per il post troppo lungo e magari un po' difficile da capire con una prima lettura ma l'argomento in se' risulta vasto e richiede anche una conoscenza di base dell'architettura di un calcolatore. Grazie all'uso di queste conoscenze potremo costruire applicazioni C++ piu' complesse e flessibili con il susseguirsi delle lezioni di questo corso! A presto!!

Nessun commento:

Posta un commento