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.
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:
- 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).
- 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).
- 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++ | Rust | Bedeutung |
|---|---|---|
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::string | String / &str | Text (UTF-8, kein Byte-Array) |
std::map<K,V> | BTreeMap<K,V> / HashMap | sortiert / gehasht |
| Exceptions | Result<T,E> + ? | Fehler als Daten |
nullptr | Option<T> | Fehlen ist ein Typ |
| Vererbung + virtual | enum + match | Sum Types, erschöpfend |
| Templates | Generics + Traits | Bounds statt impliziter Annahmen |
std::mutex | Mutex<T> | geschützter Zustand |
co_await | .await | explizites async |
| Destruktor (RAII) | Drop + Ownership | wird 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
Haken setzen, um deinen Lernfortschritt zu markieren:
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
matchbehandeln — es kann keinecase-Vergessenheit geben. - Das Verhalten ist lokal:
applyundundostehen 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
Haken setzen, um deinen Lernfortschritt zu markieren:
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
Haken setzen, um deinen Lernfortschritt zu markieren:
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
Haken setzen, um deinen Lernfortschritt zu markieren:
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
- Sum Types statt Virtueller Hierarchien?
- Ownership explizit — kein
Rc<RefCell>-Reflex? Result, kein Panic auf Eingabedaten?- Char-Indizes (Unicode), nie Byte-
len()? - Tests: Round-Trip + Fehlerpfade für jede übersetzte Einheit?
unsafe: begründet, benannt, getestet — und weniger als das C++?- 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
Haken setzen, um deinen Lernfortschritt zu markieren: