1. Introduzione
Se con il new/delete manuale vi siete sentiti come un sapper a cui hanno dato due fili e detto «fai attenzione», la libreria standard C++ è il momento in cui finalmente vi danno una presa di fabbrica con il fermaglio. L'idea di RAII (Resource Acquisition Is Initialization) nella libreria standard non è una «feature», ma una cultura di base: la maggior parte dei tipi è progettata per acquisire una risorsa alla creazione dell'oggetto e rilasciarla in modo garantito nel distruttore. E questo funziona non solo per la memoria — la «risorsa» può essere un file, un buffer, un lock, una connessione, un descrittore ecc.
È importante cogliere la giusta sensazione: RAII non è «scrivere distruttori per il gusto di scriverli», ma un modo per rendere il programma meno stressante. Scrivete codice per il significato (cosa fare), non per il rituale (in ogni ramo ricordarsi di delete, close(), unlock() ecc.).
2. RAII nei container: std::string e std::vector
Quando avete studiato per la prima volta std::string e std::vector, probabilmente pensavate a loro come a una «stringa comoda» e a un «array comodo». Ora li guardiamo con gli occhi di RAII: sono proprietari pronti della memoria dinamica, proprietari nel senso buono e «noiosi». Sanno allocare memoria, crescere, liberare memoria e non vi costringono a scrivere delete[] in ogni angolo del programma. Noiosi significa affidabili (nei programmi è un complimento).
«Memoria come risorsa»: chi la libera e quando
std::vector<int> mantiene internamente un buffer dinamico (heap) dove mette gli elementi. Quando il vector esce dallo scope il suo distruttore viene chiamato — e il buffer viene liberato automaticamente. È la stessa idea del nostro proprietario RAII artigianale della lezione precedente, solo implementata in modo professionale e testata da milioni di programmatori che a volte dimenticano di mangiare o di chiamare delete[].
Il mini-esempio «la memoria si libera da sola» sembra banale — ed è un bene:
#include <vector>
void f() {
std::vector<int> v{1, 2, 3};
v.push_back(4);
} // v viene distrutto -> la memoria viene liberata automaticamente
Un punto psicologico importante: non vedete il delete[], ma «c'è» — è semplicemente nascosto nel distruttore di vector. Quindi lavorate ancora con l'heap, ma non siete più responsabili mentalmente di ogni singolo byte.
Return anticipato e liberazione automatica
Una delle cause più antipatiche di leak è l'«uscita anticipata». Avete già visto questo nei esempi con new int{...}. Con i container ottenete l'assicurazione RAII automaticamente: quando la funzione termina il container sarà distrutto e quindi la risorsa liberata.
#include <iostream>
#include <vector>
int sum_if_ok(bool ok) {
std::vector<int> data{10, 20, 30};
if (!ok) {
return 0; // data verrà comunque distrutto correttamente
}
return data[0] + data[1] + data[2];
}
int main() {
std::cout << sum_if_ok(false) << '\n'; // 0
std::cout << sum_if_ok(true) << '\n'; // 60
}
Non c'è una «lapide» con scritto «non dimenticare di pulire». La distruzione è legata al lifetime dell'oggetto, non alla vostra attenzione.
Copia senza double-free
Nella lezione sul double-free abbiamo visto il problema: se due puntatori puntano allo stesso indirizzo e entrambi chiamano delete, succede un disastro. Con std::vector e std::string questo non accade nell'uso normale, perché la loro copia è copia per valore. Ogni copia ha il proprio buffer, e ogni distruttore libera la propria risorsa.
Confrontiamo l'idea (senza eseguire codice UB).
Pensiero non sicuro (copia del puntatore = due «proprietari» dello stesso indirizzo):
int main() {
int* p = new int{1};
int* q = p; // q e p puntano allo stesso indirizzo (due "proprietari" nella testa del principiante)
delete p;
// delete q; // UB: double-free (non lo eseguiamo)
q = nullptr;
}
E invece con vector la copia si comporta in modo sensato:
#include <iostream>
#include <vector>
int main() {
std::vector<int> a{1, 2, 3};
std::vector<int> b = a; // copia dei valori (non copia dell'"indirizzo del buffer")
b[0] = 100;
std::cout << a[0] << '\n'; // 1
std::cout << b[0] << '\n'; // 100
}
Sì, copiare un vector può essere costoso (si copiano gli elementi). Ma nel contesto di RAII è un prezzo onesto per una semantica di proprietà chiara. Più avanti discuteremo come evitare copie non necessarie; ora è più importante che la copia dei container non trasformi il programma in un campo minato.
Mini-schema: come RAII «tiene» la memoria del container
flowchart TD
A["Creato vector/string"] --> B["Allocazione memoria interna (heap)"]
B --> C["Operiamo sui dati"]
C --> D["Uscita dallo scope (}) o return"]
D --> E["Distruttore di vector/string"]
E --> F["Liberazione della memoria automaticamente"]
Il senso dello schema è che la risorsa (memoria) è «attaccata» all'oggetto (container), non a una serie di azioni manuali sparse nella funzione.
3. RAII negli stream: std::stringstream e stream di file
La parola «stream» per i principianti spesso evoca associazioni strane: qualcuno pensa a «thread di esecuzione», qualcuno a «flussi d'acqua» (e questo è più vicino alla verità di quanto sembri). Nella libreria standard uno stream è un oggetto attraverso cui scorre I/O, e l'oggetto di solito possiede una risorsa: un buffer, un descrittore di file, lo stato di formattazione. Per questo motivo gli stream sono un ottimo esempio di RAII: liberano correttamente ciò che possiedono quando escono dallo scope.
std::stringstream: buffer come risorsa
std::stringstream l'avete già incontrato nella parsing: conserva internamente una stringa, permette di scriverci con <<, poi leggere con >> o ottenere il risultato con .str(). Dentro ha un buffer (memoria), e anche questo buffer viene liberato automaticamente quando lo stringstream viene distrutto.
Uso pratico: è comodo costruire stringhe per log/messaggi senza concatenare tutto manualmente con +.
#include <iostream>
#include <sstream>
#include <string>
std::string make_log_line(int id, const std::string& text) {
std::stringstream ss;
ss << "task#" << id << ": " << text;
return ss.str();
}
int main() {
std::cout << make_log_line(7, "buy milk") << '\n'; // task#7: buy milk
}
Notate: ritorniamo std::string per valore, ed è normale. La stringa è un tipo proprietario, e la sua memoria sarà gestita automaticamente.
Stream di file: «apri e si chiude da solo»
Il lavoro completo con file lo vedremo in un'altra lezione, qui prendiamo solo un'idea: un oggetto stream di file (per esempio, std::ofstream o std::ifstream) di solito chiude il file nel suo distruttore. Quindi anche se dimenticate di chiamare .close(), alla fine dello scope lo stream rilascerà la risorsa.
Questo è uno dei motivi per cui in C++ si tende a «avvolgere le risorse in oggetti»: chiudere il file in un unico punto è più semplice che ricordarsi di chiamare close() in ogni ramo.
Mini-demonstration (senza gestire errori):
#include <fstream>
void write_demo() {
std::ofstream out("demo.txt");
out << "Hello!\n";
} // out viene distrutto -> il file viene chiuso automaticamente
Anche se per ora non scriverete sempre questo codice negli esercizi, è importante fissare in testa: gli stream sono oggetti RAII, «tengono la risorsa» e la «rilasciano» da soli.
4. RAII nei lock: std::mutex e std::lock_guard
Con i lock c'è un paradosso divertente: appena li si spiega «seriamente», ai principianti viene voglia di scappare. Ma se non li spieghi affatto, i principianti scrivono codice che a volte funziona e a volte... vive di vita propria. Quindi faremo esattamente ciò che serve per il tema RAII: guardare al lock come a una risorsa e vedere come RAII evita i dimenticati unlock().
Attenzione: non stiamo ancora costruendo programmi multithread né discutendo data race. Ora ci interessa solo la meccanica «prendi la risorsa — rilasciala in modo garantito».
Perché il manuale lock()/unlock() si rompe facilmente
Immaginate di avere un mutex e di bloccarlo manualmente, poi di fare un'uscita anticipata. Errore molto umano: avete aggiunto un return per chiarezza e vi siete dimenticati che ora serve anche un unlock().
#include <mutex>
std::mutex g_m;
void bad(bool ok) {
g_m.lock();
if (!ok) {
return; // il mutex resterà bloccato (catastrofe logica)
}
g_m.unlock();
}
Anche senza thread si vede: in questa funzione esiste un percorso in cui unlock() non viene eseguito. Nel mondo multithread questo diventa un deadlock che «succede ogni tanto», «sul PC del team lead non si riproduce» e «stranamente il venerdì sera succede sempre».
std::lock_guard: «blocca nel costruttore, sblocca nel distruttore»
std::lock_guard è un piccolo oggetto guardiano. Prende il mutex nel costruttore (blocca) e lo rilascia nel distruttore (sblocca). È il modello RAII letterale: la risorsa è il «diritto di possedere il lock».
#include <mutex>
std::mutex g_m;
void good(bool ok) {
std::lock_guard<std::mutex> guard(g_m);
if (!ok) {
return; // guard verrà distrutto -> il mutex verrà sbloccato automaticamente
}
// ... sezione critica ...
}
L'idea è così fondamentale che nei documenti e nelle discussioni della libreria standard emergono regolarmente formulazioni e requisiti attorno ai tipi di locking e alla loro interfaccia.
I confini della sezione critica sono dati da {}
È utile smettere di pensare «ho chiamato lock/unlock» e iniziare a pensare «ho creato l'oggetto guard — la sezione è iniziata; guard è stato distrutto — la sezione è finita». Allora i confini della zona protetta diventano visibili come gli indent nel codice.
Un pattern pulito usando un blocco separato:
#include <iostream>
#include <mutex>
std::mutex g_out;
void print_line(const std::string& s) {
{
std::lock_guard<std::mutex> guard(g_out);
std::cout << s << '\n';
} // guard viene distrutto -> il mutex viene rilasciato
}
Sì, in codice single-thread sembra «indossare l'elmetto per andare al negozio». Ma nella lezione su RAII il principio è importante: se il rilascio della risorsa è legato al distruttore, non dimenticherete di rilasciarla in caso di return anticipato, uscita dal blocco o qualunque struttura di codice.
5. Mini-esempio: TaskBook senza new/delete, ma con RAII
Ora assembleremo un piccolo frammento della nostra ipotetica applicazione console «TaskBook» (lista di task) che avremmo potuto sviluppare nel corso: teniamo i task in memoria, leggiamo comandi come stringhe, le parsifichiamo con stringstream, stampiamo il risultato. In questa lezione non aggiungiamo «funzionalità nuove per il gusto di farlo», ma mostriamo che quasi tutto qui è già RAII, e il controllo manuale delle risorse è superfluo.
Modello dati: task in std::vector, testo in std::string
#include <string>
#include <vector>
struct Task {
int id{};
std::string text;
};
using TaskList = std::vector<Task>;
Nessun Task*, nessun new Task[]. La memoria della lista crescerà automaticamente dentro il vector, e le stringhe conterranno il testo dentro string.
Aggiungere un task: il container gestisce la memoria
#include <string>
#include <vector>
int add_task(std::vector<Task>& tasks, const std::string& text) {
int new_id = static_cast<int>(tasks.size()) + 1;
tasks.push_back(Task{new_id, text});
return new_id;
}
Qui non è importante che sia una «generazione id perfetta» (non lo è), ma che non pensiamo affatto alla liberazione della memoria: tasks possiede i suoi elementi.
Log stringa tramite std::stringstream
#include <sstream>
#include <string>
std::string format_added(int id, const std::string& text) {
std::stringstream ss;
ss << "Added task #" << id << ": " << text;
return ss.str();
}
ss verrà distrutto — il buffer sarà liberato. Restituiamo std::string — che gestisce la memoria da sola.
Stampa con «guardiano»: lock_guard come RAII non legato alla memoria
Anche se per ora non abbiamo thread, definiamo la funzione di stampa come se potesse essere chiamata da posti diversi (o da thread diversi in futuro). È uno stile buono: la protezione della risorsa (la console) sta in un unico punto.
#include <iostream>
#include <mutex>
#include <string>
std::mutex g_cout_mutex;
void safe_println(const std::string& s) {
std::lock_guard<std::mutex> guard(g_cout_mutex);
std::cout << s << '\n';
}
Ecco come può apparire insieme:
#include <iostream>
#include <string>
#include <vector>
int main() {
std::vector<Task> tasks;
int id = add_task(tasks, "learn RAII");
safe_println(format_added(id, "learn RAII")); // Added task #1: learn RAII
}
Senso generale: abbiamo raccolto un piccolo pezzo di programma dove le risorse (memoria del container, memoria della stringa, buffer dello stringstream, lock) vengono liberate automaticamente grazie al fatto che sono avvolte in oggetti con distruttori.
6. Errori tipici usando i tipi RAII della libreria standard
Errore n.1: pensare che RAII = «niente può andare storto».
RAII garantisce il rilascio della risorsa alla distruzione dell'oggetto, ma non garantisce che stiate usando l'oggetto correttamente. Per esempio, std::vector non vi proteggerà da un errore logico del tipo «ho messo l'elemento nel posto sbagliato», e uno stream non vi proteggerà dal «leggere in un formato sbagliato». RAII è la cintura di sicurezza, non l'autopilota.
Errore n.2: conservare puntatori/riferimenti «long-lived» alle parti interne di vector/string.
Una trappola comune: prendere Task* p = &tasks[0]; e conservarlo da qualche parte, poi fare tasks.push_back(). vector potrebbe riallocare la memoria e il vostro p punterà nel nulla. Questo non annulla RAII, è solo un'altra categoria di problemi — il lifetime delle «riferimenti» agli elementi di un container. In pratica si evita di mantenere tali indirizzi a lungo o si conservano indici/ID.
Errore n.3: «gestione manuale sopra RAII»: close(), unlock(), delete[] in posti inadatti.
A volte uno sviluppatore è abituato a «per sicurezza chiudo tutto manualmente» e inizia a chiamare unlock() dove dovrebbe intervenire lock_guard, o chiude uno stream nel mezzo della funzione e poi si stupisce che ulteriori scritture non funzionano. Se scegliete un oggetto RAII, lasciate che faccia il suo lavoro: il rilascio della risorsa deve essere legato allo scope, altrimenti vi riportate ai rischi vecchi da cui volevate scappare.
Errore n.4: sezione critica troppo ampia con lock_guard.
Un altro estremo: creare il lock_guard all'inizio della funzione e tenere il mutex bloccato «per sicurezza» fino alla fine, includendo calcoli pesanti o I/O. Non è un errore di compilazione, ma architetturale. RAII è comodo perché i confini della sezione sono dati dal blocco {} — usatelo per bloccare solo la porzione necessaria, non di più.
Errore n.5: usare new[] dove già ci sono vector/string e giustificare con «è più veloce/più semplice».
Per un principiante new[] è quasi sempre più complicato: bisogna ricordarsi di delete[], non è copiabile «per valore», si può perdere l'indirizzo e causare leak. std::vector e std::string risolvono proprio questi problemi pratici. Se non sapete spiegare in modo molto chiaro perché vi serve l'heap manuale, probabilmente non vi serve ancora.
GO TO FULL VERSION