Salta al contenuto
Note per Studenti Esercizio - Allocazione dinamica e layout della memoria (primo appello 2022-23, teoria)

Esercizio - Allocazione dinamica e layout della memoria (primo appello 2022-23, teoria)

Esame
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 nn elementi, con nn 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 p con 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 ) sono oggetti che detengono un puntatore e lo deallocano automaticamente all'uscita dallo scope (RAII), eliminando memory leak e dangling pointer con un overhead minimo. unique_ptr ha un solo proprietario: non si copia (costruttore e assegnamento di copia cancellati) ma si sposta (std::move, ritorno da funzione), ha reset(), release(), get(), * e ->; due unique_ptr costruiti con lo stesso puntatore grezzo sono un errore (doppia delete) che il compilatore non vede, e si evita con make_unique. shared_ptr ha più proprietari: si copia, tiene un contatore di riferimenti (use_count) e libera l'oggetto quando l'ultimo shared_ptr viene distrutto, a un costo maggiore; weak_ptr osserva senza possedere, per rompere i cicli. auto_ptr è deprecato (rimosso in C++17).C++ - smart pointer →)

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.

vector_v0.cppcpp
#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 liberato

Versione ripasso

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, delete per new[].
  • Classe d'esempio:
cpp
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 delete un 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.

Esercizi su questo argomento

Teoria collegata