Die Diskussion über Speichersicherheit zwischen C++ und Rust ist zu einem der aktivsten Themen im Software Engineering geworden. Regierungsbehörden haben sich dazu geäußert, Konferenzvorträge widmen sich dem Thema, und die Meinungen sind auf beiden Seiten stark ausgeprägt.

Lassen Sie mich gleich offen sein: Rust ist eine hervorragende Sprache. Ihr Ownership-Modell und der Borrow Checker sind wirklich innovativ und fangen ganze Kategorien von Fehlern zur Kompilierzeit ab. Wenn Sie ein neues Projekt starten und Rust zu Ihrem Team und Ökosystem passt, ist das eine ausgezeichnete Wahl.

Gleichzeitig bleibt C++ das Rückgrat der leistungskritischsten Software der Welt: Betriebssystemkerne, Spiele-Engines, Browser, Datenbanken und Finanzsysteme. Das ist kein Zufall, und es liegt nicht daran, dass diese Teams noch nie von Rust gehört hätten.

Was ich in diesem Beitrag untersuchen möchte, ist ein Punkt, der in der Debatte oft untergeht: Modernes C++ (ab C++11) bietet robuste Werkzeuge zum Schreiben von speichersicherem Code. Smart Pointer, RAII, Container mit Bereichsprüfung und eine umfangreiche Standardbibliothek adressieren dieselben Fehlerkategorien, die Rusts Compiler erzwingt. Die beiden Sprachen verfolgen einfach unterschiedliche Designansätze für dasselbe Problem: Compiler-Erzwingung vs. vom Entwickler gewählte Idiome.

Anstatt abstrakt zu argumentieren, lassen Sie mich Ihnen anhand konkreter, ausführbarer Beispiele zeigen, was ich meine.

Haftungsausschluss: Die in diesem Artikel geäußerten Meinungen sind meine eigenen und basieren auf meiner Berufserfahrung. Alle Codebeispiele sind absichtlich für Lehrzwecke vereinfacht. Jede Sprache hat Kompromisse; dieser Beitrag soll zu einer durchdachten Diskussion anregen und nicht eine Sprache oder ihre Community herabsetzen. Ihre Erfahrungen können abweichen.

Beispiel 1: Dangling Pointer (Use-After-Free)

Dies ist einer der häufigsten Speichersicherheitsfehler. Ein Pointer wird weiter verwendet, nachdem der Speicher, auf den er zeigt, freigegeben wurde.

Das schlechte C++ (schlechte Praxis)

 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}

Was ist schiefgelaufen: Der Entwickler hat Speicher manuell mit new allokiert, ihn manuell mit delete freigegeben und dann den rohen Pointer weiter verwendet. Das ist ein klassischer Use-After-Free-Fehler. Das Problem ist nicht, dass C++ dies „zugelassen" hat – das Problem ist, dass der Entwickler ein Muster aus den 1990er Jahren verwendet hat, obwohl moderne Alternativen existieren.

Das gute C++ (moderne Praxis)

 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}

Was sich geändert hat:

std::unique_ptr besitzt die Ressource. Wenn er den Gültigkeitsbereich verlässt oder zurückgesetzt wird, wird der Speicher freigegeben und der Pointer wird auf Null prüfbar. Es gibt keinen Dangling Pointer. Kein undefiniertes Verhalten. Das ist Standard-C++ seit 2011.

Beispiel 2: Buffer Overflow

Das Lesen oder Schreiben über die Grenzen eines Arrays hinaus ist eine weitere klassische Quelle für Sicherheitslücken.

Das schlechte C++ (schlechte Praxis)

 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}

Was ist schiefgelaufen:

Der Entwickler hat eine Funktion aus der C-Ära (strcpy) mit einem C-Array fester Größe und ohne jegliche Bereichsprüfung verwendet. Das ist die Art von Code, die C++ einen schlechten Ruf gibt – aber es ist kein modernes C++. Es ist C-Code, der mit einem C++-Compiler kompiliert wurde.

Das gute C++ (moderne Praxis)

 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}

Was sich geändert hat:

std::string verwaltet seinen eigenen Speicher. Er kann nicht überlaufen. Der Versuch einer Zugriffsverletzung mit .at() oder .substr() mit ungültigen Indizes löst eine abfangbare Exception aus. Das ist seit C++98 im Standard enthalten.

Beispiel 3: Array-Zugriff außerhalb der Grenzen

Das schlechte C++ (schlechte Praxis)

 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}

Was ist schiefgelaufen:

Rohe C-Arrays haben keinerlei Bereichsprüfung. Der Entwickler hat auf einen ungültigen Index zugegriffen und Datenmüll erhalten (oder schlimmer, eine Sicherheitslücke). Auch hier ist es eine Entscheidung des Entwicklers, die primitivste verfügbare Datenstruktur zu verwenden.

Das gute C++ (moderne Praxis)

 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}

Was sich geändert hat:

std::array ist ein Container fester Größe, der seine eigene Größe kennt. Die Methode .at() führt zur Laufzeit eine Bereichsprüfung durch und löst eine Exception aus, wenn die Grenzen überschritten werden. Verfügbar seit C++11.

Beispiel 4: Double Free

Das schlechte C++ (schlechte Praxis)

 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}

Was ist schiefgelaufen:

Der Entwickler hat denselben Speicher zweimal freigegeben. Das kann den internen Zustand des Heap-Allokators beschädigen und ist ein bekannter Angriffsvektor. Es handelt sich wiederum um einen Fehler bei der manuellen Speicherverwaltung.

Das gute C++ (moderne Praxis)

 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}

Was sich geändert hat:

std::unique_ptr garantiert Einzelbesitz-Semantik. Der Speicher wird genau einmal freigegeben – entweder wenn der Pointer den Gültigkeitsbereich verlässt oder wenn reset() aufgerufen wird. Der Aufruf von reset() auf einem bereits null-gesetzten unique_ptr ist ein sicherer No-Op.

Beispiel 5: Geteilter Besitz – falsch vs. richtig

Das schlechte C++ (schlechte Praxis)

 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}

Was ist schiefgelaufen:

Mehrere Teile des Codes teilen sich einen rohen Pointer, und es gibt keinen klaren Besitz. Sobald ein Teil den Speicher freigibt, halten alle anderen Teile einen Dangling Pointer.

Das gute C++ (moderne Praxis)

 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}

Was sich geändert hat:

std::shared_ptr verwendet Referenzzählung zur Verwaltung von geteiltem Besitz. Der Speicher wird erst freigegeben, wenn der letzte shared_ptr, der darauf verweist, zerstört wird. Keine Dangling Pointer. Kein manuelles delete. Verfügbar seit C++11.

Beispiel 6: RAII – Sicherheit in modernem C++

RAII (Resource Acquisition Is Initialization) ist vielleicht das wichtigste Idiom in C++. Die Idee ist einfach: Binde die Lebensdauer einer Ressource an die Lebensdauer eines Objekts. Wenn das Objekt erstellt wird, erwirbt es die Ressource. Wenn das Objekt zerstört wird (den Gültigkeitsbereich verlässt), gibt es die Ressource frei. Keine manuelle Bereinigung. Keine Möglichkeit, es zu vergessen.

Smart Pointer sind nur eine Anwendung von RAII. Das Muster funktioniert für jede Ressource: Dateien, Mutexe, Netzwerk-Sockets, Datenbankverbindungen, GPU-Handles – was auch immer.

Das schlechte C++ (manuelle Ressourcenverwaltung)

 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}

Was ist schiefgelaufen:

Der Entwickler hat fopen/fclose im C-Stil verwendet. Wenn zwischen dem Öffnen und dem Schließen eine Exception geworfen wird, wird das Dateihandle nicht freigegeben. Mit zunehmender Anzahl von Ressourcen und Fehlerpfaden wird das zuverlässige Schließen aller Handles zu einem Wartungsalptraum.

Das gute C++ (RAII mit 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}

Was sich geändert hat:

std::ifstream ist ein RAII-Wrapper um ein Dateihandle. Die Datei wird im Konstruktor geöffnet und im Destruktor geschlossen – garantiert, unabhängig davon, wie die Funktion beendet wird (normaler Return, Exception, vorzeitiger Return). Keine Ressourcenlecks – designbedingt.

RAII für benutzerdefinierte Ressourcen (Mutex Lock)

RAII ist nicht auf die Standardbibliothek beschränkt. So funktioniert es bei der Thread-Synchronisation:

 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}

Einen eigenen RAII-Wrapper schreiben

Sie können RAII auf jede Ressource anwenden. Hier ein minimales Beispiel:

 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}

Zentrale Erkenntnis:

RAII ist kein Workaround oder ein „Best-Practice-Tipp" – es ist ein fundamentales C++-Designmuster, das Ressourcenlecks strukturell unmöglich macht, wenn es konsequent angewendet wird. Es ist älter als Rusts Ownership-Modell und hat dieses in vielerlei Hinsicht inspiriert.

Wie schneidet Rust im Vergleich ab?

Rusts Borrow Checker erkennt viele dieser Probleme, bevor Ihr Code überhaupt ausgeführt wird – zur Kompilierzeit. Das ist ein echter und bedeutsamer Vorteil, der Anerkennung verdient.

So würde ich die Kompromisse ehrlich beschreiben:

  1. Rust erzwingt Sicherheit auf Compiler-Ebene; C++ bietet Sicherheit über seine Standardbibliothek und Idiome. Beide Ansätze sind berechtigt; sie spiegeln unterschiedliche Designphilosophien wider.

  2. Modernes C++ verhindert dieselben Fehlerklassen, wenn die empfohlenen Werkzeuge verwendet werden. Smart Pointer, RAII, std::array, std::string, std::span, std::optional – das sind keine obskuren Bibliotheksfunktionen. Sie sind die empfohlene Art, C++ zu schreiben, und das seit weit über einem Jahrzehnt.

  3. Keine der beiden Sprachen ist ein Allheilmittel. Rusts unsafe-Schlüsselwort existiert aus gutem Grund – manchmal muss man das Sicherheitsnetz für FFI, Performance oder Low-Level-Operationen verlassen. Und Logikfehler, Race Conditions (in bestimmten Mustern) und Designfehler gibt es in jeder Sprache.

  4. Pragmatismus zählt. Milliarden Zeilen bewährter C++-Code laufen heute in Produktion. Für viele Teams ist die Einführung moderner C++-Praktiken, besserer Werkzeuge und statischer Analyse ein realistischerer Weg als ein vollständiges Neuschreiben – und er liefert echte Sicherheitsverbesserungen.

Was ist mit den Werkzeugen?

Moderne C++-Entwickler haben Zugang zu leistungsfähigen Sicherheitstools, die Probleme sogar über das hinaus erkennen, was die Sprache selbst erzwingt:

WerkzeugZweck
AddressSanitizer (ASan)Erkennt Use-After-Free, Buffer Overflow, Stack Overflow
MemorySanitizer (MSan)Erkennt Lesezugriffe auf nicht initialisierten Speicher
UndefinedBehaviorSanitizer (UBSan)Erkennt vorzeichenbehafteten Überlauf, Null-Dereferenzierung und mehr
ValgrindSpeicherleck-Erkennung und Profiling
Clang-TidyStatische Analyse mit Modernisierungsvorschlägen
C++ Core GuidelinesBranchenstandard-Richtlinien für sicheres, modernes C++

Das Kompilieren der „schlechten" Beispiele aus diesem Beitrag mit -fsanitize=address würde die Probleme sofort zur Laufzeit erkennen. Die Werkzeuge existieren – die Frage ist, ob die Entwickler sie nutzen.

Das Fazit

C++ und Rust nähern sich der Speichersicherheit aus unterschiedlichen Richtungen:

  • Rust macht Sicherheit zum Standard und erfordert ein explizites Opt-out mit unsafe.
  • C++ gibt dem Entwickler die volle Kontrolle und bietet Sicherheit über seine Standardbibliothek, RAII und gut etablierte Idiome.

Die meisten Speichersicherheitsprobleme in C++-Codebasen entstehen durch die Verwendung veralteter Muster – rohes new/delete, C-Arrays, Funktionen wie strcpy – anstatt der modernen Werkzeuge, die die Sprache seit über einem Jahrzehnt bereitstellt.

Die gute Nachricht ist, dass der Weg nach vorne für C++ klar und gut dokumentiert ist:

  • Verwenden Sie Smart Pointer (std::unique_ptr, std::shared_ptr) anstelle von rohem new/delete
  • Verwenden Sie Standard-Container (std::string, std::vector, std::array) anstelle von C-Puffern
  • Setzen Sie RAII für alle Ressourcenverwaltung ein
  • Nutzen Sie Sanitizer (ASan, MSan, UBSan) und statische Analyse in CI-Pipelines
  • Befolgen Sie die C++ Core Guidelines

Beide Sprachen sind leistungsfähige Werkzeuge, und beide haben ihren Platz in der modernen Softwareentwicklung. Die beste Wahl hängt von Ihrem Projekt, Ihrem Team und Ihren Rahmenbedingungen ab – nicht von Internetdebatten.

Wenn Sie diesen Beitrag nützlich oder anregend fanden, teilen Sie ihn gerne. Und wenn Sie eine andere Perspektive haben, würde ich mich freuen, sie im Kommentarbereich unten zu lesen. Die besten technischen Diskussionen entstehen, wenn wir mit Code argumentieren und nicht nur mit Meinungen.