Skip to content

Modul 25 — C++ zu Rust

Migration, Interop & Sicherheitsgarantien: wie man C++-Code nach Rust überträgt — Sum Types statt Vererbung, Ownership-Umzug, cxx/bindgen, inkrementelle Ship-of-Theseus-Migration und Verifikation des Übersetzten.

126

Warum übersetzen? Die Forschungslage

Lektion 126 von 5

Das Versprechen und die Falle

Millionen Zeilen C++ laufen in produktiven Systemen — Office-Suiten, BrowserEngines, Datenbanken, Spiel-Engines. Alles speichersicher machen ist der Traum; Rust ist die Antwort darauf. Aber wie kommst du dorthin?

Die Forschung hat dafür drei Wellen durchlaufen:

  1. Welle 1 — das Versprechen. Automatische Übersetzung: Es gab lange die Hoffnung, ein Tool könnte C++-Code einfach nach Rust portieren. Ergebnisse: Benchmarks wie TRACTOR (Bewertung von C→Rust-Übersetzern) und Werkzeuge wie Oxidize (das den Präprozessor der C-Welt struktur-erhaltend anhebt).
  2. Welle 2 — die Falle. Die Studie "C-to-Rust Fallacy: Automatic Refactoring ≠ Memory Safety" (2026) zeigte: wörtliche Übersetzung hält die Architektur des C++-Codes — und damit oft auch dessen Bugs. Die Fehler steckten im Design, nicht in der Syntax. Die Antwort der Industrie: agentische Übersetzung — Komponente für Komponente, während das System lauffähig bleibt (Ship-of-Theseus-Ansatz).
  3. Welle 3 — beweisbares Rust. Ist der Code einmal Rust, verifiziert ihn dieselbe Toolchain: Kani (Model Checker), KVerus (verifizierbares Rust mit Contracts) — Korrektheit wird prüfbar, nicht nur behauptet.

Die wichtigste Erkenntnis für dich als Übersetzer: Übersetzen ist ein Architekturprojekt, kein Syntaxprojekt.

Der Mapping-Katalog auf einen Blick

Die Grundidee jeder Migration ist simpel: Jedes C++-Konstrukt hat ein Rust-Pendant. Darauf die ganze Zeit zurückgreifen:

C++RustBedeutung
std::unique_ptr<T>Box<T>eindeutiger Besitz
std::shared_ptr<T>Rc<T> / Arc<T>geteilter Besitz
std::vector<T>Vec<T>dynamisches Array
std::stringString / &strText (UTF-8, kein Byte-Array)
std::map<K,V>BTreeMap<K,V> / HashMapsortiert / gehasht
ExceptionsResult<T,E> + ?Fehler als Daten
nullptrOption<T>Fehlen ist ein Typ
Vererbung + virtualenum + matchSum Types, erschöpfend
TemplatesGenerics + TraitsBounds statt impliziter Annahmen
std::mutexMutex<T>geschützter Zustand
co_await.awaitexplizites async
Destruktor (RAII)Drop + Ownershipwird vom Compiler durchgesetzt
// MERKE: In Rust ist Besitz explizit. Wer eine Vec<DocumentOp> baut,
// braucht keinen Destruktor, keinen unique_ptr, kein delete.
// Der Compiler erledigt das Aufräumen automatisch am Scope-Ende.
fn consume(ops: Vec<DocumentOp>) {
    for op in ops { /* ... */ }
} // Hier wird sauber aufgeräumt — von selbst.

✅ Wichtige Erkenntnisse

0/3

Haken setzen, um deinen Lernfortschritt zu markieren:

127

Klassen → Sum Types: Vererbung durch Enum ersetzen

Lektion 127 von 5

Das Kernproblem der Vererbung

C++-Klassenhierarchien mit virtual sind der Klassiker: Eine Basisklasse, ein polymorphes Verhalten, und jede neue Subklasse muss in jeder virtuelle Methode eingebaut werden. In Rust übersetzt du das als Sum Type (Enum):

  • Der Compiler erzwingt Erschöpfung: legst du eine Variante an, musst du sie in jedem match behandeln — es kann keine case-Vergessenheit geben.
  • Das Verhalten ist lokal: apply und undo stehen nebeneinander und werden zusammen gepflegt.
  • Es gibt keine Zeiger, keine vtables, keine delete-Disziplin.

Das klassische Beispiel aus Dokumenten-Engines: ein Verlauf von Operationen mit Undo — im C++-Original polymorph per Pointer-Sammlung.

C++ (das Alte, Pointer-Suppe):

class DocumentOp { public: virtual void apply(Document* d) = 0; virtual void undo(Document* d) = 0; };
class InsertOp : public DocumentOp { /* ... */ };
class DeleteOp : public DocumentOp { /* ... */ };
// OpHistory: std::vector<DocumentOp*> + manuelles delete im Destruktor.

Rust (das Neue, Sum Type):

enum DocumentOp {
    Insert { pos: usize, text: String },
    Delete { pos: usize, len: usize },
    Style  { pos: usize, style_id: u32, prev: u32 },
}

impl DocumentOp {
    fn apply(&self, doc: &mut Document) -> Result<(), HistoryError> {
        match self {
            DocumentOp::Insert { pos, text } => doc.insert(*pos, text),
            DocumentOp::Delete { pos, len }  => doc.delete(*pos, *len),
            DocumentOp::Style { pos, style_id, prev } => {
                doc.set_style(*pos, *style_id)?;
                Ok(())
            }
        }
    }
    fn undo(&self, doc: &mut Document) -> Result<(), HistoryError> {
        // Jetzt rückwärts durch die Ops — und jede Variante muss behandelt sein.
        match self {
            DocumentOp::Insert { pos, .. } => doc.delete(*pos, /*preview*/ 0),
            DocumentOp::Delete { pos, len } => doc.reinsert(*pos, *len),
            DocumentOp::Style { pos, prev, .. } => doc.set_style(*pos, *prev),
        }
    }
}

Verhalten wird zur Datenstruktur

enum ist mehr als eine Umbenennung. Die Varianten tragen Daten mit sich (text, style_id, prev), und match erzwingt vollständige Abdeckung. Eine neue Op-Sorte? Anlegen — und der Compiler zeigt dir jede Stelle, an der du sie verarbeiten musst. Das ist die größte Single-Source-of-Truth, die du von Rust bekommst.

// Muster für geschlossene Verhaltensmengen:
enum Transition {
    Idle, Draft, Published,
}

impl Transition {
    fn next(&self) -> Option<Self> {
        match self {
            Transition::Idle      => Some(Transition::Draft),
            Transition::Draft     => Some(Transition::Published),
            Transition::Published => None, // erschöpfend — kein else, keine C++-Default-Branch-Falle
        }
    }
}

✅ Wichtige Erkenntnisse

0/3

Haken setzen, um deinen Lernfortschritt zu markieren:

128

Der Ownership-Umzug: Zeiger werden Besitz

Lektion 128 von 5

Wo der Borrow Checker dich drei Mal beißt

Nach der Vererbung kommt der harte Teil: Speichermodell. In C++ war alles ein Zeiger, und irgendwer hat irgendwann delete aufgerufen (oder auch nicht). In Rust sagst du explizit, wer was besitzt. Drei klassische Übersetzungs-Kämpfe und ihre Lösungen:

Schlacht 1: „cannot borrow as mutable more than once"

Offene Callbacks (Signal/Slot, Observer) stashen &mut-Handles in Structs — in C++ völlig normal, in Rust verboten. Lösung: Übergib &mut T entlang des Aufrufpfads nach unten, statt Handles irgendwo zu verstecken. So:

// C++-Habit: dieses Struct "besitzt" einen Zeiger auf das Document.
// Rust: gar nicht erst speichern — als Parameter durchreichen.
fn render_all(doc: &mut Document) {
    for section in doc.sections() {
        render_section(doc, section); // ein &mut, sauber nach unten gereicht
    }
}

Schlacht 2: das selbst-referenzielle Struct

C++-Muster: ein std::vector<T*> in dieselbe Arena, auf die das Struct zeigt. Das ist in Rust nicht erlaubt (das Objekt würde auf sich selbst zeigen). Lösung: Speichere usize-IDs statt Referenzen und löse sie unter einem einzelnen &mut-Borrow auf.

struct Arena {
    items: Vec<Item>,
    active: Option<usize>, // ID statt &Item
}

impl Arena {
    fn activate(&mut self, id: usize) {
        if let Some(item) = self.items.get_mut(id) {
            item.hit_count += 1; // Lookup unter einem &mut — Regeln eingehalten
        }
    }
}

Schlacht 3: Callbacks → Channels

Der C++-Observer mit std::function-Listen wird in Rust zum Channel (tokio::sync::watch/mpsc). Events sind dann Datenströme, und Producer/ Consumer sind durch Ownership sauber getrennt — genau das, was Callbacks vorgeben zu können, aber nie garantieren.

// Producer/Callback-Ersatz: ein senders/receiver-Paar.
let (tx, mut rx) = tokio::sync::mpsc::channel(16);

// Worker erzeugt Events; die UI konsumiert sie — kein gemeinsamer Zustand.
tx.send(Event::StyleChanged { pos: 12 }).await?;

Sicherheit: Exceptions werden Result

C++-Exceptions unterbrechen den Kontrollfluss und können alles „abwerfen". In Rust ist ein Fehler ein Wert (Result<T, E>), der explizit behandelt werden muss. Parsen von Benutzerdaten? Immer Result — niemals panic! in Bibliothekencode.

fn parse_size(s: &str) -> Result<usize, ParseError> {
    let n: usize = s.trim().parse().map_err(ParseError::InvalidNumber)?;
    Ok(n)
}

✅ Wichtige Erkenntnisse

0/3

Haken setzen, um deinen Lernfortschritt zu markieren:

129

Interop & inkrementelle Migration: Der Ship-of-Theseus

Lektion 129 von 5

Nie Big-Bang, immer komponentenweise

Die Praxis aus der Industrie (und der Ship-of-Theseus-Methode): Du übersetzt eine Komponente, hältst die Naht (FFI), shippst sie — und wiederholst. Das System bleibt die ganze Zeit lauffähig. Dafür brauchst du zwei Brücken:

Brücke 1: Rust → C++ (Rust exportiert werden)

Rust kann eine C-ABI als dynamische Bibliothek exportieren:

# Cargo.toml — Rust-Bibliothek mit C-ABI
[lib]
crate-type = ["cdylib"]
#[no_mangle]
pub extern "C" fn wo_convert(src_ptr: *const u8, src_len: usize,
                              dst: *mut Vec<u8>) -> i32 {
    // C++ ruft diese Funktion über den generierten C-Header auf.
    let input = unsafe { std::slice::from_raw_parts(src_ptr, src_len) };
    match convert(input) {
        Ok(out) => { unsafe { *dst = out; } 0 }
        Err(_)  => -1,
    }
}

Brücke 2: C++ → Rust (Rust ruft C++ auf)

bindgen erzeugt Rust-Bindings aus C/C++-Headern; für große, lebendige Grenzen ist cxx die bessere Wahl — es generiert beide Seiten (C++-und Rust-Code) aus einer gemeinsamen Deklaration und gibt dir typsichere, nicht-unsafe Aufrufe:

// mit cxx: sicheres call von C++-Methoden, kein unsafe im Aufruf-Code
let doc = ffi::wo_document_open(path)?;
doc.insert_paragraph(12); // cxx-generated, typesafe Boundary

Die Regel für die Naht

Jede FFI-Grenze ist eine Sicherheitsgrenze. Übersetzter Code, der unsafe enthält, braucht: (1) einen Kommentar, der die exakte Invariante benennt, (2) einen Test, der bricht, wenn die Invariante verletzt wird. Dein Ziel: mit jedem Migrations-Schritt wird die Zahl der unsafe-Stellen kleiner als die Zahl der Pointer-Casts, die das C++ hatte.

// Regel: unsafe ist ein Tor, kein Werkzeug.
//   Invariante: src_len == src_ptr gültiger Puffer (vom C++-Caller garantiert).
//   Test: wo_convert mit zu kurzem Buffer → -1, kein UB (über Miri/Kani geprüft).

✅ Wichtige Erkenntnisse

0/3

Haken setzen, um deinen Lernfortschritt zu markieren:

130

Verifikation: Übersetztes beweisen, nicht vertrauen

Lektion 130 von 5

Verify, don't trust

Das größte Missverständnis der Migration: „Es kompiliert mit dem Borrow Checker, also ist es sicher." Der Borrow Checker garantiert Speichersicherheit des sicheren Codes — nicht, dass deine Übersetzung das richtige Verhalten hat. Deshalb gilt: Jede übersetzte Einheit braucht Tests — Round-Trip-Tests (C++-Ausgabe → Rust-Ausgabe vergleichen) und Fehlerpfad-Tests (ungültiger Input → Err, kein Panic).

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn roundtrip_docx_to_html_keeps_paragraphs() {
        let html = convert(b"<w:p>Hallo Welt</w:p>").unwrap();
        assert!(html.contains("Hallo Welt")); // Inhalt überlebt, nicht exakte Formatierung
    }

    #[test]
    fn empty_input_yields_error_not_panic() {
        assert!(convert(b"").is_err());
    }
}

Über Tests hinaus: formale Werkzeuge

Wenn du die Akzeptanzkriterien wirklich beweisen willst (und nicht nur testen):

  • Kani — Model Checker: beweist Abwesenheit von Panics und Vertragsverletzungen, auch in unsafe-Seams.
  • KVerus / Verus — verifiziertes Rust mit Contracts: requires/ensures- Annotationen werden zu mathematischen Beweisen.

Das ist der Punkt, an dem die Translation so sicher wird wie die C-Bibliothek es nie war — und so schnell wie Rust es verspricht.

// Der Unterschied:
//   cargo test       → „diese Fälle laufen durch"
//   cargo kani        → „es gibt keinen Eingabewert, der panic!t"
//   verus             → „die Funktion erfüllt ihren Vertrag für ALLE Eingaben"

Der Beurteilungsbogen für deine Übersetzung

  1. Sum Types statt Virtueller Hierarchien?
  2. Ownership explizit — kein Rc<RefCell>-Reflex?
  3. Result, kein Panic auf Eingabedaten?
  4. Char-Indizes (Unicode), nie Byte-len()?
  5. Tests: Round-Trip + Fehlerpfade für jede übersetzte Einheit?
  6. unsafe: begründet, benannt, getestet — und weniger als das C++?
  7. Idiomatisch — liest es sich wie frisch geschriebenes Rust?

Erfüllst du alle sieben, ist das keine Übersetzung mehr — es ist eine Neugeburt. Glückwunsch, du beherrschst Migration.

✅ Wichtige Erkenntnisse

0/3

Haken setzen, um deinen Lernfortschritt zu markieren:

This site uses essential cookies for Stripe payments. No tracking cookies.