La discussione sulla sicurezza della memoria tra C++ e Rust è diventata uno degli argomenti più dibattuti nell’ingegneria del software. Le agenzie governative hanno espresso la loro posizione, intere conferenze vi dedicano interventi, e le opinioni sono forti da entrambe le parti.
Permettetemi di essere diretto: Rust è un linguaggio eccellente. Il suo modello di ownership e il borrow checker sono genuinamente innovativi, e intercettano intere categorie di bug a tempo di compilazione. Se state iniziando un nuovo progetto e Rust si adatta al vostro team e al vostro ecosistema, è un’ottima scelta.
Allo stesso tempo, C++ rimane la colonna portante del software più critico in termini di prestazioni al mondo: kernel di sistemi operativi, motori di gioco, browser, database e sistemi finanziari. Non è un caso, e non è perché quei team non abbiano sentito parlare di Rust.
Ciò che voglio esplorare in questo articolo è un punto che spesso si perde nel dibattito: il C++ moderno (C++11 e successivi) fornisce strumenti robusti per scrivere codice sicuro dal punto di vista della memoria. Smart pointer, RAII, container con controllo dei limiti e una ricca libreria standard affrontano le stesse categorie di bug che il compilatore di Rust impone. I due linguaggi adottano semplicemente approcci progettuali diversi allo stesso problema: imposizione del compilatore vs. idiomi scelti dallo sviluppatore.
Piuttosto che discutere in astratto, lasciate che vi mostri cosa intendo con esempi concreti ed eseguibili.
Disclaimer: Le opinioni espresse in questo articolo sono personali e basate sulla mia esperienza professionale. Tutti gli esempi di codice sono intenzionalmente semplificati a scopo didattico. Ogni linguaggio ha i suoi compromessi; questo articolo mira a incoraggiare una discussione ragionata, non a denigrare alcun linguaggio o la sua comunità. I risultati possono variare.
Esempio 1: Puntatore Dangling (Use-After-Free)
Questo è uno dei bug di sicurezza della memoria più comuni. Un puntatore continua a essere usato dopo che la memoria a cui punta è stata liberata.
Il C++ Sbagliato (Pratica Scorretta)
1#include <iostream>
2
3int main() {
4 int* ptr = new int(42);
5 std::cout << "Value: " << *ptr << std::endl;
6
7 // ptr is now dangling (it still holds the address of freed memory)
8 delete ptr;
9
10 // Undefined behavior: reading freed memory
11 std::cout << "After delete: " << *ptr << std::endl;
12
13 return 0;
14}
Cosa è andato storto:
Lo sviluppatore ha allocato manualmente la memoria con new, l’ha liberata manualmente con delete, e poi ha continuato a usare il puntatore raw. Questo è un classico bug use-after-free da manuale. Il problema non è che C++ “abbia permesso” che ciò accadesse; il problema è che lo sviluppatore ha usato un pattern degli anni ‘90 quando esistono alternative moderne.
Il C++ Corretto (Pratica Moderna)
1#include <iostream>
2#include <memory>
3
4int main() {
5 auto ptr = std::make_unique<int>(42);
6 std::cout << "Value: " << *ptr << std::endl;
7
8 // Explicitly releases the memory
9 ptr.reset();
10
11 if (!ptr) {
12 std::cout << "Pointer is null, safe to handle." << std::endl;
13 }
14
15 return 0;
16}
Cosa è cambiato:
std::unique_ptr possiede la risorsa. Quando esce dallo scope o viene resettato, la memoria viene liberata e il puntatore diventa verificabile come null. Non ci sono puntatori dangling. Nessun comportamento indefinito. Questo è C++ standard dal 2011.
Esempio 2: Buffer Overflow
Leggere o scrivere oltre i limiti di un array è un’altra fonte classica di vulnerabilità.
Il C++ Sbagliato (Pratica Scorretta)
1#include <iostream>
2#include <cstring>
3
4int main() {
5 char buffer[10];
6
7 // Copying more data than the buffer can hold
8 const char* source = "This string is way too long for the buffer";
9 std::strcpy(buffer, source);
10
11 std::cout << "Buffer: " << buffer << std::endl;
12 return 0;
13}
Cosa è andato storto:
Lo sviluppatore ha usato una funzione dell’era C (strcpy) con un array C a dimensione fissa e zero controlli sui limiti. Questo è il tipo di codice che dà una cattiva reputazione al C++, ma non è C++ moderno. È codice C compilato con un compilatore C++.
Il C++ Corretto (Pratica Moderna)
1#include <iostream>
2#include <string>
3
4int main() {
5 std::string buffer;
6
7 // std::string manages its own memory and grows as needed
8 buffer = "This string is way too long for a fixed buffer, but std::string handles it.";
9
10 std::cout << "Buffer: " << buffer << std::endl;
11
12 // Need a substring? Bounds-checked:
13 try {
14 std::string sub = buffer.substr(0, 10);
15 std::cout << "Substring: " << sub << std::endl;
16
17 // This would throw std::out_of_range:
18 // std::string bad_sub = buffer.substr(9999, 10);
19 } catch (const std::out_of_range& e) {
20 std::cout << "Caught: " << e.what() << std::endl;
21 }
22
23 return 0;
24}
Cosa è cambiato:
std::string gestisce la propria memoria. Non può andare in overflow. Tentare un’operazione fuori dai limiti con .at() o .substr() con indici non validi lancia un’eccezione intercettabile. Questa funzionalità è nello standard dal C++98.
Esempio 3: Accesso Fuori dai Limiti di un Array
Il C++ Sbagliato (Pratica Scorretta)
1#include <iostream>
2
3int main() {
4 int arr[5] = {10, 20, 30, 40, 50};
5
6 // Accessing index 10 (way out of bounds)
7 std::cout << "arr[10] = " << arr[10] << std::endl;
8
9 return 0;
10}
Cosa è andato storto:
Gli array C raw non hanno alcun controllo sui limiti. Lo sviluppatore ha acceduto a un indice non valido e ha ottenuto dati spazzatura (o peggio, una vulnerabilità di sicurezza). Ancora una volta, è una scelta dello sviluppatore usare la struttura dati più primitiva disponibile.
Il C++ Corretto (Pratica Moderna)
1#include <iostream>
2#include <array>
3#include <stdexcept>
4
5int main() {
6 std::array<int, 5> arr = {10, 20, 30, 40, 50};
7
8 try {
9 // .at() performs bounds checking and throws on invalid access
10 std::cout << "arr.at(2) = " << arr.at(2) << std::endl;
11 std::cout << "arr.at(10) = " << arr.at(10) << std::endl;
12 } catch (const std::out_of_range& e) {
13 std::cout << "Caught out-of-range: " << e.what() << std::endl;
14 }
15
16 return 0;
17}
Cosa è cambiato:
std::array è un container a dimensione fissa che conosce la propria dimensione. Il metodo .at() esegue un controllo sui limiti a runtime e lancia un’eccezione se si superano i limiti. Disponibile dal C++11.
Esempio 4: Double Free
Il C++ Sbagliato (Pratica Scorretta)
1#include <iostream>
2
3int main() {
4 int* ptr = new int(100);
5 std::cout << "Value: " << *ptr << std::endl;
6
7 delete ptr;
8 // Double free (undefined behavior, potential crash or exploit)
9 delete ptr;
10
11 return 0;
12}
Cosa è andato storto:
Lo sviluppatore ha liberato la stessa memoria due volte. Questo può corrompere lo stato interno dell’allocatore heap ed è un vettore di attacco ben noto. È, ancora una volta, un errore di gestione manuale della memoria.
Il C++ Corretto (Pratica Moderna)
1#include <iostream>
2#include <memory>
3
4int main() {
5 auto ptr = std::make_unique<int>(100);
6 std::cout << "Value: " << *ptr << std::endl;
7
8 // Memory is freed exactly once when ptr goes out of scope.
9 // You literally cannot double-free with unique_ptr.
10 // Calling reset() is also safe:
11 ptr.reset();
12 // No-op. No crash. No UB.
13 ptr.reset();
14
15 std::cout << "Done safely." << std::endl;
16 return 0;
17}
Cosa è cambiato:
std::unique_ptr garantisce la semantica di proprietà singola. La memoria viene liberata esattamente una volta: o quando il puntatore esce dallo scope o quando viene chiamato reset(). Chiamare reset() su un unique_ptr già nullo è un’operazione sicura senza effetti.
Esempio 5: Proprietà Condivisa Fatta Male vs. Fatta Bene
Il C++ Sbagliato (Pratica Scorretta)
1#include <iostream>
2
3void use(int* p) {
4 std::cout << "Using: " << *p << std::endl;
5}
6
7int main() {
8 int* shared = new int(99);
9
10 use(shared);
11
12 delete shared;
13
14 // Another function tries to use the same pointer (use-after-free)
15 use(shared);
16
17 return 0;
18}
Cosa è andato storto:
Più parti del codice condividono un puntatore raw, e non c’è una proprietà chiara. Una volta che una parte elimina la memoria, tutte le altre restano con un puntatore dangling.
Il C++ Corretto (Pratica Moderna)
1#include <iostream>
2#include <memory>
3
4void use(std::shared_ptr<int> p) {
5 std::cout << "Using: " << *p
6 << " (ref count: " << p.use_count() << ")" << std::endl;
7}
8
9int main() {
10 auto shared = std::make_shared<int>(99);
11
12 use(shared);
13 use(shared);
14
15 // Memory freed automatically when the last shared_ptr goes out of scope.
16 std::cout << "Final ref count: " << shared.use_count() << std::endl;
17
18 return 0;
19}
Cosa è cambiato:
std::shared_ptr usa il conteggio dei riferimenti per gestire la proprietà condivisa. La memoria viene liberata solo quando l’ultimo shared_ptr che la referenzia viene distrutto. Nessun puntatore dangling. Nessun delete manuale. Disponibile dal C++11.
Esempio 6: RAII - Sicurezza del C++ Moderno
RAII (Resource Acquisition Is Initialization) è forse il singolo idioma più importante in C++. L’idea è semplice: legare il ciclo di vita di una risorsa al ciclo di vita di un oggetto. Quando l’oggetto viene creato, acquisisce la risorsa. Quando l’oggetto viene distrutto (esce dallo scope), rilascia la risorsa. Nessuna pulizia manuale. Nessuna possibilità di dimenticare.
Gli smart pointer sono solo un’applicazione di RAII. Il pattern funziona per qualsiasi risorsa: file, mutex, socket di rete, connessioni a database, handle GPU — qualsiasi cosa.
Il C++ Sbagliato (Gestione Manuale delle Risorse)
1#include <iostream>
2#include <fstream>
3#include <stdexcept>
4
5void process_file(const std::string& filename) {
6 FILE* f = fopen(filename.c_str(), "r");
7 if (!f) {
8 throw std::runtime_error("Cannot open file");
9 }
10
11 char buf[256];
12 if (!fgets(buf, sizeof(buf), f)) {
13 // Oops - if we throw or return here, we leak the file handle
14 throw std::runtime_error("Cannot read file");
15 }
16
17 // If any exception is thrown above, fclose is never called
18 fclose(f);
19 std::cout << "Read: " << buf << std::endl;
20}
21
22int main() {
23 try {
24 process_file("example.txt");
25 } catch (const std::exception& e) {
26 std::cerr << "Error: " << e.what() << std::endl;
27 }
28 return 0;
29}
Cosa è andato storto:
Lo sviluppatore ha usato fopen/fclose in stile C. Se viene lanciata un’eccezione tra l’apertura e la chiusura, l’handle del file viene perso. Man mano che il numero di risorse e percorsi d’errore cresce, chiudere tutto in modo affidabile diventa un incubo di manutenzione.
Il C++ Corretto (RAII con std::ifstream)
1#include <iostream>
2#include <fstream>
3#include <string>
4
5void process_file(const std::string& filename) {
6 std::ifstream file(filename);
7 if (!file.is_open()) {
8 throw std::runtime_error("Cannot open file");
9 }
10
11 std::string line;
12 if (!std::getline(file, line)) {
13 throw std::runtime_error("Cannot read file");
14 }
15
16 // No need to close (std::ifstream closes automatically when it goes out of scope, even if an exception is thrown)
17 std::cout << "Read: " << line << std::endl;
18}
19
20int main() {
21 try {
22 process_file("example.txt");
23 } catch (const std::exception& e) {
24 std::cerr << "Error: " << e.what() << std::endl;
25 }
26 return 0;
27}
Cosa è cambiato:
std::ifstream è un wrapper RAII attorno a un handle di file. Il file viene aperto nel costruttore e chiuso nel distruttore in modo garantito, indipendentemente da come la funzione termina (ritorno normale, eccezione, ritorno anticipato). Zero perdite di risorse per progettazione.
RAII per Risorse Personalizzate (Blocco Mutex)
RAII non si limita alla libreria standard. Ecco come funziona con la sincronizzazione dei thread:
1#include <iostream>
2#include <mutex>
3#include <thread>
4#include <vector>
5
6std::mutex mtx;
7int shared_counter = 0;
8
9void increment(int times) {
10 for (int i = 0; i < times; ++i) {
11 // std::lock_guard locks the mutex on construction,
12 // unlocks it on destruction (end of scope, even if an exception occurs)
13 std::lock_guard<std::mutex> lock(mtx);
14 ++shared_counter;
15 }
16}
17
18int main() {
19 std::vector<std::thread> threads;
20 for (int i = 0; i < 4; ++i) {
21 threads.emplace_back(increment, 10000);
22 }
23
24 for (auto& t : threads) {
25 t.join();
26 }
27
28 // Always prints 40000 (no data race, no forgotten unlock)
29 std::cout << "Counter: " << shared_counter << std::endl;
30
31 return 0;
32}
Scrivere il Proprio Wrapper RAII
Potete applicare RAII a qualsiasi risorsa. Ecco un esempio minimale:
1#include <iostream>
2#include <stdexcept>
3
4// A generic RAII wrapper (acquires in constructor, releases in destructor)
5class SocketHandle {
6 int fd_;
7public:
8 explicit SocketHandle(int fd) : fd_(fd) {
9 if (fd_ < 0) throw std::runtime_error("Invalid socket");
10 std::cout << "Socket " << fd_ << " acquired." << std::endl;
11 }
12
13 ~SocketHandle() {
14 if (fd_ >= 0) {
15 std::cout << "Socket " << fd_ << " released." << std::endl;
16 fd_ = -1;
17 }
18 }
19
20 // Prevent copying (single ownership, like unique_ptr)
21 SocketHandle(const SocketHandle&) = delete;
22 SocketHandle& operator=(const SocketHandle&) = delete;
23
24 // Allow moving
25 SocketHandle(SocketHandle&& other) noexcept : fd_(other.fd_) {
26 other.fd_ = -1;
27 }
28
29 int get() const { return fd_; }
30};
31
32void do_network_stuff() {
33 SocketHandle sock(42); // "Acquired"
34 std::cout << "Using socket " << sock.get() << std::endl;
35 // Even if an exception were thrown here, the socket is released.
36} // sock goes out of scope (destructor called, socket released)
37
38int main() {
39 do_network_stuff();
40 std::cout << "Back in main - no resource leak." << std::endl;
41 return 0;
42}
Punto chiave:
RAII non è un workaround né un “consiglio di best practice” — è un pattern di progettazione fondamentale del C++ che rende le perdite di risorse strutturalmente impossibili quando applicato in modo coerente. Precede il modello di ownership di Rust e, per molti aspetti, lo ha ispirato.
Come si Confronta Rust?
Il borrow checker di Rust intercetta molti di questi problemi prima ancora che il codice venga eseguito, a tempo di compilazione. Questo è un vantaggio genuino e significativo, e merita riconoscimento.
Ecco come inquadrerei i compromessi in modo onesto:
Rust impone la sicurezza a livello di compilatore; C++ fornisce la sicurezza attraverso la sua libreria standard e i suoi idiomi. Entrambi gli approcci sono validi; riflettono filosofie progettuali diverse.
Il C++ moderno previene le stesse classi di bug quando vengono utilizzati gli strumenti raccomandati. Smart pointer, RAII,
std::array,std::string,std::span,std::optional— non sono funzionalità oscure della libreria. Sono il modo raccomandato di scrivere C++ e lo sono da ben oltre un decennio.Nessuno dei due linguaggi è una soluzione universale. La parola chiave
unsafedi Rust esiste per un motivo: a volte è necessario uscire dalla rete di sicurezza per FFI, prestazioni o operazioni a basso livello. E bug logici, race condition (in alcuni pattern) ed errori di progettazione esistono in ogni linguaggio.Il pragmatismo conta. Miliardi di righe di codice C++ collaudato in battaglia sono in produzione oggi. Per molti team, adottare pratiche C++ moderne, strumenti migliori e analisi statica è un percorso più realistico di una riscrittura completa — e offre miglioramenti concreti in termini di sicurezza.
E Gli Strumenti?
Gli sviluppatori C++ moderni hanno accesso a potenti strumenti di sicurezza che individuano problemi anche oltre ciò che il linguaggio stesso impone:
| Strumento | Scopo |
|---|---|
| AddressSanitizer (ASan) | Rileva use-after-free, buffer overflow, stack overflow |
| MemorySanitizer (MSan) | Rileva letture di memoria non inizializzata |
| UndefinedBehaviorSanitizer (UBSan) | Rileva overflow con segno, dereferenziazione null e altro |
| Valgrind | Rilevamento memory leak e profilazione |
| Clang-Tidy | Analisi statica con suggerimenti di modernizzazione |
| C++ Core Guidelines | Linee guida standard del settore per un C++ sicuro e moderno |
Compilare gli esempi “sbagliati” di questo articolo con -fsanitize=address segnalerebbe immediatamente i problemi a runtime. Gli strumenti esistono; la domanda è se gli sviluppatori li usano.
La Conclusione
C++ e Rust affrontano la sicurezza della memoria da angolazioni diverse:
- Rust rende la sicurezza il comportamento predefinito e richiede di rinunciarvi esplicitamente con
unsafe. - C++ dà allo sviluppatore il pieno controllo e fornisce la sicurezza attraverso la sua libreria standard, RAII e idiomi consolidati.
La maggior parte dei problemi di sicurezza della memoria nelle codebase C++ deriva dall’uso di pattern obsoleti — new/delete raw, array in stile C, funzioni come strcpy — piuttosto che dagli strumenti moderni che il linguaggio fornisce da oltre un decennio.
La buona notizia è che il percorso da seguire per C++ è chiaro e ben documentato:
- Usare smart pointer (
std::unique_ptr,std::shared_ptr) al posto dinew/deleteraw - Usare container standard (
std::string,std::vector,std::array) al posto di buffer in stile C - Abbracciare RAII per tutta la gestione delle risorse
- Sfruttare i sanitizer (ASan, MSan, UBSan) e l’analisi statica nelle pipeline CI
- Seguire le C++ Core Guidelines
Entrambi i linguaggi sono strumenti potenti, ed entrambi hanno un posto nello sviluppo software moderno. La scelta migliore dipende dal vostro progetto, dal vostro team e dai vostri vincoli — non dai dibattiti su internet.
Se avete trovato questo articolo utile o stimolante, sentitevi liberi di condividerlo. E se avete una prospettiva diversa, mi farebbe piacere leggerla nella sezione commenti qui sotto. Le migliori discussioni ingegneristiche nascono quando si argomenta con il codice, non solo con le opinioni.
Commenti