Esercizio - Allocazione dinamica e layout della memoria (primo appello 2022-23, teoria)
In questa pagina 4
Domanda aperta 2 del primo appello 2022-23 del corso di Laboratorio di Programmazione, Ing. Informatica UniPD. Teoria: C++ - allocazione dinamica della memoria con new e deletela memoria dinamica si prende dal free store (heap) con new, che restituisce un puntatore all'oggetto o al primo elemento dell'array (new T, new T[n], con inizializzatori {}), e si restituisce con delete (per new) e delete[] (per new[]); il puntatore non sa quanti elementi ci sono, la memoria non ha nome e non ha controllo dei limiti; in C++ non c'è garbage collector (scelta per efficienza e controllo), quindi un'area che perde ogni riferimento diventa garbage e produce un memory leak, e gli errori tipici sono la doppia cancellazione e il dangling pointer (si mette il puntatore a nullptr dopo delete); il distruttore ~T() è chiamato dal compilatore quando l'oggetto esce dallo scope o con delete e libera le risorse, per cui una classe come vector acquisisce la memoria nel costruttore e la rilascia nel distruttore; new alloca e chiama il costruttore, delete chiama il distruttore e libera; operator[] con due versioni (T& e const) dà accesso in lettura e scrittura al buffer; si alloca dinamicamente solo se serve davvero (memoria grande, dimensione nota a run-time, durata controllata).C++ - allocazione dinamica della memoria con new e delete →, C++ - chiamate a funzione e layout della memoriauna chiamata a funzione crea un record di attivazione (RA) con una copia dei parametri e le variabili locali, impilato nello stack (LIFO) e tolto all'uscita; la dimensione dipende dalle variabili locali e il tempo per inserirlo è costante; il processo ha cinque regioni di memoria: text (istruzioni, spesso read-only), initialized data (globali e statiche inizializzate), uninitialized data o BSS (globali e statiche non inizializzate, azzerate dal sistema), stack (RA) e heap (allocazione dinamica con new e delete); la durata (storage duration) di una variabile è diversa dallo scope: le globali e le static sono statiche (esistono per tutto il programma, inizializzate una volta), le locali sono automatiche (create all'entrata nel blocco, distrutte in ordine inverso all'uscita); le globali sono inizializzate prima del main ma l'ordine fra file diversi non è definito.C++ - chiamate a funzione e layout della memoria →, C++ - puntatori, array e castun puntatore è una variabile che contiene un indirizzo di memoria e sa il tipo dell'oggetto puntato (T*); si ottiene l'indirizzo con & e si accede al dato con * (dereference, un lvalue), nullptr indica nessun oggetto e due puntatori a tipi diversi sono tipi diversi; void* è un puntatore a memoria grezza (accetta qualsiasi puntatore ma non si dereferenzia) e i cast (static_cast, reinterpret_cast, const_cast) servono per interfacciarsi con hardware e codice non modificabile; il puntatore, a differenza della reference, si può spostare, può essere nullptr e va dereferenziato, e assegnarlo copia l'indirizzo (shallow copy) mentre assegnare una reference copia l'oggetto; un array C è una sequenza contigua di elementi dello stesso tipo, con dimensione costante, senza controllo dei limiti, che passato a una funzione decade in un puntatore al primo elemento; l'aritmetica dei puntatori somma o sottrae interi spostando di n elementi (non di n byte) e la differenza fra puntatori è in elementi; gli errori classici sono nullptr, puntatori non inizializzati, accessi fuori dai limiti e puntatori a oggetti usciti dallo scope.C++ - puntatori, array e cast →.
Testo. Descrivere l'allocazione dinamica della memoria in C++, spiegando che effetti ha sul layout della memoria. Riportare e descrivere gli strumenti di programmazione in C++ utilizzati per l'allocazione dinamica. Riportare un esempio di codice di gestione dinamica della memoria all'interno di una classe.
Che cos'è e perché serve
Allocazione dinamica. Richiesta, a tempo di esecuzione, di un blocco di memoria la cui dimensione (o la cui durata) non è nota a tempo di compilazione, presa dal free store (heap) e restituita dal programmatore quando non serve più. Non è gestita da uno scope: la memoria resta allocata finché non la si libera.
Serve per: strutture di dimensione decisa a run-time (un vector di elementi, con letto da file), grandi quantità di memoria (lo stack è piccolo), oggetti che devono sopravvivere alla funzione che li crea (e passare al chiamante), controllo esplicito di quando si alloca e si libera.
Effetti sul layout della memoria
Un processo ha cinque regioni: text, initialized data, BSS, stack, heap. L'allocazione dinamica agisce solo sull'heap; il puntatore che la regge è una normale variabile (nello stack se locale, nei dati se globale) e contiene l'indirizzo del blocco.
indirizzi alti +------------------------+
| STACK | RA di f(): n, p (indirizzo del blocco), v (oggetto Vector)
| p = 0xac00 ----------+-----+
| (cresce in basso) | |
| ... | |
| (cresce in alto) | |
| HEAP (free store) | |
| 0xac00: [ n double ] <-----+ <- nato con new, vive finche' non c'e' delete[]
+------------------------+
| BSS: globali e static non inizializzate (a 0)
| DATA: globali e static inizializzate
indirizzi bassi | TEXT: il codice
+------------------------+- Prima di
new double[n]il blocco non esiste; dopo, esiste nell'heap e il puntatore lo raggiunge. Il blocco non ha nome. - Quando la funzione ritorna, il suo RA sparisce e il puntatore
pcon lui: il blocco resta nell'heap. Se nessuno lo libera, diventa garbage: è il memory leak. - Il puntatore sullo stack è copiato (
return p;), la memoria sull'heap no: per questo si può passare un oggetto al chiamante, che ne diventa responsabile. - L'heap non ha l'ordine LIFO dello stack: i blocchi si allocano e liberano in qualsiasi ordine (e per questo allocare costa di più, con chiamate al sistema operativo).
Gli strumenti del C++
| strumento | descrizione |
|---|---|
new T |
alloca un oggetto sull'heap, invoca il costruttore, restituisce T* |
new T[n] |
alloca un array di n elementi (n anche a run-time), li costruisce, restituisce il puntatore al primo |
| inizializzatori | new double{5.5}, new double[5]{0,1,2,3,4}; senza di essi i tipi built-in non sono inizializzati |
delete p |
invoca il distruttore e libera il blocco allocato con new |
delete[] p |
libera un array allocato con new[], distruggendo tutti gli elementi (usare quella sbagliata è un errore) |
distruttore ~T() |
funzione che il compilatore chiama alla fine della vita dell'oggetto: libera le risorse |
puntatori, nullptr |
l'unico modo di raggiungere il blocco; delete p; p = nullptr; evita il dangling pointer |
| costruttore/assegnamento di copia e di spostamento | servono perché la copia di default copierebbe il puntatore |
smart pointer (unique_ptr, shared_ptr) |
rendono automatica la delete (C++ - smart pointerGli smart pointer (classi template di |
Errori: memory leak (blocco irraggiungibile e non liberato), doppia cancellazione, dangling pointer (uso dopo delete), delete per un new[], accesso fuori dai limiti.
Esempio: allocazione dinamica dentro una classe
Una classe Vector che possiede un buffer: lo alloca nel costruttore, lo libera nel distruttore (~Vector), espone operator[] in due versioni. La gestione della memoria è dentro la classe: chi la usa non scrive mai new o delete. f3 mette a confronto la gestione manuale con p e quella automatica con un oggetto Vector.
#include <iostream>
using namespace std;
class Vector {
public:
explicit Vector(int s)
: sz{s}, elem{new double[s]} // alloca il buffer sul free store
{
for (int i = 0; i < s; ++i) elem[i] = 0; // new non inizializza i double
cout << " Vector(" << sz << "): buffer allocato\n";
}
~Vector() // chiamato da solo all'uscita dallo scope
{
delete[] elem; // libera il buffer
cout << " ~Vector(" << sz << "): buffer liberato\n";
}
int size() const { return sz; }
double& operator[](int n) { return elem[n]; } // lettura e scrittura
double operator[](int n) const { return elem[n]; } // solo lettura, su oggetti const
private:
int sz; // numero di elementi
double* elem; // puntatore al buffer
};
void f3(int n)
{
double* p = new double[n]; // gestione MANUALE: serve il delete[]
Vector v(n); // gestione AUTOMATICA: ci pensa il distruttore
v[0] = 3.5;
v[n - 1] = 7;
p[0] = v[0] + v[n - 1];
cout << " p[0] = " << p[0] << ", v.size() = " << v.size() << '\n';
delete[] p;
} // qui v viene distrutto
int main()
{
cout << "entro in f3\n";
f3(4);
cout << "esco da f3\n";
const Vector cv(2); // oggetto const
cout << "cv[1] = " << cv[1] << '\n'; // usa la versione const di operator[]
return 0;
}entro in f3
Vector(4): buffer allocato
p[0] = 10.5, v.size() = 4
~Vector(4): buffer liberato
esco da f3
Vector(2): buffer allocato
cv[1] = 0
~Vector(2): buffer liberatoVector(4)alloca 4double(heap), li azzera e salva puntatore e dimensione (nello stack, dentrov);~Vector(4)li libera alla}dif3: chi usa la classe non deve ricordarsene.p(gestione manuale) richiededelete[] p;; dimenticarlo darebbe un leak.- Copia di
Vectornon definita qui: con il costruttore di copia di default dueVectorcondividerebbero il buffer e lo libererebbero due volte: servono costruttore e assegnamento di copia o spostamento (C++ - costruttori, inizializzazione, copia e spostamentouna buona interfaccia è completa e minimale, ha i costruttori giusti, supporta o proibisce la copia, usa tipi adeguati, dichiara const ciò che non modifica e libera le risorse nel distruttore; il costruttore di default (senza argomenti) esiste quando c'è un valore iniziale sensato, si ottiene con valori di default nei membri (in-class initializer) o argomenti di default, e explicit vieta la conversione implicita da un argomento; initializer_listpermette la lista {a, b, c} e con le graffe ha la precedenza sul costruttore a un intero (vector{3} è un elemento, vector(3) sono tre); la copia di default è membro a membro (shallow copy) e con un puntatore a un buffer duplica il puntatore invece del contenuto (due oggetti che condividono i dati e una doppia delete), quindi servono il costruttore di copia e l'assegnamento di copia che fanno la deep copy (l'assegnamento prima alloca e copia, poi libera i vecchi dati, e deve tollerare l'auto-assegnamento); this è il puntatore all'oggetto corrente, parametro implicito delle funzioni membro; il costruttore e l'assegnamento di spostamento (T&&, rvalue reference) rubano il buffer della sorgente lasciandola vuota ma valida, il compilatore li sceglie da solo (con la copy elision a volte non servono nemmeno); i cinque (distruttore, copia, spostamento) vanno definiti insieme.C++ - costruttori, inizializzazione, copia e spostamento → ).
Versione ripasso
- Allocazione dinamica: blocco di memoria richiesto a run-time dal free store/heap, di dimensione o durata non nota in compilazione, liberato dal programmatore (non da uno scope). Serve per dimensioni a run-time, grandi quantità, oggetti che sopravvivono alla funzione. Rimando: C++ - allocazione dinamica della memoria con new e deletela memoria dinamica si prende dal free store (heap) con new, che restituisce un puntatore all'oggetto o al primo elemento dell'array (new T, new T[n], con inizializzatori {}), e si restituisce con delete (per new) e delete[] (per new[]); il puntatore non sa quanti elementi ci sono, la memoria non ha nome e non ha controllo dei limiti; in C++ non c'è garbage collector (scelta per efficienza e controllo), quindi un'area che perde ogni riferimento diventa garbage e produce un memory leak, e gli errori tipici sono la doppia cancellazione e il dangling pointer (si mette il puntatore a nullptr dopo delete); il distruttore ~T() è chiamato dal compilatore quando l'oggetto esce dallo scope o con delete e libera le risorse, per cui una classe come vector acquisisce la memoria nel costruttore e la rilascia nel distruttore; new alloca e chiama il costruttore, delete chiama il distruttore e libera; operator[] con due versioni (T& e const) dà accesso in lettura e scrittura al buffer; si alloca dinamicamente solo se serve davvero (memoria grande, dimensione nota a run-time, durata controllata).C++ - allocazione dinamica della memoria con new e delete →.
- Layout: text, initialized data, BSS, stack, heap; agisce solo sull'heap; il puntatore è una variabile normale (stack se locale) che contiene l'indirizzo; il blocco non ha nome. Al ritorno sparisce il RA e il puntatore, il blocco resta: senza
deleteè garbage (memory leak);return p;copia solo il puntatore.
STACK: p = 0xac00 ------+ HEAP: 0xac00: [ n double ] (new ... delete[])
DATA/BSS: globali e static TEXT: codice- Strumenti:
new T,new T[n](invocano il costruttore, restituiscono un puntatore), inizializzatori{},delete p,delete[] p(distruttore + libera), distruttore~T(),nullptr, copia e spostamento (la copia di default copia il puntatore), smart pointer. Errori: leak, doppia cancellazione, dangling pointer,deletepernew[]. - Classe d'esempio:
class Vector {
public:
explicit Vector(int s) : sz{s}, elem{new double[s]} { for (int i = 0; i < s; ++i) elem[i] = 0; }
~Vector() { delete[] elem; }
double& operator[](int n) { return elem[n]; }
double operator[](int n) const { return elem[n]; }
private:
int sz; double* elem;
};
void f3(int n) { double* p = new double[n]; Vector v(n); /* ... */ delete[] p; } // v si libera da solo- Errori tipici: liberare con
deleteun array; dimenticare il distruttore; copiare il puntatore con la copia di default; pensare che il blocco sparisca con la funzione; dire che l'heap è LIFO.