Salta al contenuto
Note per Studenti Esercizio - RAII e garanzie sulle eccezioni (quarto appello 2021-22, teoria)

Esercizio - RAII e garanzie sulle eccezioni (quarto appello 2021-22, teoria)

Esame
In questa pagina 3

Domanda aperta 3 del quarto appello 2021-22 (12 settembre 2022) del corso di Laboratorio di Programmazione, Ing. Informatica UniPD. La stessa domanda è ricomparsa nel terzo appello 2022-23: Esercizio - RAII e garanzie sulle eccezioni (terzo appello 2022-23, teoria). 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 →, 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 la tecnica RAII e come viene utilizzata nei costruttori e distruttori di una classe costruiti opportunamente. Descrivere i concetti di basic guarantee, strong guarantee, no-throw guarantee.

Il problema

Una risorsa (memoria, file, lock, thread, socket) ha un ciclo acquisizione, uso, rilascio. Le eccezioni possono saltare il rilascio. In

cpp
void suspicious(int s, int x)
{
    int* p = new int[s];
    // ...
    if (x) return;                 // il flusso NON arriva al delete[]
    // ... un'eccezione, per esempio da v.at(x), salta il delete[] allo stesso modo
    delete[] p;
}

la memoria si perde in almeno tre modi (p riassegnato, return anticipato, eccezione): è un problema di gestione delle risorse, non solo di eccezioni. Si può aggiungere try { ... } catch (...) { delete[] p; throw; }, ma il codice si duplica, si allunga e va ripetuto a ogni accesso e a ogni allocazione: difficile da leggere e da mantenere.

RAII: Resource Acquisition Is Initialization

RAII. Tecnica con cui si acquisisce la risorsa nel costruttore di un oggetto e la si rilascia nel suo distruttore. L'oggetto è creato solo se è stato possibile acquisire la risorsa; quando il flusso di esecuzione lascia uno scope, il linguaggio invoca i distruttori di tutti gli oggetti locali e dei loro sotto-oggetti, in qualunque modo si esca: fine del blocco, return, break, eccezione (stack unwinding).

È un'idea generale: vale per memoria (vector, string, smart pointer), file, socket, lock, buffer di I/O. Il codice di rilascio sta in un solo posto, il distruttore, scritto una volta.

raii_return.cppcpp
#include <cstdlib>
#include <iostream>
#include <memory>
#include <new>
#include <vector>
using namespace std;

static int n_new = 0, n_delete = 0;                  // contano i blocchi allocati e liberati
void* operator new(size_t s)  { ++n_new; return malloc(s); }
void* operator new[](size_t s) { ++n_new; return malloc(s); }
void operator delete(void* p) noexcept { if (p) ++n_delete; free(p); }
void operator delete[](void* p) noexcept { if (p) ++n_delete; free(p); }
void operator delete(void* p, size_t) noexcept { if (p) ++n_delete; free(p); }
void operator delete[](void* p, size_t) noexcept { if (p) ++n_delete; free(p); }

// Gestione MANUALE: il flusso puo' non arrivare al delete[] (return anticipato; lo stesso vale per un'eccezione)
void sospetta(int s, int x)
{
    int* p = new int[s];
    p[0] = 1;
    if (x) return;                                   // uscita anticipata: il delete[] e' saltato
    delete[] p;
}

// Gestione RAII: la memoria e' acquisita nel costruttore dell'oggetto e liberata dal suo distruttore
void sicura_vector(int s, int x)
{
    vector<int> v(s);                                // il buffer appartiene al vector
    v[0] = 1;
    if (x) return;                                   // all'uscita dallo scope il distruttore di v libera il buffer
}

void sicura_unique(int s, int x)
{
    unique_ptr<int[]> p(new int[s]);                 // lo smart pointer e' un oggetto con distruttore
    p[0] = 1;
    if (x) return;
}

int main()
{
    int a = n_new, d = n_delete;
    sospetta(10, 0);
    cout << "sospetta, senza return anticipato: new " << n_new - a << ", delete " << n_delete - d << '\n';
    a = n_new; d = n_delete;
    sospetta(10, 1);
    cout << "sospetta, con return anticipato:   new " << n_new - a << ", delete " << n_delete - d << "  <- LEAK\n";
    a = n_new; d = n_delete;
    sicura_vector(10, 1);
    cout << "vector, con return anticipato:     new " << n_new - a << ", delete " << n_delete - d << '\n';
    a = n_new; d = n_delete;
    sicura_unique(10, 1);
    cout << "unique_ptr, con return anticipato: new " << n_new - a << ", delete " << n_delete - d << '\n';
    return 0;
}
sospetta, senza return anticipato: new 1, delete 1
sospetta, con return anticipato:   new 1, delete 0  <- LEAK
vector, con return anticipato:     new 1, delete 1
unique_ptr, con return anticipato: new 1, delete 1

Nel programma, sospetta con return anticipato perde il blocco (new 1, delete 0); le versioni RAII con vector<int> e con unique_ptr<int[]> lo liberano comunque. L'uscita anticipata con return e quella per eccezione si comportano allo stesso modo rispetto ai distruttori degli oggetti locali (srotolamento dello stack), per cui le due versioni RAII sono sicure anche quando riempi() lancia.

Come si scrive la classe. Costruttore: acquisisce (new, fopen, lock); se fallisce lancia un'eccezione e l'oggetto non esiste. Distruttore: rilascia tutto, e non deve lanciare eccezioni. Copia: deep, oppure vietata (= delete). Esempio nelle classi con new[]/delete[] di 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 →.

Le garanzie

garanzia che cosa promette la funzione
basic guarantee se lancia, non perde risorse (nessun leak) e gli oggetti restano in uno stato valido, ma i valori possono essere cambiati
strong guarantee la basic più: se fallisce, tutti i valori osservabili (non locali alla funzione) sono quelli di prima della chiamata: commit or rollback
no-throw guarantee la funzione non lancia mai eccezioni

Esempio dalle slide: make_vec() che alloca un vector<int> sull'heap e lo riempie (il riempimento può lanciare).

raii_make_vec.cppcpp
#include <iostream>
#include <memory>
#include <stdexcept>
#include <vector>
using namespace std;

// Riempie un vector con n valori; con fallisci == true lancia un'eccezione al terzo inserimento
void riempi(vector<int>& v, int n, bool fallisci)
{
    for (int i = 0; i < n; ++i) {
        if (fallisci && i == 2) throw runtime_error("riempimento fallito");
        v.push_back(i);
    }
}

// VERSIONE 1: gestione manuale con try/catch. Dà la BASIC GUARANTEE: o funziona, o lancia e non perde memoria.
vector<int>* make_vec_manuale(int n, bool fallisci)
{
    vector<int>* p = new vector<int>;
    try {
        riempi(*p, n, fallisci);
    }
    catch (...) {
        delete p;                    // libera
        throw;                       // e rilancia la stessa eccezione
    }
    return p;                        // il chiamante dovra' fare delete
}

// VERSIONE 2: RAII con unique_ptr. Nessun try/catch: se riempi() lancia, il distruttore di p libera il vector.
unique_ptr<vector<int>> make_vec(int n, bool fallisci)
{
    unique_ptr<vector<int>> p { new vector<int> };
    riempi(*p, n, fallisci);
    return p;                        // spostato al chiamante: nessuna delete da ricordare
}

int main()
{
    vector<int>* a = make_vec_manuale(4, false);
    cout << "manuale, senza errore: " << a->size() << " elementi\n";
    delete a;                        // responsabilita' del chiamante

    try {
        vector<int>* b = make_vec_manuale(4, true);
        cout << "non arrivo qui: " << b->size() << '\n';
    }
    catch (runtime_error& e) {
        cout << "manuale, con errore: eccezione \"" << e.what() << "\", nessun leak (delete p nel catch)\n";
    }

    unique_ptr<vector<int>> c = make_vec(4, false);
    cout << "unique_ptr, senza errore: " << c->size() << " elementi, nessuna delete da scrivere\n";

    try {
        unique_ptr<vector<int>> d = make_vec(4, true);
        cout << "non arrivo qui: " << d->size() << '\n';
    }
    catch (runtime_error& e) {
        cout << "unique_ptr, con errore: eccezione \"" << e.what() << "\", il vector e' stato liberato da solo\n";
    }
    return 0;
}

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

manuale, senza errore: 4 elementi
manuale, con errore: eccezione "riempimento fallito", nessun leak (delete p nel catch)
unique_ptr, senza errore: 4 elementi, nessuna delete da scrivere
unique_ptr, con errore: eccezione "riempimento fallito", il vector e' stato liberato da solo
  • make_vec_manuale (prima versione): new vector, riempimento dentro try; se lancia, il catch (...) fa delete p e rilancia con throw;: la funzione o funziona o lancia senza perdere memoria: basic guarantee. Se non ci sono istruzioni "strane" nel riempimento (che toccano dati esterni) offre anche la strong guarantee, perché il vettore parziale sparisce e il chiamante non osserva nulla. Il difetto è il try/catch obbligatorio e il fatto che il chiamante deve ricordarsi la delete.
  • make_vec (seconda versione): lo unique_ptr è un oggetto locale con distruttore: se riempi() lancia, lo srotolamento lo distrugge e libera il vector; se non lancia, return p lo sposta al chiamante, che non deve fare nessuna delete. Stessa garanzia, nessun try/catch.
  • no-throw: tipico di distruttori, swap, spostamenti dichiarati noexcept. La STL fornisce la basic guarantee per tutte le sue operazioni (e la strong per alcune, come push_back quando l'elemento non lancia in spostamento).

Versione ripasso

garanzia promessa
basic se lancia nessun leak e stato valido
strong basic + valori osservabili invariati (commit or rollback)
no-throw non lancia mai
cpp
vector<int>* make_vec_manuale(int n, bool f)       // basic (e strong se il riempimento e' "pulito")
{ vector<int>* p = new vector<int>;
  try { riempi(*p, n, f); } catch (...) { delete p; throw; }
  return p; }
unique_ptr<vector<int>> make_vec(int n, bool f)     // RAII: nessun try/catch
{ unique_ptr<vector<int>> p { new vector<int> }; riempi(*p, n, f); return p; }
  • I tre casi di suspicious(int s, int x): int* p = new int[s]; ... delete[] p; salta il delete[] se p è riassegnato (if (x) p = q;), se c'è un return anticipato (if (x) return;) o se un'istruzione lancia (p[x] = v.at(x);); con vector<int> p(s) il problema sparisce.
  • Uscita del programma di prova: sospetta, senza return anticipato: new 1, delete 1; con return anticipato: new 1, delete 0 (leak); vector e unique_ptr<int[]> con return anticipato: new 1, delete 1. Il return anticipato e l'eccezione si comportano allo stesso modo per i distruttori dei locali.
  • Errori tipici: liberare solo nel ramo senza errori; catch che non rilancia (throw; mancante); distruttore che lancia; confondere basic e strong (la strong richiede lo stato invariato); credere che serva il try/catch con gli smart pointer.
  • Uscita di raii_make_vec (ricavata a mano: usa try/catch): manuale, senza errore: 4 elementi; manuale, con errore: eccezione "riempimento fallito", nessun leak (delete p nel catch); unique_ptr, senza errore: 4 elementi, nessuna delete da scrivere; unique_ptr, con errore: eccezione "riempimento fallito", il vector e' stato liberato da solo.

Esercizi su questo argomento

Teoria collegata