Salta al contenuto
Note per Studenti Esercizio - RAII e garanzie sulle eccezioni (terzo appello 2022-23, teoria)

Esercizio - RAII e garanzie sulle eccezioni (terzo appello 2022-23, teoria)

Esame

Domanda aperta 3 del terzo appello 2022-23 del corso di Laboratorio di Programmazione, Ing. Informatica UniPD. La stessa domanda era nel quarto appello 2021-22 (Esercizio - RAII e garanzie sulle eccezioni (quarto appello 2021-22, teoria)), svolta con l'esempio di make_vec e con la gestione della memoria. Qui lo stesso argomento è illustrato con un'altra risorsa (un lock) e con un esempio della differenza fra le garanzie. Teoria: 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++ - 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 →.

Testo. Descrivere la tecnica RAII: cosa significa e come viene utilizzata nei costruttori e distruttori di una classe costruiti opportunamente. Descrivere i concetti di basic guarantee, strong guarantee, no-throw guarantee.

RAII: che cosa significa

RAII = Resource Acquisition Is Initialization: l'acquisizione di una risorsa (memoria, file aperto, lock, thread, socket, finestra) coincide con l'inizializzazione di un oggetto. Si acquisisce nel costruttore e si rilascia nel distruttore.

Perché funziona: in C++ il distruttore di un oggetto locale è chiamato automaticamente quando il flusso di esecuzione lascia lo scope, in qualunque modo lo lasci: fine normale del blocco, return anticipato, break, eccezione. Il codice di rilascio sta in un solo posto e non può essere "saltato". In più:

  • l'oggetto esiste solo se l'acquisizione è riuscita: se il costruttore lancia, l'oggetto non esiste e non c'è nulla da rilasciare;
  • ci si libera di try/catch (...) { rilascia; throw; } ripetuti dappertutto;
  • gli oggetti composti funzionano da soli: il distruttore di una classe distrugge i sotto-oggetti.

Come si scrive la classe: costruttore = acquisisce (e se non riesce lancia); distruttore = rilascia tutto, senza lanciare; copia: vietata (= delete) o profonda; spostamento: trasferisce la risorsa. Gli smart pointer e vector sono classi RAII.

raii_lock.cppcpp
#include <iostream>
using namespace std;

// Classe RAII: acquisisce il "lock" nel costruttore e lo rilascia nel distruttore
class Lock {
public:
    explicit Lock(const char* nome) : nome_{nome} { cout << "  lock(" << nome_ << ")\n"; }
    ~Lock() { cout << "  unlock(" << nome_ << ")\n"; }
    Lock(const Lock&) = delete;                  // un lock non si copia
    Lock& operator=(const Lock&) = delete;
private:
    const char* nome_;
};

int funzione(int x)
{
    Lock a("A");                                 // acquisito qui
    if (x == 0) {
        cout << "  uscita anticipata (x == 0)\n";
        return -1;                               // il distruttore di a e' chiamato comunque
    }
    {
        Lock b("B");                             // blocco interno
        cout << "  lavoro dentro il blocco interno\n";
    }                                            // unlock(B) alla chiusura del blocco
    cout << "  fine normale\n";
    return x;                                    // unlock(A) all'uscita
}

int main()
{
    cout << "funzione(5):\n";
    funzione(5);
    cout << "funzione(0):\n";
    funzione(0);
    return 0;
}
funzione(5):
  lock(A)
  lock(B)
  lavoro dentro il blocco interno
  unlock(B)
  fine normale
  unlock(A)
funzione(0):
  lock(A)
  uscita anticipata (x == 0)
  unlock(A)

La classe Lock acquisisce in lock(...) (il costruttore) e rilascia in unlock(...) (il distruttore). In funzione(5): A si rilascia in uscita, B alla chiusura del blocco interno. In funzione(0): il return anticipato salta tutto il resto, ma il distruttore di a è chiamato comunque: unlock(A) compare. Con un'eccezione lanciata dentro la funzione succederebbe lo stesso (stack unwinding): senza RAII sarebbe necessario un try/catch con unlock e throw;. Un lock che resta preso per una dimenticanza è un caso classico di deadlock.

Le tre garanzie

Quando una funzione può lanciare un'eccezione, si descrive che cosa si può contare che sia vero dopo il fallimento:

garanzia promessa
basic guarantee se la funzione lancia, non c'è nessun leak di risorse e gli oggetti restano in uno stato valido (invarianti rispettati), ma lo stato può essere cambiato rispetto a prima
strong guarantee la basic più: in caso di fallimento tutti i valori osservabili (non locali alla funzione) sono quelli di prima della chiamata: commit or rollback, tutto o niente
no-throw guarantee la funzione non lancia mai (distruttori, swap, funzioni noexcept)

La STL fornisce la basic guarantee per tutte le sue operazioni. La strong guarantee si ottiene tipicamente con il metodo copia, modifica, scambia: si lavora su una copia (se qualcosa lancia, l'originale non è stato toccato), poi si scambia con l'originale con un'operazione no-throw (swap): il commit.

garanzie.cppcpp
#include <iostream>
#include <vector>
using namespace std;

struct Fallisce {};                                       // tipo che segnala il fallimento

void operazione_rischiosa(int i, int fallisci_a)          // lancia quando i == fallisci_a
{
    if (i == fallisci_a) throw Fallisce();
}

// BASIC GUARANTEE: se lancia, nessun leak e v e' valido, ma puo' essere stato modificato a meta'
void aggiungi_basic(vector<int>& v, int n, int fallisci_a)
{
    for (int i = 0; i < n; ++i) {
        operazione_rischiosa(i, fallisci_a);
        v.push_back(i * 10);                              // ogni push_back e' gia' "committato"
    }
}

// STRONG GUARANTEE (commit or rollback): se lancia, v e' identico a prima della chiamata
void aggiungi_strong(vector<int>& v, int n, int fallisci_a)
{
    vector<int> copia = v;                                // si lavora su una COPIA
    for (int i = 0; i < n; ++i) {
        operazione_rischiosa(i, fallisci_a);
        copia.push_back(i * 10);
    }
    v.swap(copia);                                        // commit: lo swap non lancia (no-throw)
}

void stampa(const char* nome, const vector<int>& v)
{
    cout << nome << " (size " << v.size() << "):";
    for (int x : v) cout << ' ' << x;
    cout << '\n';
}

int main()
{
    vector<int> v1 {1, 2};
    try { aggiungi_basic(v1, 5, 3); }
    catch (Fallisce&) { cout << "basic: eccezione al passo 3\n"; }
    stampa("v1 dopo basic ", v1);

    vector<int> v2 {1, 2};
    try { aggiungi_strong(v2, 5, 3); }
    catch (Fallisce&) { cout << "strong: eccezione al passo 3\n"; }
    stampa("v2 dopo strong", v2);

    vector<int> v3 {1, 2};
    aggiungi_strong(v3, 5, -1);                           // senza fallimenti: il commit avviene
    stampa("v3 senza errori", v3);
    return 0;
}

Uscita (il programma usa try/catch: in WebAssembly le eccezioni non funzionano, quindi è compilato per Linux e l'uscita è ricavata a mano dalle regole del linguaggio):

basic: eccezione al passo 3
v1 dopo basic  (size 5): 1 2 0 10 20
strong: eccezione al passo 3
v2 dopo strong (size 2): 1 2
v3 senza errori (size 7): 1 2 0 10 20 30 40
  • aggiungi_basic: aggiunge elementi uno alla volta; quando al passo 3 lancia, v1 contiene già 0,10,200, 10, 20 (valido ma cambiato: basic guarantee, nessun leak).
  • aggiungi_strong: lavora su copia; al passo 3 lancia prima dello swap, quindi v2 è identico a prima (1 2): strong guarantee. Il commit (v.swap(copia)) è no-throw. Senza errori (v3) lo scambio avviene e v3 ha tutti gli elementi.
  • Il prezzo della strong è una copia in più.

Versione ripasso

cpp
class Lock { public: explicit Lock(const char* n) : nome_{n} { /* lock */ }  ~Lock() { /* unlock */ }
             Lock(const Lock&) = delete; Lock& operator=(const Lock&) = delete; private: const char* nome_; };
int funzione(int x) { Lock a("A"); if (x == 0) return -1;   /* unlock(A) comunque */ /* ... */ return x; }

Prova: funzione(0) stampa lock(A), uscita anticipata, unlock(A); funzione(5): lock(A), lock(B), unlock(B) alla chiusura del blocco, unlock(A) in uscita.

garanzia promessa
basic se lancia: nessun leak, stato valido, ma forse cambiato
strong basic + valori osservabili come prima (commit or rollback)
no-throw non lancia mai (distruttori, swap, noexcept)
  • Strong con copia, modifica, scambia: vector<int> copia = v; /* modifiche su copia, possono lanciare */ v.swap(copia); (commit no-throw). Esempio: aggiungi_basic che lancia al passo 3 lascia v1 = 1 2 0 10 20 (valido, cambiato); aggiungi_strong lascia v2 = 1 2 (invariato); senza errori v3 = 1 2 0 10 20 30 40. La STL dà la basic.
  • Errori tipici: catch che non rilancia; distruttore che lancia; confondere basic e strong (la strong richiede lo stato invariato); acquisire la risorsa fuori dal costruttore; credere che serva try/catch con gli smart pointer.

Esercizi su questo argomento

Teoria collegata