Discuția despre siguranța memoriei între C++ și Rust a devenit una dintre cele mai active teme din ingineria software. Agenții guvernamentale s-au pronunțat, conferințele dedică prezentări acestui subiect, iar opiniile sunt puternice de ambele părți.

Să fiu sincer de la început: Rust este un limbaj excelent. Modelul său de ownership și borrow checker-ul sunt cu adevărat inovatoare și detectează categorii întregi de bug-uri la compilare. Dacă începeți un proiect nou și Rust se potrivește echipei și ecosistemului vostru, este o alegere excelentă.

În același timp, C++ rămâne coloana vertebrală a celui mai performant software din lume: kerneluri de sisteme de operare, motoare de jocuri, browsere, baze de date și sisteme financiare. Asta nu e întâmplător și nu pentru că acele echipe nu au auzit de Rust.

Ceea ce vreau să explorez în acest articol este un aspect care se pierde adesea în dezbatere: C++ modern (C++11 și ulterior) oferă instrumente robuste pentru scrierea de cod sigur din punct de vedere al memoriei. Pointerii inteligenți, RAII, containerele cu verificare a limitelor și o bibliotecă standard bogată abordează aceleași categorii de bug-uri pe care compilatorul Rust le impune. Cele două limbaje adoptă pur și simplu abordări de design diferite pentru aceeași problemă: impunere la nivel de compilator vs. idiomuri alese de dezvoltator.

În loc să argumentez în abstract, permiteți-mi să vă arăt ce vreau să spun cu exemple concrete, funcționale.

Disclaimer: Opiniile exprimate în acest articol sunt ale mele și se bazează pe experiența mea profesională. Toate exemplele de cod sunt simplificate intenționat în scopuri educaționale. Fiecare limbaj are compromisuri; acest articol își propune să încurajeze discuția constructivă, nu să denigreze vreun limbaj sau comunitatea sa. Rezultatele pot varia.

Exemplul 1: Pointer Suspendat (Use-After-Free)

Acesta este unul dintre cele mai frecvente bug-uri de siguranță a memoriei. Un pointer continuă să fie utilizat după ce memoria la care face referire a fost eliberată.

C++ Greșit (Practică Proastă)

 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}

Ce a mers prost: Dezvoltatorul a alocat manual memorie cu new, a eliberat-o manual cu delete, apoi a continuat să folosească pointerul brut. Acesta este un bug clasic de use-after-free. Problema nu este că C++ „a permis" acest lucru — problema este că dezvoltatorul a folosit un tipar din anii 1990 când există alternative moderne.

C++ Corect (Practică Modernă)

 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}

Ce s-a schimbat:

std::unique_ptr deține resursa. Când iese din domeniul de vizibilitate sau este resetat, memoria este eliberată și pointerul devine verificabil pentru null. Nu există pointer suspendat. Nu există comportament nedefinit. Acesta este C++ standard din 2011.

Exemplul 2: Depășire de Buffer

Citirea sau scrierea dincolo de limitele unui tablou este o altă sursă clasică de vulnerabilități.

C++ Greșit (Practică Proastă)

 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}

Ce a mers prost:

Dezvoltatorul a folosit o funcție din era C (strcpy) cu un tablou C de dimensiune fixă și fără nicio verificare a limitelor. Acesta este genul de cod care dă o reputație proastă C++, dar nu este C++ modern. Este cod C compilat cu un compilator C++.

C++ Corect (Practică Modernă)

 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}

Ce s-a schimbat:

std::string își gestionează propria memorie. Nu poate avea depășire de buffer. Încercarea unei operații în afara limitelor cu .at() sau .substr() cu indici invalizi aruncă o excepție ce poate fi capturată. Aceasta este în standard încă din C++98.

Exemplul 3: Acces în Afara Limitelor Tabloului

C++ Greșit (Practică Proastă)

 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}

Ce a mers prost:

Tablourile C brute nu au absolut nicio verificare a limitelor. Dezvoltatorul a accesat un index invalid și a obținut date nevalide (sau, și mai rău, o vulnerabilitate de securitate). Din nou, aceasta este o alegere a dezvoltatorului de a folosi cea mai primitivă structură de date disponibilă.

C++ Corect (Practică Modernă)

 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}

Ce s-a schimbat:

std::array este un container de dimensiune fixă care își cunoaște propria dimensiune. Metoda .at() efectuează verificarea limitelor la runtime și aruncă o excepție dacă depășiți limitele. Disponibil din C++11.

Exemplul 4: Eliberare Dublă (Double Free)

C++ Greșit (Practică Proastă)

 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}

Ce a mers prost:

Dezvoltatorul a eliberat aceeași memorie de două ori. Aceasta poate corupe starea internă a alocatorului de heap și este un vector de exploatare bine cunoscut. Este, încă o dată, o greșeală de gestionare manuală a memoriei.

C++ Corect (Practică Modernă)

 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}

Ce s-a schimbat:

std::unique_ptr garantează semantica de proprietate unică. Memoria este eliberată exact o singură dată — fie când pointerul iese din domeniul de vizibilitate, fie când se apelează reset(). Apelarea reset() pe un unique_ptr deja null este o operație sigură fără efect.

Exemplul 5: Proprietate Partajată — Greșit vs. Corect

C++ Greșit (Practică Proastă)

 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}

Ce a mers prost:

Mai multe părți ale codului partajează un pointer brut, fără o proprietate clară. Odată ce o parte șterge memoria, toate celelalte părți rămân cu un pointer suspendat.

C++ Corect (Practică Modernă)

 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}

Ce s-a schimbat:

std::shared_ptr folosește numărare de referințe pentru a gestiona proprietatea partajată. Memoria este eliberată doar când ultimul shared_ptr care o referențiază este distrus. Fără pointeri suspendați. Fără delete manual. Disponibil din C++11.

Exemplul 6: RAII — Siguranța în C++ Modern

RAII (Resource Acquisition Is Initialization) este probabil cel mai important idiom din C++. Ideea este simplă: leagă durata de viață a unei resurse de durata de viață a unui obiect. Când obiectul este creat, acesta achiziționează resursa. Când obiectul este distrus (iese din domeniul de vizibilitate), eliberează resursa. Fără curățare manuală. Fără posibilitatea de a uita.

Pointerii inteligenți sunt doar o aplicație a RAII. Acest tipar funcționează pentru orice resursă: fișiere, mutex-uri, socket-uri de rețea, conexiuni la baze de date, handle-uri GPU — orice vă puteți imagina.

C++ Greșit (Gestionare Manuală a Resurselor)

 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}

Ce a mers prost:

Dezvoltatorul a folosit fopen/fclose în stil C. Dacă o excepție este aruncată între deschidere și închidere, handle-ul fișierului este pierdut. Pe măsură ce numărul de resurse și căile de eroare cresc, închiderea fiabilă a tuturor resurselor devine un coșmar de întreținere.

C++ Corect (RAII cu 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}

Ce s-a schimbat:

std::ifstream este un wrapper RAII în jurul unui handle de fișier. Fișierul este deschis în constructor și închis în destructor — garantat, indiferent de modul în care funcția se încheie (return normal, excepție, return prematur). Zero scurgeri de resurse prin design.

RAII pentru Resurse Personalizate (Blocare Mutex)

RAII nu se limitează la biblioteca standard. Iată cum funcționează cu sincronizarea firelor de execuție:

 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}

Scrierea Propriului Wrapper RAII

Puteți aplica RAII la orice resursă. Iată un exemplu minimal:

 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}

Concluzie cheie:

RAII nu este un compromis sau un „sfat de bună practică" — este un tipar fundamental de design în C++ care face scurgerile de resurse structural imposibile atunci când este aplicat consecvent. Precede modelul de ownership din Rust și, în multe privințe, l-a inspirat.

Cum Se Compară Rust?

Borrow checker-ul din Rust detectează multe dintre aceste probleme înainte ca codul să ruleze, la compilare. Acesta este un avantaj autentic și semnificativ, care merită recunoaștere.

Iată cum aș prezenta compromisurile în mod onest:

  1. Rust impune siguranța la nivel de compilator; C++ oferă siguranță prin biblioteca standard și idiomuri. Ambele abordări sunt valide; ele reflectă filozofii de design diferite.

  2. C++ modern previne aceleași clase de bug-uri atunci când instrumentele recomandate sunt folosite. Pointerii inteligenți, RAII, std::array, std::string, std::span, std::optional — acestea nu sunt funcționalități obscure de bibliotecă. Ele sunt modalitatea recomandată de a scrie C++ și au fost de peste un deceniu.

  3. Niciunul dintre limbaje nu este o soluție magică. Cuvântul cheie unsafe din Rust există dintr-un motiv — uneori trebuie să ieși din plasa de siguranță pentru FFI, performanță sau operații de nivel scăzut. Iar bug-urile de logică, condițiile de cursă (în anumite tipare) și erorile de design există în orice limbaj.

  4. Pragmatismul contează. Miliarde de linii de C++ testat în producție rulează astăzi. Pentru multe echipe, adoptarea practicilor moderne de C++, a instrumentelor mai bune și a analizei statice este o cale mai realistă decât o rescriere completă — și aduce îmbunătățiri reale de siguranță.

Ce Putem Spune Despre Instrumente?

Dezvoltatorii moderni de C++ au acces la instrumente puternice de siguranță care detectează probleme chiar dincolo de ceea ce impune limbajul în sine:

InstrumentScop
AddressSanitizer (ASan)Detectează use-after-free, depășire de buffer, stack overflow
MemorySanitizer (MSan)Detectează citirea memoriei neinițializate
UndefinedBehaviorSanitizer (UBSan)Detectează overflow cu semn, dereferențiere null și altele
ValgrindDetectarea scurgerilor de memorie și profilare
Clang-TidyAnaliză statică cu sugestii de modernizare
C++ Core GuidelinesGhiduri standard din industrie pentru C++ sigur și modern

Compilarea exemplelor „greșite" din acest articol cu -fsanitize=address ar semnala imediat problemele la runtime. Instrumentele există — întrebarea este dacă dezvoltatorii le folosesc.

Concluzia

C++ și Rust abordează siguranța memoriei din unghiuri diferite:

  • Rust face siguranța implicită și te obligă să renunți explicit la ea cu unsafe.
  • C++ oferă dezvoltatorului control total și asigură siguranța prin biblioteca standard, RAII și idiomuri bine stabilite.

Cele mai multe probleme de siguranță a memoriei din bazele de cod C++ provin din utilizarea tiparelor depășite — new/delete brut, tablouri în stil C, funcții precum strcpy — în loc de instrumentele moderne pe care limbajul le oferă de peste un deceniu.

Vestea bună este că drumul înainte pentru C++ este clar și bine documentat:

  • Folosiți pointeri inteligenți (std::unique_ptr, std::shared_ptr) în loc de new/delete brut
  • Folosiți containere standard (std::string, std::vector, std::array) în loc de buffere în stil C
  • Adoptați RAII pentru toată gestionarea resurselor
  • Utilizați sanitizere (ASan, MSan, UBSan) și analiză statică în pipeline-urile CI
  • Urmați C++ Core Guidelines

Ambele limbaje sunt instrumente puternice, și ambele au un loc în dezvoltarea software modernă. Cea mai bună alegere depinde de proiectul vostru, echipa voastră și constrângerile voastre — nu de dezbateri pe internet.

Dacă ați găsit acest articol util sau provocator, nu ezitați să îl distribuiți. Și dacă aveți o perspectivă diferită, mi-ar plăcea să o aud în secțiunea de comentarii de mai jos. Cele mai bune discuții de inginerie au loc când argumentăm cu cod, nu doar cu opinii.