Der Borrow Checker ist die Compiler-Phase, die jeden Borrow in deinem Code prüft und sicherstellt, dass die Aliasing-XOR-Mutability-Regel eingehalten wird. Was anfangs wie ein launischer Kritiker wirkt, ist eines der mächtigsten Werkzeuge der Sprache: er fängt komplette Klassen von Bugs (Data Races, Use-after-Free, iterator invalidation) zur Compile-Zeit ab. Dieser Artikel zeigt, wie der Borrow Checker mental funktioniert, was die Non-Lexical Lifetimes (NLL) seit Edition 2018 verändert haben, geht durch die fünf häufigsten Fehler-Codes mit ausführlichen Erklärungen und gibt konkrete Strategien zum Beheben jeder Konflikt-Klasse.
Was der Borrow Checker macht
Der Borrow Checker ist eine statische Analyse-Phase im Rust-Compiler. Er läuft nach Type-Check und vor Code-Generierung. Für jeden Wert in deinem Programm verfolgt er:
- Wer besitzt ihn? (Ownership)
- Welche aktiven Borrows existieren? (Shared vs. Mut)
- Wann werden sie wieder freigegeben? (Lifetime)
Wenn irgendwo eine Operation die Aliasing-XOR-Mutability-Regel verletzen würde, wird sie als Compile-Fehler abgelehnt.
Was er nicht prüft
- Logische Korrektheit — er garantiert nur Memory-Safety, nicht dass dein Algorithmus richtig ist.
- Panic-Vermeidung —
unwrap()aufNone, Index out of bounds. Das sind Laufzeit-Probleme. - Memory-Leaks —
std::mem::forget,Rc-Zyklen. Safe Rust kann leaken, ohne Memory-Safety zu verletzen. unsafe-Blöcke — innerhalb vonunsafe { ... }darfst du Regeln verletzen, auf eigene Verantwortung.
Non-Lexical Lifetimes (NLL)
Vor Rust 1.31 (Edition 2018) hat der Borrow Checker lexikalisch gearbeitet: ein Borrow lebte bis zum Ende seines Scopes (}). Das führte zu vielen unnötigen Konflikten.
Mit NLL ist der Checker flow-sensitive: ein Borrow lebt nur bis zu seiner letzten Verwendung im Code-Pfad.
fn main() {
let mut v = vec![1, 2, 3];
let r = &v[0];
println!("{r}"); // letzte Verwendung von r
v.push(4); // ok — r ist „tot" ab hier
println!("{:?}", v);
}Lexikalisch wäre r noch bis zum Funktions-Ende „aktiv" — und damit v.push(4) ein Konflikt. NLL erkennt, dass r nach println! nicht mehr verwendet wird.
NLL macht idiomatischen Rust-Code drastisch einfacher und ist heute Default. Du musst dich nicht aktiv darum kümmern; gut zu wissen ist nur: der Compiler ist klüger, als die Regeln auf den ersten Blick suggerieren.
E0499 — cannot borrow as mutable more than once
Zwei &mut T auf denselben Wert gleichzeitig.
fn main() {
let mut s = String::from("Hi");
let r1 = &mut s;
let r2 = &mut s; // E0499 — zweites &mut
r1.push_str("!");
}Diagnose:
error[E0499]: cannot borrow `s` as mutable more than once at a time
--> src/main.rs:4:18
|
3 | let r1 = &mut s;
| ------ first mutable borrow occurs here
4 | let r2 = &mut s;
| ^^^^^^ second mutable borrow occurs here
5 | r1.push_str("!");
| -- first borrow later used hereBehebung:
- Den ersten Borrow erst zu Ende führen, dann den zweiten erstellen.
- Wenn beide Refs disjunkte Teile betreffen (
v[0]undv[1]):split_at_mut. - Bei verschachtelten Aufrufen: Zwischenwerte in lokale Bindungen extrahieren.
E0502 — cannot borrow as mutable because also borrowed as immutable
&T und &mut T gleichzeitig auf denselben Wert.
fn main() {
let mut v = vec![1, 2, 3];
let r1 = &v[0]; // shared Borrow auf v
v.push(4); // E0502 — &mut v braucht push
println!("{r1}");
}Diagnose:
error[E0502]: cannot borrow `v` as mutable because it is also borrowed as immutable
--> src/main.rs:4:5
|
3 | let r1 = &v[0];
| -- immutable borrow occurs here
4 | v.push(4);
| ^^^^^^^^^ mutable borrow occurs here
5 | println!("{r1}");
| ---- immutable borrow later used hereBehebung:
- Die letzte Verwendung des shared Borrow vor den mutablen Aufruf ziehen (NLL macht das oft sichtbar).
- Wert kopieren statt referenzieren:
let r1 = v[0];(beiCopy-Typen). - Falls beides parallel nötig:
Cell/RefCell(Interior Mutability) — Smart-Pointer-Kapitel.
E0506 — cannot assign to borrowed value
Direkt-Mutation eines Werts, der gerade geborgt ist.
fn main() {
let mut x = 5;
let r = &x;
x = 10; // E0506 — x ist shared geborgt durch r
println!("{r}");
}Diagnose:
error[E0506]: cannot assign to `x` because it is borrowed
--> src/main.rs:4:5
|
3 | let r = &x;
| -- `x` is borrowed here
4 | x = 10;
| ^^^^^^ `x` is assigned to here but it was already borrowed
5 | println!("{r}");
| --- borrow later used hereBehebung:
- Borrow vor der Zuweisung beenden.
- Bei mehreren Borrows: explizit als Block gruppieren.
E0596 — cannot borrow as mutable
Versuch, eine &mut-Referenz von einer non-mut-Bindung zu nehmen.
fn main() {
let s = String::from("Hi"); // ohne mut
let r = &mut s; // E0596
r.push_str("!");
}Diagnose:
error[E0596]: cannot borrow `s` as mutable, as it is not declared as mutable
--> src/main.rs:3:13
|
3 | let r = &mut s;
| ^^^^^^ cannot borrow as mutable
|
help: consider changing this to be mutable
|
2 | let mut s = String::from("Hi");
| +++Behebung:
let mut s = ...;stattlet s = ...;. Der Compiler zeigt die exakte Stelle.
E0716 — temporary value dropped while borrowed
Eine Referenz auf einen temporären Wert, der zu früh gedroppt wird.
fn liefere_string() -> String { String::from("Hi") }
fn main() {
let r = &liefere_string(); // E0716 — Temporary lebt nicht lang genug
// Korrektur: erst in Bindung speichern, dann referenzieren
// let s = liefere_string();
// let r = &s;
println!("{r}");
}Tatsächlich verlängert Rust hier oft die Lebenszeit der Temporary („Temporary Lifetime Extension"), sodass dieser Code in einfachen Fällen funktioniert. Bei komplexeren Patterns greift E0716:
fn main() {
let r;
{
let s = String::from("temp");
r = &s; // s lebt nur im inneren Block
}
// println!("{r}"); // E0597 — s ist tot
}Behebung:
- Den temporären Wert in eine eigene
let-Bindung mit ausreichendem Scope packen. - Falls die Funktion eine Referenz zurückgibt: prüfen, ob die Funktion einen owned Wert zurückgeben sollte.
Strategien zum Beheben von Borrow-Konflikten
Fünf Reflexe, mit denen sich die meisten Borrow-Fehler systematisch lösen:
1. Borrow-Lebenszeit verkürzen
fn main() {
let mut v = vec![1, 2, 3];
{
let r = &v[0]; // r lebt nur in diesem Block
println!("{r}");
}
v.push(4); // ok — r ist weg
}Block-Klammern um den Borrow zwingen ihn zu früherem Drop.
2. Wert extrahieren statt referenzieren
fn main() {
let mut v = vec![1, 2, 3];
let erstes = v[0]; // Copy, kein Borrow
v.push(4); // ok
println!("{erstes}");
}Bei Copy-Typen reicht eine Direkt-Kopie statt einer Referenz.
3. Splitting mit split_at_mut oder Iterator-Indices
fn tausch_erste_und_letzte(v: &mut [i32]) {
if v.len() < 2 { return; }
let len = v.len();
let (links, rechts) = v.split_at_mut(len - 1);
std::mem::swap(&mut links[0], &mut rechts[0]);
}split_at_mut teilt einen Slice in zwei garantiert disjunkte mutable Slices.
4. Interior Mutability mit Cell/RefCell
Wenn die Borrow-Regeln zu Compile-Zeit nicht ausreichen, gibt es Container, die die Prüfung zur Laufzeit verschieben:
use std::cell::RefCell;
fn main() {
let zaehler = RefCell::new(0);
// shared Ref, aber Mutation möglich:
*zaehler.borrow_mut() += 1;
*zaehler.borrow_mut() += 1;
println!("{}", zaehler.borrow()); // 2
}Achtung: RefCell panickt zur Laufzeit, wenn doppelt mutable geborgt wird. Es verschiebt die Borrow-Prüfung nur zeitlich. Mehr im Smart-Pointer-Kapitel.
5. Funktion umstrukturieren
Manchmal ist der Konflikt ein Code-Smell — die Funktion macht zu viel. Aufspalten in zwei Funktionen, eine liest, die andere schreibt, löst den Knoten.
Praxis: Borrow Checker im echten Code
Vec mutieren während Lesen
fn entferne_duplikate(v: &mut Vec<i32>) {
// Erst Indices der Duplikate sammeln, dann entfernen.
let mut zu_entfernen = Vec::new();
let mut gesehen = std::collections::HashSet::new();
for (i, &val) in v.iter().enumerate() {
if !gesehen.insert(val) {
zu_entfernen.push(i);
}
}
// Von hinten nach vorne entfernen (Indices bleiben gültig)
for &i in zu_entfernen.iter().rev() {
v.remove(i);
}
}Klassisches Pattern: &v für Lesen, danach &mut v für Modifikation. Die zwei Schritte sind durch separate Loops getrennt — kein Borrow-Konflikt.
Cache-Update mit explizitem Scope
use std::collections::HashMap;
fn lookup_oder_lade(cache: &mut HashMap<String, Vec<u8>>, key: &str) -> Vec<u8> {
// Erst Lookup (shared borrow), dann ggf. insert (mut borrow) —
// beide Borrows sind disjunkt im Zeitablauf.
if let Some(daten) = cache.get(key) {
return daten.clone();
}
// Hier ist der get-Borrow tot — neue Daten einfügen
let daten = vec![0u8; 1024]; // teure Berechnung
cache.insert(key.to_string(), daten.clone());
daten
}NLL erkennt, dass der get-Borrow nach dem return endet — danach ist insert mit &mut cache ok. Ohne NLL wäre das ein Konflikt.
Iter-Sum mit nachträglicher Mutation
fn normalisieren(v: &mut Vec<f64>) {
let summe: f64 = v.iter().sum(); // shared borrow für iter()
if summe == 0.0 { return; }
for x in v.iter_mut() { // jetzt mut borrow
*x /= summe;
}
}Die iter()-Borrow endet mit der Auswertung von summe. Danach kann iter_mut() frei greifen. Klassisches Read-then-Write-Pattern.
Verschachtelte Datenstruktur mit split_at_mut
fn tausche_zeilen(matrix: &mut [Vec<i32>], i: usize, j: usize) {
if i == j { return; }
let (lo, hi) = if i < j { (i, j) } else { (j, i) };
let (links, rechts) = matrix.split_at_mut(hi);
std::mem::swap(&mut links[lo], &mut rechts[0]);
}Zwei mutable Borrows auf verschiedene Zeilen einer Matrix — möglich durch split_at_mut.
Borrow-Konflikt durch Restrukturierung lösen
struct Stats {
werte: Vec<f64>,
mittel: f64,
}
impl Stats {
// Schlecht: &mut self UND iter() braucht &self gleichzeitig
// pub fn aktualisiere(&mut self) {
// self.mittel = self.werte.iter().sum::<f64>() / self.werte.len() as f64;
// }
// Funktioniert tatsächlich — der Borrow Checker erkennt disjunkte Felder.
// Noch klarer per Hilfsfunktion:
pub fn aktualisiere(&mut self) {
self.mittel = Self::berechne_mittel(&self.werte);
}
fn berechne_mittel(werte: &[f64]) -> f64 {
werte.iter().sum::<f64>() / werte.len() as f64
}
}Wenn der Borrow Checker zu komplex wird: Berechnung in eine static Hilfsfunktion auslagern, die nur Slice-Parameter nimmt. Macht den Code auch testbarer.
Map-Update mit Entry-API
use std::collections::HashMap;
fn inkrementieren(zaehler: &mut HashMap<String, u32>, key: &str) {
// Idiomatisch mit Entry-API — ein Borrow für Lookup + ggf. Insert
*zaehler.entry(key.to_string()).or_insert(0) += 1;
}Die entry-API gibt einen mutable Borrow auf den Entry-Wert zurück und löst gleichzeitig „insert falls nicht da". Borrow-konfliktfrei.
Zwei Werte aus einem Vec gleichzeitig
fn paar_verarbeiten(v: &mut Vec<i32>, i: usize, j: usize) {
// v[i] und v[j] gleichzeitig mutable — direkt geht NICHT.
// Lösung: split_at_mut, oder einzeln per swap-Trick:
if i < j {
let (links, rechts) = v.split_at_mut(j);
links[i] += rechts[0];
}
}Direkter v[i] + v[j] mit beidem mutable scheitert am Borrow Checker. split_at_mut ist die idiomatische Lösung.
State-Machine mit lokalen Bindings
#[derive(Debug)]
enum State { Init, Aktiv(String), Beendet }
fn weiter(state: &mut State) {
// Wert herausnehmen, neuen einsetzen — mem::replace
let alt = std::mem::replace(state, State::Beendet);
*state = match alt {
State::Init => State::Aktiv(String::from("start")),
State::Aktiv(s) => {
println!("verlasse Aktiv mit: {s}");
State::Beendet
}
State::Beendet => State::Beendet,
};
}mem::replace nimmt den alten Wert heraus (gibt ihn als owned zurück) und ersetzt mit einem temporären — danach kann frei mit dem alten Wert gematcht werden. Klassisches State-Machine-Idiom.
Interessantes
NLL macht idiomatischen Code möglich.
Vor NLL waren viele natürlich wirkende Patterns Borrow-Konflikte — etwa „lies, dann schreib" in einer Zeile. Heute erkennt der Checker die letzte Verwendung eines Borrows und beendet ihn dort. Bei alten Tutorials, die Borrow-Konflikte zeigen, immer prüfen: gilt das heute noch?
Fehler-Codes lassen sich mit rustc --explain nachschlagen.
rustc --explain E0502 zeigt eine ausführliche Erklärung mit Beispiel-Code und Lösung. Sehr lehrreich beim Lernen — und auch später, wenn man ein selten gesehenes Fehler-Code-Format trifft.
Disjunkte Struct-Felder darfst du parallel mutable borgen.
let rx = &mut p.x; let ry = &mut p.y; ist erlaubt. Der Borrow Checker analysiert pro Feld. Bei Vec-Indices klappt das nicht — der Checker kann v[0] und v[1] nicht als disjunkt erkennen.
iter hält den Container shared geborgt.
Während eines for x in &v { ... }-Loops ist v als shared geborgt. Im Loop-Body kann v nicht mutable verwendet werden. Für In-Place-Mutation: iter_mut() benutzen.
RefCell ist die Notbremse, nicht der Standard.
Wenn der Compile-Time-Borrow-Check zu strikt für eine spezifische Situation ist, gibt es RefCell (Single-Threaded) oder Mutex (Multi-Threaded). Sie verschieben die Prüfung zur Laufzeit. Nutze sie gezielt — nicht als „macht den Compiler still"-Workaround.
Borrow-Konflikte sind oft Symptome von schlechtem Design.
Wenn der Borrow Checker hartnäckig klagt, lohnt ein Blick aufs Design. Hat eine Funktion zu viel Verantwortung? Sollte ein Wert in zwei separate Felder aufgeteilt werden? Oft löst Refactoring den Konflikt eleganter als technische Tricks.
Reborrowing wird oft implizit gemacht.
Wenn du eine &mut-Referenz an eine Funktion gibst, fügt der Compiler oft ein Reborrow ein. Damit ist die Original-Referenz nach dem Call wieder nutzbar. Mehr im Reborrowing-Artikel.
Der Borrow Checker macht keinen Lauf-Zeit-Overhead.
Die ganze Analyse ist Compile-Zeit. Das fertige Binary hat keinen einzigen Check eingebaut, der die Aliasing-Regeln zur Laufzeit prüft (außer du nutzt RefCell/Mutex explizit). Die Garantien sind kostenlos.
Weiterführende Ressourcen
Externe Quellen
- The Rust Book – References and Borrowing
- rustc Error Index
- Non-Lexical Lifetimes RFC
- The Rustonomicon – Aliasing
- std::cell::RefCell
- Slice::split_at_mut