Smartpointer in Rust: Box, Rc und Arc
1. Überblick
Standardmäßig hat jeder Wert in Rust nur einen Owner, und Rust muss die Größe des Wertes zur Compile-Zeit kennen. Viele reale Szenarien erfüllen diese beiden Bedingungen nicht: rekursive Datentypen, Listen mit verschiedenen Typen oder Daten, die mehrere Owner brauchen. Dieser Artikel stellt die drei häufigsten Smartpointer vor: Box<T>, Rc<T> und Arc<T>. Danach wirst du wissen, welchen Typ du in welcher Situation wählst, wie du sie mit Weak, RefCell und Mutex kombinierst und welche Fehler häufig auftreten.
2. Was sind Smartpointer?
Ein Smartpointer ist ein Struct, der einen Zeiger umhüllt und zwei wichtige Traits implementiert:
Deref: ermöglicht es dir, den Smartpointer wie eine normale Referenz zu benutzen (*ptr, Methoden direkt aufrufen).Drop: gibt Ressourcen automatisch frei, wenn der Smartpointer seinen Gültigkeitsbereich verlässt.
String und Vec<T> sind eigentlich auch Smartpointer. Box, Rc und Arc unterscheiden sich dadurch, dass sie sich auf die Eigentümerschaft von Daten auf dem Heap konzentrieren. Alle drei befinden sich in der Standardbibliothek. Du musst keine zusätzliche Dependency zu Cargo.toml hinzufügen.
3. Box<T>: ein Owner, Daten auf dem Heap
3.1. Grundlegende Allokation
fn main() {
let order_total = Box::new(250_000u64);
println!("Summe: {}", order_total);
let doubled = *order_total * 2;
println!("Verdoppelt: {}", doubled);
}
Box::new allokiert den Wert 250_000 auf dem Heap. Auf dem Stack bleibt nur der Zeiger mit fester Größe. println! funktioniert sofort, weil Box Display implementiert, wenn T das tut. Der Operator * ruft den inneren Wert über Deref ab. Wenn order_total main verlässt, wird der Heap-Speicher automatisch freigegeben.
Summe: 250000
Verdoppelt: 500000
Das Einpacken einer u64 in einen Box wie im obigen Beispiel bringt keinen Vorteil. Dieser Code illustriert nur die Syntax. Box ist wirklich nützlich in den zwei Fällen unten.
3.2. Rekursive Datentypen
Nehmen wir an, du beschreibst einen Bestellverarbeitungsprozess als Kette von Schritten:
#[derive(Debug)]
enum OrderStep {
Done,
Next(String, Box<OrderStep>),
}
use OrderStep::{Done, Next};
fn count(step: &OrderStep) -> usize {
match step {
Done => 0,
Next(_, rest) => 1 + count(rest),
}
}
fn main() {
let flow = Next(
"Bestellung".into(),
Box::new(Next(
"Zahlung".into(),
Box::new(Next("Lieferung".into(), Box::new(Done))),
)),
);
println!("Schritte: {}", count(&flow));
}
Wenn du Next(String, OrderStep) ohne Box schreibst, gibt der Compiler einen Fehler aus:
error[E0072]: recursive type `OrderStep` has infinite size
Der Grund ist, dass ein OrderStep einen weiteren OrderStep enthält, der wiederum einen anderen enthält und so weiter, weshalb die Größe unendlich wäre. Box<OrderStep> hat immer die Größe eines Zeigers, daher ist die Größe des Enums endlich. In der Funktion count hat rest den Typ &Box<OrderStep>. Rust konvertiert es automatisch zu &OrderStep durch Deref-Coercion.
Schritte: 3
3.3. Trait-Objekte
Box<dyn Trait> ermöglicht es dir, mehrere verschiedene Typen in der gleichen Collection zu speichern:
trait PaymentMethod {
fn pay(&self, amount: u64) -> String;
}
struct Cash;
struct Card {
last4: String,
}
impl PaymentMethod for Cash {
fn pay(&self, amount: u64) -> String {
format!("Bargeld: {amount}")
}
}
impl PaymentMethod for Card {
fn pay(&self, amount: u64) -> String {
format!("Karte ****{}: {amount}", self.last4)
}
}
fn main() {
let methods: Vec<Box<dyn PaymentMethod>> = vec![
Box::new(Cash),
Box::new(Card { last4: "4242".into() }),
];
for m in &methods {
println!("{}", m.pay(100_000));
}
}
Cash und Card haben unterschiedliche Größen und können daher nicht direkt im gleichen Vec gespeichert werden. Wenn du sie in Box<dyn PaymentMethod> einpackst, haben alle Elemente die gleiche Größe (einen fetten Zeiger: Datenzeiger plus vtable-Zeiger). Der Aufruf von pay wird zur Laufzeit aufgelöst.
Bargeld: 100000
Karte ****4242: 100000
Hinweis: Wenn alle Elemente den gleichen Typ haben, verwende Generics (
impl PaymentMethododer<T: PaymentMethod>) stattBox<dyn …>. Generics verwenden Static Dispatch, benötigen keine Heap-Allokation und keinen Aufruf über die vtable.
4. Rc<T>: mehrere Owner in einem Thread
4.1. Reference Counting
Nehmen wir an, mehrere Order gehören zum gleichen Customer. Du willst die Kundeninformation nicht in jede Bestellung kopieren und kannst auch nicht bestimmen, welche Bestellung am längsten „leben” wird, um sie als Owner festzulegen. Das ist der Moment, um Rc (Reference Counted) zu verwenden:
use std::rc::Rc;
struct Customer {
name: String,
}
struct Order {
id: u32,
customer: Rc<Customer>,
}
fn main() {
let alice = Rc::new(Customer { name: "Alice".into() });
println!("Nach Erstellung: {}", Rc::strong_count(&alice));
let o1 = Order { id: 1, customer: Rc::clone(&alice) };
{
let o2 = Order { id: 2, customer: Rc::clone(&alice) };
println!("Im Block: {}", Rc::strong_count(&alice));
println!("Bestellung {} von {}", o2.id, o2.customer.name);
}
println!("Nach Block: {}", Rc::strong_count(&alice));
println!("Bestellung {} von {}", o1.id, o1.customer.name);
}
Rc::clone kopiert nicht Customer. Es erhöht nur den strong count-Zähler um 1. Wenn o2 am Ende des Blocks gelöscht wird, nimmt der Zähler um 1 ab. Customer wird nur freigegeben, wenn der Zähler auf 0 fällt.
Nach Erstellung: 1
Im Block: 3
Bestellung 2 von Alice
Nach Block: 2
Bestellung 1 von Alice
Nach Konvention sollte man Rc::clone(&alice) statt alice.clone() schreiben. Diese Schreibweise zeigt dem Leser, dass dies ein günstiger Zähler-Inkrementierungsvorgang ist, keine tiefe Kopie.
4.2. Daten mit Rc<RefCell<T>> ändern
Rc<T> gibt dir nur unveränderliche Referenzen (&T). Wenn mehrere Owner Daten ändern müssen, kombiniere es mit RefCell:
use std::cell::RefCell;
use std::rc::Rc;
struct Cart {
items: Vec<String>,
}
fn main() {
let cart = Rc::new(RefCell::new(Cart { items: vec![] }));
let web = Rc::clone(&cart);
let mobile = Rc::clone(&cart);
web.borrow_mut().items.push("Shirt".into());
mobile.borrow_mut().items.push("Hut".into());
println!("{:?}", cart.borrow().items);
}
RefCell verlagert die Überprüfung der Borrow-Regeln von der Compile-Zeit zur Laufzeit. borrow_mut() gibt einen Guard zurück. Dieser Guard verfällt am Ende der Anweisung, daher ist der nächste Aufruf noch gültig.
["Shirt", "Hut"]
Hinweis: Wenn du
borrow_mut()aufrufst, während ein andererborrow()oderborrow_mut()noch aktiv ist, panikiert das Programm mitalready borrowed: BorrowMutError. Halte den Guard so kurz wie möglich. Verwendetry_borrow_mut(), wenn du den Fehler behandeln möchtest, statt zu paniken.
4.3. Zirkuläre Referenzen und Weak<T>
Wenn Customer eine Liste von Rc<Order> hält und jede Order wiederum Rc<Customer> hält, werden die beiden Zähler nie auf 0 fallen. Das Ergebnis ist ein Speicherleck. Rust kann diesen Fehler zur Compile-Zeit nicht verhindern. Die Lösung ist, Weak für eine Richtung der Referenz zu verwenden:
use std::cell::RefCell;
use std::rc::{Rc, Weak};
struct Customer {
name: String,
orders: RefCell<Vec<Rc<Order>>>,
}
struct Order {
id: u32,
customer: Weak<Customer>,
}
fn main() {
let bob = Rc::new(Customer {
name: "Bob".into(),
orders: RefCell::new(vec![]),
});
let order = Rc::new(Order { id: 7, customer: Rc::downgrade(&bob) });
bob.orders.borrow_mut().push(Rc::clone(&order));
if let Some(c) = order.customer.upgrade() {
println!("Bestellung {} gehört zu {}", order.id, c.name);
}
println!("strong={}, weak={}", Rc::strong_count(&bob), Rc::weak_count(&bob));
drop(bob);
println!("Existiert Kunde noch? {}", order.customer.upgrade().is_some());
}
Rc::downgrade erstellt einen Weak. Weak erhöht nur den weak count und hält die Daten nicht am Leben. Wenn du ihn verwenden möchtest, rufst du upgrade() auf, um Option<Rc<T>> zu erhalten. Das Ergebnis ist None, wenn die Daten bereits freigegeben wurden. Nach drop(bob) fällt der Strong Count von Customer auf 0, sodass Customer freigegeben wird, auch wenn Weak in order noch vorhanden ist.
Bestellung 7 gehört zu Bob
strong=1, weak=1
Existiert Kunde noch? false
Allgemeine Regel: Verwende Rc für die “Eltern → Kind”-Richtung, Weak für die “Kind → Eltern”-Richtung.
5. Arc<T>: mehrere Owner über mehrere Threads
5.1. Warum Rc nicht mit Threads funktioniert
Rc aktualisiert den Zähler mit normaler Addition und ist nicht sicher, wenn mehrere Threads gleichzeitig darauf zugreifen. Daher implementiert Rc nicht das Trait Send. Wenn du Rc an thread::spawn übergibst, lehnt der Compiler ab:
error[E0277]: `Rc<Customer>` cannot be sent between threads safely
Dies ist eine Stärke von Rust: Data-Race-Fehler werden zur Compile-Zeit blockiert. Arc (Atomically Reference Counted) hat eine identische API zu Rc, verwendet aber Atomic-Operationen zum Aktualisieren des Zählers.
5.2. Gemeinsame Nutzung schreibgeschützter Daten
use std::sync::Arc;
use std::thread;
struct ShopConfig {
currency: String,
vat_percent: u64,
}
fn main() {
let config = Arc::new(ShopConfig { currency: "EUR".into(), vat_percent: 19 });
let handles: Vec<_> = [100u64, 200]
.into_iter()
.map(|price| {
let config = Arc::clone(&config);
thread::spawn(move || {
let vat = price * config.vat_percent / 100;
println!("{} + MwSt = {} {}", price, price + vat, config.currency);
})
})
.collect();
for h in handles {
h.join().unwrap();
}
}
Jeder Thread erhält über move eine eigene Kopie von Arc. Alle Kopien zeigen auf die gleiche ShopConfig. Da die Daten nur gelesen werden, ist kein Locking notwendig. Die Reihenfolge der beiden Ausgabezeilen kann zwischen den Läufen unterschiedlich sein.
100 + MwSt = 119 EUR
200 + MwSt = 238 EUR
5.3. Daten mit Arc<Mutex<T>> ändern
RefCell ist nicht thread-sicher. Das entsprechende Äquivalent für mehrere Threads ist Mutex (oder RwLock):
use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
let revenue = Arc::new(Mutex::new(0u64));
let mut handles = Vec::new();
for amount in [100u64, 250, 400] {
let revenue = Arc::clone(&revenue);
handles.push(thread::spawn(move || {
*revenue.lock().unwrap() += amount;
}));
}
for h in handles {
h.join().unwrap();
}
println!("Umsatz: {}", *revenue.lock().unwrap());
}
lock() gibt einen MutexGuard zurück. Die Sperre wird automatisch freigegeben, wenn der Guard gelöscht wird, hier am Ende der Anweisung. unwrap() behandelt den Fall, in dem der Mutex vergiftet ist, d.h. ein anderer Thread während des Haltens der Sperre panikiert hat.
Umsatz: 750
Hinweis: Für einen einfachen Ganzzahl-Zähler ist
Arc<AtomicU64>schneller alsArc<Mutex<u64>>. Außerdem sollteArcnicht dort verwendet werden, wo nur ein Thread vorhanden ist. Atomare Operationen sind teurer als normale Addition, obwohl der Unterschied gering ist.
6. Tests für Reference-Counting-Verhalten schreiben
Rust hat ein eingebautes Test-Framework, es ist kein zusätzliches Crate notwendig. Platziere die folgenden Tests am Ende von src/lib.rs oder src/main.rs:
#[cfg(test)]
mod tests {
use std::rc::{Rc, Weak};
use std::sync::{Arc, Mutex};
use std::thread;
struct Customer {
name: String,
}
#[test]
fn rc_clone_increases_strong_count_and_drop_decreases_it() {
let alice = Rc::new(Customer { name: "Alice".into() });
let copy = Rc::clone(&alice);
assert_eq!(Rc::strong_count(&alice), 2);
assert!(Rc::ptr_eq(&alice, ©));
drop(copy);
assert_eq!(Rc::strong_count(&alice), 1);
assert_eq!(alice.name, "Alice");
}
#[test]
fn weak_upgrade_returns_none_after_owner_dropped() {
let bob = Rc::new(Customer { name: "Bob".into() });
let weak: Weak<Customer> = Rc::downgrade(&bob);
assert!(weak.upgrade().is_some());
drop(bob);
assert!(weak.upgrade().is_none());
}
#[test]
fn arc_mutex_sums_correctly_across_threads() {
let revenue = Arc::new(Mutex::new(0u64));
let handles: Vec<_> = (1..=100)
.map(|i| {
let revenue = Arc::clone(&revenue);
thread::spawn(move || *revenue.lock().unwrap() += i)
})
.collect();
handles.into_iter().for_each(|h| h.join().unwrap());
assert_eq!(*revenue.lock().unwrap(), 5050);
assert_eq!(Arc::strong_count(&revenue), 1);
}
}
Der erste Test überprüft den Zähler und verwendet Rc::ptr_eq, um zu bestätigen, dass zwei Rc auf den gleichen Speicher zeigen, d.h. es gibt keine tiefe Kopie. Der zweite Test bestätigt, dass Weak die Daten nicht am Leben hält. Der dritte Test führt 100 Threads aus, die addieren, das Ergebnis muss 5050 sein. Nachdem alle Threads verbunden sind, bleibt nur noch eine starke Referenz.
Tests ausführen:
cargo test
running 3 tests
test tests::rc_clone_increases_strong_count_and_drop_decreases_it ... ok
test tests::weak_upgrade_returns_none_after_owner_dropped ... ok
test tests::arc_mutex_sums_correctly_across_threads ... ok
test result: ok. 3 passed; 0 failed
7. Welche sollte man wählen?
| Anforderung | Wahl |
|---|---|
| Ein Owner, braucht Heap (rekursive Typen, Trait-Objekte, große Daten für günstige Moves) | Box<T> |
| Mehrere Owner, ein Thread, nur lesen | Rc<T> |
| Mehrere Owner, ein Thread, ändern | Rc<RefCell<T>> |
| Mehrere Owner, mehrere Threads, nur lesen | Arc<T> |
| Mehrere Owner, mehrere Threads, ändern | Arc<Mutex<T>> / Arc<RwLock<T>> |
| Rückwärts-Referenzen, Cache, zirkuläre Referenzen vermeiden | Weak<T> |
Häufige Fehler:
- Smartpointer verwenden, wenn nur Borrowing nötig ist. Wenn eine Funktion Daten nur liest, nimm
&CustomerstattRc<Customer>. So ist die Funktion flexibler und du musst den Zähler nicht erhöhen oder verringern. Rc<RefCell<T>>übernutzen. Code, der dicht mit diesem Pattern gefüllt ist, ist oft ein Zeichen dafür, dass du OOP-Denkweise aus Java oder C# mitbringst. Überdenke die Ownership-Struktur oder verwende Indizes inVec(Arena-Pattern).- Zirkuläre Referenzen.
RcundArckönnen beide Speicherlecks verursachen, wenn zwei Objekte sich mit starken Referenzen gegenseitig referenzieren. VerwendeWeakfür eine Richtung. MutexGuardzu lange halten. Besonders gefährlich ist das Halten eines Guard über.awaitin Async-Code oder wenn mehrere Mutexe in unterschiedlicher Reihenfolge in verschiedenen Threads gesperrt werden, was zu Deadlock führt.
8. Fazit
Verwende Box<T>, wenn du Daten mit nur einem Owner auf dem Heap ablegen musst, typischerweise für rekursive Typen und Trait-Objekte. Rc<T> ermöglicht mehrere Owner in einem Thread, während Arc<T> das gleiche über mehrere Threads hinweg mit atomaren Zählern macht. Die Smartpointer selbst erlauben nur Lesezugriff. Wenn du Daten ändern musst, kombinierst du sie mit RefCell (ein Thread) oder Mutex/RwLock (mehrere Threads), und nutzt Weak um zirkuläre Referenzen zu vermeiden. Beginne mit normalem Borrowing und wechsle nur zu Smartpointern, wenn die standardmäßigen Ownership-Regeln wirklich nicht ausreichen.