Smart Pointer in Rust
Sea of Objects vs. Baumstrukturen
Abschnitt betitelt „Sea of Objects vs. Baumstrukturen“In den meisten Sprachen mit Garbage Collector ist ein Speichermodell üblich,
das man als “Sea of Objects” beschreibt. Objekte schwimmen im Speicher. Objekt
A verweist auf B, B auf C, und C vielleicht wieder zurück auf A.
Es ist ein wilder, unstrukturierter Graph.
Standardmäßig erzwingt das Ownership-Modell von Rust Baumstrukturen.
Mit normalen Referenzen (& oder &mut) oder Box<T> ist es nur möglich,
Datenstrukturen als Baum abzubilden (genauer gesagt als einen gerichteten
azyklischen Graphen).
Um andere Datenstrukturen zu implementieren, benötigen wir Smart Pointers.
Was sind Smart Pointers?
Abschnitt betitelt „Was sind Smart Pointers?“Smart Pointers sind Datentypen, die sich wie normale Zeiger verhalten, aber zusätzlichen Schutz bieten. Ihre Hauptaufgabe ist es, dynamischen Speicher (Heap) automatisch zu verwalten. Im Gegensatz zu “rohen Zeigern” (Raw Pointers) nutzen sie das RAII-Prinzip (Resource Acquisition Is Initialization):
- Erstellung: Speicher wird reserviert.
- Out of scope: Speicher wird automatisch freigegeben.
Sie verhindern klassische Fehler wie Memory Leaks oder Dangling Pointer.
Ein Vergleich der Ansätze:
- C: Manuelle Verwaltung: Die Speicherverwaltung erfolgt vollständig manuell über Rohzeiger und Funktionen wie
mallocoderfree. Dies gewährt dem Entwickler die maximale Kontrolle über das Speicherlayout und die Lebensdauer der Objekte. Allerdings ist die Sicherheit gering, da das System sehr anfällig für schwerwiegende Fehler wie Speicherlecks und Pufferüberläufe ist. - C++: RAII und Standard-Smart-Pointer: Durch das RAII-Konzept wird die Ressourcenfreigabe automatisch an die Lebensdauer von Objekten gebunden. Das Modell bietet weiterhin eine hohe Kontrolle, automatisiert jedoch die Bereinigung effizient. Die Sicherheit ist deutlich höher als in C, wenngleich Referenzzyklen und die falsche Nutzung von Rohzeigern weiterhin Risiken bergen.
- Rust: Das System basiert auf strengen Ownership- und Borrowing-Regeln, die der Compiler bereits vor der Ausführung erzwingt. Dies garantiert maximale Speichersicherheit ohne Garbage Collector.
Box<T> speichert die Daten auf dem Heap statt dem Stack. Die Box
selbst ist ein Pointer, der auf dem Stack gespeichert wird. Dies ist nützlich in
folgenden Szenarien:
Wenn die Größe eines Typen zu Kompilierzeit unbekannt ist
Abschnitt betitelt „Wenn die Größe eines Typen zu Kompilierzeit unbekannt ist“Der Typ Vehicle ist im folgenden Beispiel nur ein Trait ohne konkrete Größe.
Jede Variable, die auf dem Stack gespeichert werden soll, muss aber eine
bestimmbare Größe haben.
trait Vehicle { fn drive(&self);}struct Truck;impl Vehicle for Truck { fn drive(&self) { println!("Brumm") }}
fn main() { // dyn Vehicle hat keine Größe, muss also auf den Heap let t = Box::new(Truck); t.drive();}Wenn man den Typen in einer Box speichert, so wird die Box auf dem Stack abgelegt. Diese hat eine feste Größe (die des Pointers) und kann daher direkt auf dem Stack gespeichert werden.
Für rekursive Datentypen
Abschnitt betitelt „Für rekursive Datentypen“struct List { value: i32, next: Option<Box<List>>;}Ohne Box könnte der Compiler die Größe von List nicht berechnen (Liste enthält
Liste, die Liste enthält…). Da die Box eine feste Größe hat, ist der Compiler
nun zufrieden.
2. Rc: Shared Ownership
Abschnitt betitelt „2. Rc: Shared Ownership“Rc<T> ermöglicht Shared Ownership (geteilten Besitz). Mehrere Variablen können
denselben Wert besitzen. Der Speicher wird erst freigegeben, wenn der letzte
Owner verschwindet.
Vergleich: Borrowing vs Multiple Ownership
Abschnitt betitelt „Vergleich: Borrowing vs Multiple Ownership“Klassisches Borrowing:
Hier wird a am Ende des Blocks gedroppt. b und c würden auf bereinigten Speicher zeigen.
Shared Ownership (Rc):
Hier zeigen a, b und c auf denselben Speicher im Heap. Erst wenn alle drei
weg sind, wird der String gelöscht.
fn main() { let a = Rc::new(String::new()); // strong_count 1 let b = Rc::clone(&a); // strong_count 2 let c = Rc::clone(&a); // strong_count 3 drop(a); // strong_count 2 println!("{c}"); // c hat noch Ownership}Rc vs. Arc (Thread-Sicherheit)
Abschnitt betitelt „Rc vs. Arc (Thread-Sicherheit)“Rc<T> ist nicht threadsicher. Wenn zwei Threads gleichzeitig den Zähler
erhöhen, kann dies zu Speicherfehlern führen. Dafür gibt es Arc<T> (Atomic
Rc). Die API ist identisch, aber Arc nutzt spezielle CPU-Befehle, die
sicherstellen, dass der Zähler auch bei parallelem Zugriff korrekt bleibt.
Under the hood: Rc und Arc Warum nicht immer Arc nutzen? Weil Thread-Sicherheit Leistung kostet.
- Rc Assembly:
inc qword ptr [rdi]- Dauer: ~1 CPU-Zyklus
- Simples Hochzählen
- Arc Assembly:
lock xadd qword ptr [rdi], rax- Dauer: ~20-100+ CPU-Zyklen.
- Das
lockPräfix zwingt die CPU, Caches zu synchronisieren, was bremst.
Das Problem ohne lock: Wenn Thread A und Thread B gleichzeitig x = x + 1 rechnen wollen:
- Beide lesen 0.
- Beide rechnen lokal 0 + 1.
- Beide schreiben 1. Ergebnis ist 1, sollte aber 2 sein. Arc verhindert das.
lock xadd führt diese gesamte Operation atomar aus, das heißt quasi in einem
Rutsch, ohne dass ein anderer Thread dazwischen funken kann.
3. RefCell: Interior Mutability
Abschnitt betitelt „3. RefCell: Interior Mutability“Rc und Arc ermöglichen geteiltes Ownership. Sie erlauben nur unveränderliche
Referenzen auf die Daten, um bei gemeinsamen gleichzeitigem Verändern Race
Conditions zu vermeiden.
RefCell<T> ist die Lösung, um Daten zu verändern, selbst wenn man nur eine
immutable Referenz darauf besitzt. Dieses Design-Pattern nennt sich Interior
Mutability.
fn main() { let x = 5; let y = &mut x; // Fehler!}fn main() { let x = 5; let y = RefCell::new(x);}Ein Power-Duo Rc<RefCell<T>>
Abschnitt betitelt „Ein Power-Duo Rc<RefCell<T>>“Dies ist die häufigste Kombination in Rust:
Rcsorgt dafür, dass die Daten mehrere Besitzer haben.RefCellsorgt dafür, dass wir die Daten ändern können.
use std::rc::Rc;use std::cell::RefCell;
fn main() { let log = Rc::new(RefCell::new(String::new()));
let a = Rc::clone(&log); let b = Rc::clone(&log); let c = Rc::clone(&log);
{ let mut mut_refa = a.borrow_mut(); mut_refa.push_str(" -> A war hier"); }
{ let mut mut_refb = b.borrow_mut(); mut_refb.push_str(" -> B war hier"); }
println!("Log Ergebnis: {}", c.borrow());
// Dieser Code würde einen Panic verursachen, da zwei Schreibzugriffe // let mut_refa = cell.borrow_mut(); // let mut_refb = cell.borrow_mut(); // a.push_str(" -> A war hier"); // b.push_str(" -> B war hier");}Welchen Smart Pointer wann nutzen?
Abschnitt betitelt „Welchen Smart Pointer wann nutzen?“Um zu entscheiden, welchen Smart Pointer man benötigt, muss man zwei Fragen beantworten: “Wie viele Besitzer gibt es?” und “Wann wird das Ownership geprüft?”.
- Ownership (Besitz)
Rc/Arc: Ermöglicht mehrere Besitzer für dieselben DatenBoxundRefCell: Erlauben nur einen einzigen Besitzer
- Borrow Checking (Wann wird geprüft?) Hier liegt der entscheidende Unterschied zwischen Box und RefCell:
Box(Statisch): Der Compiler prüft die Borrowing-Regeln zur Compile-Zeit. Wenn du einen Fehler machst, kompiliert das Programm nicht.RefCell(Dynamisch): Die Regeln werden erst zur Laufzeit geprüft. Wenn du einen Fehler machst (z.B. zwei mutable Borrows gleichzeitig), stürzt das Programm ab (Panic).
https://www.youtube.com/watch?v=8O0Nt9qY_vo
https://www.youtube.com/watch?v=xNrglKGi-7o&list=PLzcWTPTvhHtVWMJF_27EVB0cef7KUfsB3&index=59