Esercizio - RAII e garanzie sulle eccezioni (quarto appello 2021-22, teoria)
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
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
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.
#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 1Nel 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).
#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 solomake_vec_manuale(prima versione):new vector, riempimento dentrotry; se lancia, ilcatch (...)fadelete pe rilancia conthrow;: 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 è iltry/catchobbligatorio e il fatto che il chiamante deve ricordarsi ladelete.make_vec(seconda versione): lounique_ptrè un oggetto locale con distruttore: seriempi()lancia, lo srotolamento lo distrugge e libera ilvector; se non lancia,return plo sposta al chiamante, che non deve fare nessunadelete. Stessa garanzia, nessuntry/catch.- no-throw: tipico di distruttori,
swap, spostamenti dichiaratinoexcept. La STL fornisce la basic guarantee per tutte le sue operazioni (e la strong per alcune, comepush_backquando l'elemento non lancia in spostamento).
Versione ripasso
- Problema: una risorsa (memoria, file, lock) ha acquisizione, uso, rilascio;
returnanticipato, riassegnazione del puntatore o eccezione saltano ildelete[]. Soluzione contry/catch (...) { delete[] p; throw; }: codice duplicato, lungo, da ripetere ovunque. - RAII = acquisizione della risorsa nel costruttore, rilascio nel distruttore: l'oggetto esiste solo se la risorsa è stata acquisita; all'uscita dallo scope (fine blocco,
return, eccezione: stack unwinding) i distruttori di tutti gli oggetti locali liberano la risorsa. Vale per memoria (vector, smart pointer), file, socket, lock, buffer di I/O. Il distruttore non deve lanciare; copia deep o vietata. Rimando: 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) →. - Prova:
sospetta(10, 1)conreturnanticipato: new 1, delete 0 (leak); convector<int>ounique_ptr<int[]>: new 1, delete 1.
| garanzia | promessa |
|---|---|
| basic | se lancia nessun leak e stato valido |
| strong | basic + valori osservabili invariati (commit or rollback) |
| no-throw | non lancia mai |
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 ildelete[]sepè riassegnato (if (x) p = q;), se c'è unreturnanticipato (if (x) return;) o se un'istruzione lancia (p[x] = v.at(x);); convector<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);vectoreunique_ptr<int[]>con return anticipato: new 1, delete 1. Ilreturnanticipato e l'eccezione si comportano allo stesso modo per i distruttori dei locali. - Errori tipici: liberare solo nel ramo senza errori;
catchche non rilancia (throw;mancante); distruttore che lancia; confondere basic e strong (la strong richiede lo stato invariato); credere che serva iltry/catchcon gli smart pointer. - Uscita di
raii_make_vec(ricavata a mano: usatry/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.