Salta al contenuto
Note per Studenti Esercizio - Smart pointer, unique_ptr e shared_ptr (primo appello 2021-22, teoria)

Esercizio - Smart pointer, unique_ptr e shared_ptr (primo appello 2021-22, teoria)

Esame
In questa pagina 3

Domanda aperta 2 del primo appello 2021-22 (25 gennaio 2022) del corso di Laboratorio di Programmazione, Ing. Informatica UniPD. Teoria: 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 →, C++ - eccezioni e gestione delle risorse (RAII)Un errore che non si può gestire dove avviene si segnala con throw e si gestisce con try/catch più in alto; la libreria standard ha la gerarchia std::exception (logic_error e runtime_error con le loro derivate) e le eccezioni proprie si derivano pubblicamente da una di esse; si cattura per riferimento. Le eccezioni mettono in crisi il codice che acquisisce risorse (memoria con new, file, lock, socket): se una chiamata lancia prima della delete, la risorsa si perde. La soluzione è RAII (Resource Acquisition Is Initialization): acquisire la risorsa nel costruttore di un oggetto locale e rilasciarla nel distruttore, perché all'uscita da uno scope, anche per eccezione (stack unwinding), i distruttori degli oggetti locali vengono chiamati. Garanzie di una funzione: basic (nessuna perdita, stato valido), strong (commit or rollback: se fallisce lo stato osservabile è quello di prima) e no-throw (non lancia).C++ - eccezioni e gestione delle risorse (RAII) →, 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 →.

Testo. Descrivere le caratteristiche fondamentali degli smart pointers. Illustrare le differenze fra unique_ptr<T> e shared_ptr<T>. Descrivere il comportamento delle seguenti funzioni della classe unique_ptr<T>: reset() e release().

Caratteristiche degli smart pointer

Gli smart pointer ("puntatori intelligenti") sono classi template definite in <memory>. Un oggetto smart pointer detiene un puntatore e ne gestisce la vita:

unique_ptr e shared_ptr

unique_ptr<T> shared_ptr<T>
possesso esclusivo: un solo proprietario condiviso: molti proprietari dello stesso oggetto
copia disabilitata (costruttore e assegnamento di copia = delete) permessa
spostamento permesso (std::move, ritorno da funzione) permesso
meccanismo nessuno: overhead sostanzialmente nullo rispetto a un puntatore nudo reference counting: un contatore dei shared_ptr che detengono lo stesso puntatore
quando libera quando lo unique_ptr è distrutto (o reset) quando l'ultimo shared_ptr che lo detiene è distrutto
costo trascurabile il conteggio consuma risorse

Limite di unique_ptr: due unique_ptr non possono puntare allo stesso oggetto. La copia è vietata dal compilatore (unique_ptr<X> q {p}; errore), ma se si inizializzano due unique_ptr con lo stesso puntatore nudo il compilatore non se ne accorge e si ha una doppia delete; il punto debole è l'esistenza del puntatore nudo, risolvibile con make_unique.

reset() e release()

funzione effetto
reset(p) lo unique_ptr detiene il nuovo puntatore p; se prima ne deteneva un altro, l'oggetto puntato è distrutto (deallocato). reset() senza argomento: semplice deallocazione, lo smart pointer diventa vuoto
release() rinuncia al possesso: restituisce il puntatore nudo e mette lo smart pointer a nullptr, senza distruggere l'oggetto. La responsabilità di liberarlo passa a chi lo riceve; si perde la deallocazione automatica
smart_demo.cppcpp
#include <iostream>
#include <memory>
using namespace std;

struct Risorsa {
    int id;
    explicit Risorsa(int i) : id{i} { cout << "  Risorsa(" << id << ") creata\n"; }
    ~Risorsa() { cout << "  Risorsa(" << id << ") distrutta\n"; }
};

int main()
{
    cout << "unique_ptr:\n";
    {
        unique_ptr<Risorsa> u {new Risorsa(1)};
        cout << "  u->id = " << u->id << ", (*u).id = " << (*u).id << '\n';   // si usa come un puntatore
        // unique_ptr<Risorsa> copia = u;            // errore: la copia e' disabilitata
        unique_ptr<Risorsa> spostato = std::move(u); // lo spostamento si puo' fare
        cout << "  dopo il move: u " << (u ? "detiene" : "e' vuoto") << ", spostato " << (spostato ? "detiene" : "e' vuoto") << '\n';

        spostato.reset(new Risorsa(2));              // reset(p): distrugge la Risorsa(1) e detiene la nuova
        cout << "  reset(new ...): ora detiene la " << spostato->id << '\n';

        Risorsa* nudo = spostato.release();          // release(): restituisce il puntatore SENZA distruggere
        cout << "  dopo release: spostato " << (spostato ? "detiene" : "e' vuoto") << ", il puntatore nudo punta a " << nudo->id << '\n';
        delete nudo;                                 // ora la responsabilita' e' nostra
        spostato.reset();                            // reset() senza argomenti: semplice deallocazione (qui non c'e' nulla)
    }
    cout << "shared_ptr:\n";
    {
        shared_ptr<Risorsa> a = make_shared<Risorsa>(3);
        cout << "  use_count = " << a.use_count() << '\n';
        {
            shared_ptr<Risorsa> b = a;               // la copia e' permessa: contatore dei riferimenti
            cout << "  dopo la copia use_count = " << a.use_count() << '\n';
        }                                            // b esce dallo scope: il contatore scende, la risorsa resta
        cout << "  dopo l'uscita di b use_count = " << a.use_count() << '\n';
    }                                                // a esce: contatore a 0, la risorsa e' distrutta
    cout << "fine\n";
    return 0;
}
unique_ptr:
  Risorsa(1) creata
  u->id = 1, (*u).id = 1
  dopo il move: u e' vuoto, spostato detiene
  Risorsa(2) creata
  Risorsa(1) distrutta
  reset(new ...): ora detiene la 2
  dopo release: spostato e' vuoto, il puntatore nudo punta a 2
  Risorsa(2) distrutta
shared_ptr:
  Risorsa(3) creata
  use_count = 1
  dopo la copia use_count = 2
  dopo l'uscita di b use_count = 1
  Risorsa(3) distrutta
fine

Lettura:

  • std::move(u) trasferisce il possesso: u resta vuoto, spostato detiene la Risorsa(1). La riga con la copia (unique_ptr<Risorsa> copia = u;) non compilerebbe.
  • spostato.reset(new Risorsa(2)): viene creata la Risorsa(2) e poi la Risorsa(1) che spostato deteneva è distrutta (compare Risorsa(1) distrutta).
  • release() restituisce il puntatore alla Risorsa(2) e lascia spostato vuoto; nessun distruttore è stato chiamato: la delete nudo; esplicita è obbligatoria, altrimenti si perde la risorsa.
  • shared_ptr: use_count va a 1, poi a 2 con la copia b, torna a 1 quando b esce dal blocco; la risorsa è distrutta solo quando esce a, cioè quando il contatore arriva a 0.

Un esempio d'uso tipico (dalle slide): make_vec() che costruisce un vector<int> sull'heap può diventare unique_ptr<vector<int>> make_vec() e ritornare lo unique_ptr (spostato col move constructor): se il riempimento lancia un'eccezione, la memoria è liberata dal distruttore dello unique_ptr e non serve il try/catch con delete p; throw;.

Versione ripasso

unique_ptr shared_ptr
possesso esclusivo condiviso
copia vietata (= delete) permessa
move sì sì
meccanismo nessun overhead reference counting
libera alla distruzione / reset quando l'ultimo proprietario muore
  • reset(p): lo smart pointer detiene p; il vecchio oggetto puntato è distrutto; senza argomento: deallocazione. release(): restituisce il puntatore nudo e mette lo smart pointer a nullptr senza distruggere: la responsabilità passa al chiamante (delete).
  • Limite di unique_ptr: due unique_ptr dallo stesso puntatore nudo compilano ma fanno doppia delete (rimedio: make_unique).
cpp
unique_ptr<Risorsa> u {new Risorsa(1)};
unique_ptr<Risorsa> s = std::move(u);        // u vuoto; la copia non compila
s.reset(new Risorsa(2));                     // distrugge la Risorsa(1)
Risorsa* nudo = s.release();  delete nudo;   // s vuoto, delete a mano
shared_ptr<Risorsa> a = make_shared<Risorsa>(3);   // use_count 1; con la copia b: 2; dopo b: 1
  • Esempio: unique_ptr<vector<int>> make_vec() ritorna lo unique_ptr (move): con un'eccezione nel riempimento nessun leak e nessun try/catch.
  • Errori tipici: copiare uno unique_ptr; usare release() pensando che liberi la memoria; due smart pointer dallo stesso puntatore nudo; dimenticare che shared_ptr ha un costo di conteggio.

Esercizi su questo argomento

Teoria collegata