Salta al contenuto
Note per Studenti Esercizio - Un unique_ptr minimo e make_unique (laboratorio 10)

Esercizio - Un unique_ptr minimo e make_unique (laboratorio 10)

In questa pagina 3

Testo.

  1. Implementare un semplice unique_ptr che supporti solo: costruttore, distruttore, ->, *, release(). Non implementare altre funzionalità (assegnamento, costruttore di copia, ecc.).
  2. Nel modulo sugli smart pointer si è visto che una debolezza non gestibile dal compilatore è l'inizializzazione di due unique_ptr con lo stesso puntatore. Per risolverla si usa make_unique(), che restituisce uno unique_ptr senza rendere accessibile il puntatore; è una funzione template in cui il template specificato è il tipo puntato e gli argomenti inizializzano l'oggetto puntato. Dopo aver letto la documentazione (con attenzione all'esempio): creare con make_unique() uno unique_ptr a un std::vector<int> di 10 elementi (la dimensione è passata in costruzione); inizializzare tutti gli elementi con un ciclo; aggiungere altri 4 elementi con push_back(); stampare tutti i valori.

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) →.


Esercizio 1: MiniUnique<T>

Che cosa deve fare, operazione per operazione

Uno unique_ptr è un oggetto che possiede un puntatore: ne ha la responsabilità di delete. Le cinque operazioni richieste:

operazione che cosa fa perché è fatta così
costruttore MiniUnique(T* p) memorizza p explicit, perché da T* a smart pointer la conversione implicita sarebbe pericolosa (C++ - buona interfaccia e progetti su più fileUna classe C++ ben progettata decide esplicitamente le sette operazioni essenziali (costruttore con argomenti, di default, di copia, assegnamento di copia, costruttore e assegnamento di spostamento, distruttore): se serve il distruttore perché si possiede una risorsa, servono quasi sempre anche copia e spostamento; un costruttore a un argomento definisce una conversione implicita, che si blocca con explicit; per capire quando i costruttori e il distruttore vengono chiamati si traccia una struct che stampa ogni chiamata. Un progetto su più file separa interfaccia (header .h con definizioni di classe e dichiarazioni, protetto da include guard) e implementazione (.cpp, compilati uno per uno, mai inclusi); al compilatore si passano solo i .cpp, il main sta in un file a sé; gli errori di compilazione (sintassi, tipi, dichiarazioni mancanti) vengono dal compilatore, quelli di linking (definizione mancante, due main, nessun main) dal linker.C++ - buona interfaccia e progetti su più file →); valore di default nullptr per creare uno smart pointer vuoto
distruttore delete ptr_ è il cuore di RAII: la memoria si libera all'uscita dallo scope; delete nullptr è lecito e non fa nulla, quindi non serve un if
operator-> restituisce ptr_ p->campo diventa ptr_->campo: il compilatore applica -> ricorsivamente al risultato
operator* restituisce *ptr_ come T& si usa come *p, anche in scrittura
release() restituisce ptr_ e lo azzera cede la proprietà: dopo la chiamata il distruttore non cancella più niente; la delete passa al chiamante

Il testo dice di non implementare altro, ma una classe che possiede una risorsa e lascia le copie generate dal compilatore è sbagliata (regola dei cinque, C++ - buona interfaccia e progetti su più fileUna classe C++ ben progettata decide esplicitamente le sette operazioni essenziali (costruttore con argomenti, di default, di copia, assegnamento di copia, costruttore e assegnamento di spostamento, distruttore): se serve il distruttore perché si possiede una risorsa, servono quasi sempre anche copia e spostamento; un costruttore a un argomento definisce una conversione implicita, che si blocca con explicit; per capire quando i costruttori e il distruttore vengono chiamati si traccia una struct che stampa ogni chiamata. Un progetto su più file separa interfaccia (header .h con definizioni di classe e dichiarazioni, protetto da include guard) e implementazione (.cpp, compilati uno per uno, mai inclusi); al compilatore si passano solo i .cpp, il main sta in un file a sé; gli errori di compilazione (sintassi, tipi, dichiarazioni mancanti) vengono dal compilatore, quelli di linking (definizione mancante, due main, nessun main) dal linker.C++ - buona interfaccia e progetti su più file →): con la copia di default due MiniUnique avrebbero lo stesso puntatore e farebbero due delete. Quindi copia e assegnamento di copia sono dichiarati = delete: è una disabilitazione, non un'implementazione, ed è ciò che fa lo unique_ptr vero. Conseguenza voluta: senza costruttore di spostamento questo MiniUnique non si può nemmeno restituire da una funzione (il compilatore dice call to deleted constructor of 'MiniUnique<int>'); per farlo servirebbe l'operazione di spostamento, che il testo esclude.

lab10/mini_unique.cppcpp
#include <iostream>
#include <utility>
using namespace std;

// Esercizio 1: uno unique_ptr minimo. Solo: costruttore, distruttore, ->, *, release().
template <typename T>
class MiniUnique {
public:
    explicit MiniUnique(T* p = nullptr) : ptr_{p} {}      // prende possesso del puntatore
    ~MiniUnique() { delete ptr_; }                         // delete di nullptr e' lecito: non fa nulla

    MiniUnique(const MiniUnique&) = delete;                // due proprietari = doppia delete
    MiniUnique& operator=(const MiniUnique&) = delete;

    T* operator->() const { return ptr_; }
    T& operator*() const { return *ptr_; }
    T* release()                                           // cede il puntatore e rinuncia al possesso
    {
        T* p = ptr_;
        ptr_ = nullptr;
        return p;
    }
private:
    T* ptr_;
};

struct X {
    int v;
    explicit X(int vv) : v{vv} { cout << "  X(" << v << ")\n"; }
    ~X() { cout << "  ~X(" << v << ")\n"; }
    void hello() const { cout << "  ciao da X(" << v << ")\n"; }
};

int main()
{
    cout << "1) distruzione automatica\n";
    {
        MiniUnique<X> p{new X{1}};
        p->hello();                                        // operator->
        (*p).v += 10;                                      // operator*
        cout << "  v = " << p->v << '\n';
    }

    cout << "2) release\n";
    {
        MiniUnique<X> q{new X{2}};
        X* grezzo = q.release();                           // q non possiede piu' nulla
        cout << "  q.release() restituito, grezzo->v = " << grezzo->v << '\n';
        delete grezzo;                                     // la delete passa a chi ha chiamato release
    }                                                      // ~MiniUnique: ptr_ e' nullptr, nessuna delete

    cout << "3) puntatore nullo\n";
    {
        MiniUnique<int> vuoto;                             // nessun oggetto: il distruttore non fa danni
        cout << "  creato e distrutto vuoto\n";
    }

    // MiniUnique<X> a{new X{3}};
    // MiniUnique<X> b = a;                                // errore: copia cancellata
}

Uscita eseguita:

1) distruzione automatica
  X(1)
  ciao da X(1)
  v = 11
  ~X(11)
2) release
  X(2)
  q.release() restituito, grezzo->v = 2
  ~X(2)
3) puntatore nullo
  creato e distrutto vuoto

Lettura della traccia:

  1. X(1) è costruito dal new; p->hello() usa operator->; (*p).v += 10 usa operator* e modifica l'oggetto (da 1 a 11); ~X(11) appare alla chiusura delle graffe, senza nessuna delete scritta: la fa il distruttore di MiniUnique.
  2. Dopo q.release() lo smart pointer è vuoto; ~X(2) compare perché il programma fa delete grezzo. Alla fine del blocco ~MiniUnique esegue delete nullptr: niente. Dimenticare la delete dopo release() produce un memory leak: chi usa release() si riprende la responsabilità.
  3. Con nullptr il distruttore non fa danni.

Esercizio 2: make_unique

make_unique<T>(args...) crea l'oggetto con new T(args...) e lo affida subito a uno unique_ptr. Il chiamante non vede mai il puntatore grezzo, quindi non può darlo a un secondo unique_ptr. Gli argomenti sono quelli del costruttore di T: make_unique<vector<int>>(10) chiama vector<int>(10), cioè un vettore di 10 elementi a zero (con le parentesi tonde del costruttore: vector<int>{10} sarebbe invece un vettore di un solo elemento di valore 10).

lab10/make_unique_vec.cppcpp
#include <iostream>
#include <memory>
#include <utility>
#include <vector>
using namespace std;

// Per capire make_unique: una versione minima con template variadico e perfect forwarding.
// (La libreria standard ha gia' std::make_unique: questa e' solo per vedere cosa fa.)
template <typename T, typename... Args>
unique_ptr<T> my_make_unique(Args&&... args)
{
    return unique_ptr<T>(new T(std::forward<Args>(args)...));
}

int main()
{
    // Esercizio 2: unique_ptr a un vector<int> di 10 elementi, senza mai vedere un puntatore grezzo
    unique_ptr<vector<int>> p = make_unique<vector<int>>(10);   // 10 e' l'argomento del costruttore di vector
    cout << "size iniziale: " << p->size() << '\n';

    for (size_t i = 0; i < p->size(); ++i)                       // inizializza tutti gli elementi con un ciclo
        (*p)[i] = static_cast<int>(i * i);

    for (int k = 0; k < 4; ++k)                                  // aggiunge altri 4 elementi
        p->push_back(100 + k);

    cout << "size finale: " << p->size() << '\n';
    for (int x : *p) cout << x << ' ';                           // stampa tutti i valori
    cout << '\n';

    // lo stesso con la versione scritta a mano
    auto q = my_make_unique<vector<int>>(3, 7);                  // vector<int>(3, 7): tre elementi uguali a 7
    cout << "my_make_unique: size " << q->size() << ", q[1] = " << (*q)[1] << '\n';
}                                                                // p e q liberano i vector da soli

Uscita eseguita:

size iniziale: 10
size finale: 14
0 1 4 9 16 25 36 49 64 81 100 101 102 103
my_make_unique: size 3, q[1] = 7

Passo per passo: p->size() usa -> per arrivare al vector; (*p)[i] dereferenzia lo smart pointer e poi indicizza (p[i] non compila, perché lo unique_ptr non è un array); il ciclo scrive i quadrati i*i nelle 10 posizioni; i 4 push_back aggiungono 100..103 (il vettore può riallocare, ma lo unique_ptr punta all'oggetto vector, non al suo buffer interno, quindi il puntatore resta valido); il ciclo for (int x : *p) stampa i 14 valori.

La funzione my_make_unique del programma è solo un'illustrazione di cosa fa la libreria: un template variadico Args&&... args accetta un numero qualsiasi di argomenti e std::forward li passa al costruttore di T senza copie inutili (perfect forwarding). Con my_make_unique<vector<int>>(3, 7) si chiama vector<int>(3, 7): tre elementi uguali a 7, come mostra la riga finale. Nella pratica si usa std::make_unique (C++14).

Errori da evitare

  • Dimenticare il costruttore explicit: MiniUnique<int> p = new int; compilerebbe nascondendo l'acquisizione.
  • Lasciare la copia di default: doppia delete all'uscita.
  • Chiamare release() e non fare la delete: leak.
  • delete sul valore di get()/release() mentre lo smart pointer possiede ancora l'oggetto: doppia deallocazione.
  • Costruire due smart pointer dallo stesso new (il difetto che make_unique elimina).

Versione ripasso

lab10/mini_unique.cppcpp
#include <iostream>
#include <utility>
using namespace std;

// Esercizio 1: uno unique_ptr minimo. Solo: costruttore, distruttore, ->, *, release().
template <typename T>
class MiniUnique {
public:
    explicit MiniUnique(T* p = nullptr) : ptr_{p} {}      // prende possesso del puntatore
    ~MiniUnique() { delete ptr_; }                         // delete di nullptr e' lecito: non fa nulla

    MiniUnique(const MiniUnique&) = delete;                // due proprietari = doppia delete
    MiniUnique& operator=(const MiniUnique&) = delete;

    T* operator->() const { return ptr_; }
    T& operator*() const { return *ptr_; }
    T* release()                                           // cede il puntatore e rinuncia al possesso
    {
        T* p = ptr_;
        ptr_ = nullptr;
        return p;
    }
private:
    T* ptr_;
};

struct X {
    int v;
    explicit X(int vv) : v{vv} { cout << "  X(" << v << ")\n"; }
    ~X() { cout << "  ~X(" << v << ")\n"; }
    void hello() const { cout << "  ciao da X(" << v << ")\n"; }
};

int main()
{
    cout << "1) distruzione automatica\n";
    {
        MiniUnique<X> p{new X{1}};
        p->hello();                                        // operator->
        (*p).v += 10;                                      // operator*
        cout << "  v = " << p->v << '\n';
    }

    cout << "2) release\n";
    {
        MiniUnique<X> q{new X{2}};
        X* grezzo = q.release();                           // q non possiede piu' nulla
        cout << "  q.release() restituito, grezzo->v = " << grezzo->v << '\n';
        delete grezzo;                                     // la delete passa a chi ha chiamato release
    }                                                      // ~MiniUnique: ptr_ e' nullptr, nessuna delete

    cout << "3) puntatore nullo\n";
    {
        MiniUnique<int> vuoto;                             // nessun oggetto: il distruttore non fa danni
        cout << "  creato e distrutto vuoto\n";
    }

    // MiniUnique<X> a{new X{3}};
    // MiniUnique<X> b = a;                                // errore: copia cancellata
}
  • Uscita: X(1), ciao da X(1), v = 11, ~X(11) alla fine dello scope; con release() la delete è del chiamante (~X(2)); con nullptr il distruttore non fa danni.
  • make_unique<T>(args...): new T(args...) subito in uno unique_ptr, senza puntatore grezzo accessibile; make_unique<vector<int>>(10) = 10 elementi; si accede con (*p)[i] e p->push_back(...). Uscita: size iniziale: 10, size finale: 14, valori 0 1 4 9 16 25 36 49 64 81 100 101 102 103.
  • Variadico: template <typename T, typename... Args> unique_ptr<T> my_make_unique(Args&&... args) con std::forward; my_make_unique<vector<int>>(3, 7) = tre elementi uguali a 7.
  • Errori tipici: costruttore non explicit, copia di default, release() senza delete, due smart pointer dallo stesso new, p[i] invece di (*p)[i].

Teoria collegata