Ein C-Programm, das einen Buffer freigibt und trotzdem weiter daraus liest, kompiliert problemlos. Danach beschädigt es den Speicher, manchmal sofort, manchmal erst drei Requests später, in einem Teil des Codes, der diesen Buffer nie berührt hat. Rust lehnt dieses Programm schon beim cargo build ab, ganz ohne Garbage Collector. Der Compiler verfolgt, wem jeder Wert gehört und wer ihn ansehen darf. Dieses Verfolgen ist Ownership und Borrowing.

Was sind Rusts Ownership-Regeln?

Rusts Ownership-Modell lässt sich auf drei Regeln zurückführen:

  1. Jeder Wert hat genau einen Owner.
  2. Es kann immer nur einen Owner gleichzeitig geben.
  3. Verlässt der Owner seinen Scope, wird der Wert gedroppt.

„Gedroppt" bedeutet: Sein Destruktor läuft, und sein Speicher wird sofort freigegeben, nicht erst dann, wenn ein Garbage Collector dazu kommt. Das ist der Kompromiss, auf den Rust sich einlässt: keine Runtime, die entscheidet, wann Speicher zurückgewonnen wird, dafür erzwingt der Compiler diese Regeln bei jeder Zeile, die Sie schreiben.

Regel 2 ist die, die anfangs am meisten verwirrt. Weisen Sie eine String einer zweiten Variable zu, und die erste funktioniert nicht mehr:

1
2
3
4
5
6
fn main() {
    let s1 = String::from("hello");
    let s2 = s1;

    println!("{s1}, world!");
}
1
2
3
4
5
6
7
8
9
error[E0382]: borrow of moved value: `s1`
 --> src/main.rs:5:16
  |
2 |     let s1 = String::from("hello");
  |         -- move occurs because `s1` has type `String`, which does not implement the `Copy` trait
3 |     let s2 = s1;
  |              -- value moved here
5 |     println!("{s1}, world!");
  |                ^^ value borrowed here after move

let s2 = s1 kopiert nicht die Bytes des Strings. String enthält einen Pointer auf einen Buffer auf dem Heap, eine Länge und eine Kapazität. Würde man diese Struct Bit für Bit kopieren, würden zwei Owner auf denselben Buffer zeigen, und wenn beide ihren Scope verlassen, würde der Buffer zweimal freigegeben. Rust vermeidet das, indem es die Zuweisung zu einem Move macht: s2 übernimmt den Besitz des Werts, und s1 ist nicht mehr gültig. Übergeben Sie eine String an eine Funktion, passiert dasselbe: Die Funktion übernimmt den Besitz, und die übergebene Variable wird beim Aufrufer ungültig.

Warum werden Integer in Rust nicht gemovt?

Integer haben dieses Problem nicht:

1
2
3
4
5
6
fn main() {
    let x = 5;
    let y = x;

    println!("{x} and {y}");
}

Dieser Code kompiliert. i32 implementiert den Copy-Trait, also dupliziert die Zuweisung den Wert, statt ihn zu moven. Es gibt nichts auf dem Heap, das doppelt freigegeben werden könnte, nur vier Bytes auf dem Stack. Jeder Integer-Typ, bool, jeder Fließkommatyp, char, und Tupel, die nur aus Copy-Typen bestehen ((i32, bool), nicht (i32, String)), verhalten sich genauso. Ein Typ kann Copy nur implementieren, wenn keiner seiner Bestandteile Drop implementiert — Rusts Art zu sagen: Wenn Aufräumen wichtig ist, muss das Duplizieren explizit passieren.

Wenn Sie eine zweite, unabhängige Kopie einer String oder eines Vec wollen, sagen Sie das mit .clone(). Das ist eine zusätzliche Heap-Allokation, und genau deshalb macht Rust das nicht stillschweigend bei jeder Zuweisung.

Wie funktioniert Borrowing in Rust?

Müsste jede Funktion, die einen Wert lesen will, dessen Besitz übernehmen, würden Sie am Ende aus jeder Funktion Werte in einem Tupel zurückgeben, nur um sie weiter benutzen zu können. Borrowing schafft das ab: Nehmen Sie eine Referenz mit &, benutzen Sie den Wert, und die Ownership wechselt nie.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
fn main() {
    let s1 = String::from("hello");
    let len = calculate_length(&s1);

    println!("The length of '{s1}' is {len}.");
}

fn calculate_length(s: &String) -> usize {
    s.len()
}

s1 bleibt nach dem Aufruf gültig, weil calculate_length ihn nur geborrowed hat. Der Parametertyp &String ist eine Referenz: Sie zeigt auf die Daten von s1, ohne sie zu besitzen, also gibt es nichts zu droppen, wenn der Borrow endet.

Zwei Regeln halten das sicher:

  • Zu jedem Zeitpunkt können Sie entweder eine mutable Referenz auf einen Wert haben oder beliebig viele immutable Referenzen, niemals beides.
  • Eine Referenz kann nie länger leben als die Daten, auf die sie zeigt.

Verstoßen Sie gegen die erste, stoppt der Compiler Sie:

1
2
3
4
5
6
7
8
9
fn main() {
    let mut s = String::from("hello");

    let r1 = &s;
    let r2 = &s;
    let r3 = &mut s;

    println!("{r1}, {r2}, and {r3}");
}
1
2
3
4
5
6
7
8
9
error[E0502]: cannot borrow `s` as mutable because it is also borrowed as immutable
 --> src/main.rs:6:14
  |
4 |     let r1 = &s;
  |              -- immutable borrow occurs here
6 |     let r3 = &mut s;
  |              ^^^^^^ mutable borrow occurs here
8 |     println!("{r1}, {r2}, and {r3}");
  |                -- immutable borrow later used here

Auch zwei mutable Referenzen auf denselben Wert gleichzeitig werden abgelehnt (error[E0499]: cannot borrow `s` as mutable more than once at a time). Keiner der beiden Fehler ist willkürlich. Eine immutable Referenz ist ein Versprechen an den Rest des Programms, dass sich der Wert nicht unter ihr ändert, und eine mutable Referenz braucht Exklusivität, um dieses Versprechen zu halten. Diese Regeln lassen genau den klassischen Thread-Safety-Bug aus C oder Go nicht mehr durch: zwei Codeteile, die gleichzeitig in denselben Speicher schreiben, während ein drittes ihn liest.

Das heißt nicht, dass Rust jede Race Condition abfängt, die Sie schreiben könnten. Sicheres Rust kann trotzdem an einem Mutex deadlocken oder Operationen in der falschen Reihenfolge ausführen, und unsafe-Blöcke können die Prüfungen komplett umgehen. Was der Borrow Checker eliminiert, ist eine bestimmte, sehr häufige Form des Bugs: aliasierter mutabler Speicher, erkannt, bevor das Programm läuft.

Was bedeuten Lifetime-Annotationen wie ‘a?

Jede Referenz hat eine Lifetime: den Scope, in dem sie gültig ist. Normalerweise findet der Compiler das selbst heraus. Er braucht Ihre Hilfe, wenn eine Funktion eine Referenz zurückgibt und mehr als eine Eingabereferenz im Spiel ist, weil er keine Möglichkeit hat zu wissen, an welche Eingabe die Ausgabe gebunden ist.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() { x } else { y }
}

fn main() {
    let string1 = String::from("abcd");
    let string2 = "xyz";

    let result = longest(string1.as_str(), string2);
    println!("The longest string is {result}");
}

'a ist ein Lifetime-Parameter, gesprochen „Tick-a". Es ist ein Name, keine Dauer. &'a str an beiden Parametern und am Rückgabetyp zu schreiben sagt dem Compiler: Die Referenz, die ich zurückgebe, lebt höchstens so lange wie die kürzere von x und y. Die Annotation ändert nicht, wie lange irgendetwas lebt. Sie beschreibt eine Beziehung, die der Compiler ohnehin prüfen muss, sodass er einen Aufruf abweisen kann, bei dem die zurückgegebene Referenz länger leben würde als eine ihrer Eingaben.

Sie schreiben 'a nicht überall, weil Rust es in den gängigen Fällen elidiert. Eine Funktion mit genau einem Referenzparameter weist diese Lifetime automatisch ihrem Rückgabetyp zu, und eine Methode, die &self nimmt, weist die Lifetime von self dem Rückgabetyp zu. Deshalb kompiliert fn first_word(s: &str) -> &str ohne jede Annotation: eine Eingabe, eine Ausgabe, nichts Mehrdeutiges. longest nimmt zwei unabhängige Referenzparameter und kein self, also hat die Elision nichts, an dem sie sich festhalten kann, und Sie schreiben die Lifetime von Hand.

Was tun, wenn der Borrow Checker Ihr Design blockiert

Die Beschwerden des Borrow Checkers wirken in den ersten Wochen feindlich. Und manchmal passt die naheliegende Version einer Datenstruktur überhaupt nicht zum One-Owner-at-a-Time-Modell: ein Baum mit Parent-Pointern, oder ein Cache, den zwei Subsysteme beide verändern müssen. Die pragmatischen Auswege:

  • .clone() den Wert, statt mit einem Borrow zu kämpfen. Das kostet eine Allokation, und es ist eine Ein-Zeilen-Korrektur, die Sie später optimieren können, sobald der Rest des Codes funktioniert.
  • Rc<T> (oder Arc<T> über Threads hinweg) für geteiltes Ownership, wenn mehr als ein Teil des Programms einen Wert legitim am Leben halten muss.
  • RefCell<T> (oder Mutex<T> über Threads hinweg), wenn Sie etwas über eine geteilte Referenz verändern müssen. Das verschiebt die Borrow-Prüfung von der Kompilierzeit auf die Laufzeit und tauscht einen Compiler-Fehler gegen einen Panic, falls Sie es falsch machen.

Für keine dieser Lösungen müssen Sie sich schämen. Sie zeigen auch, warum dieses Modell nicht für jedes Projekt richtig ist. Ein Einwegskript, das eine Datei liest und einen Report ausgibt, gewinnt nichts dadurch, Ownership bis auf das Byte zu verfolgen, und eine Sprache mit Garbage Collector und Goroutinen bringt Sie schneller zu einem funktionierenden nebenläufigen Programm, wenn Speichersicherheit zur Kompilierzeit keine Anforderung ist. Rust zahlt sich aus, wenn ein Use-after-free oder eine Data Race in Produktion der Fehlschlag ist, den Sie sich nicht leisten können, und Sie den Preis dafür lieber vorab in Form von Compiler-Fehlern zahlen.

Wenn rustc Ihren Code das nächste Mal mit E0382 oder E0502 ablehnt, lesen Sie den Fehler, bevor Sie reflexhaft zu .clone() greifen. Er benennt die beiden Borrows im Konflikt und wo jeder beginnt, und das Scope-Problem zu beheben, auf das er zeigt, braucht meist weniger Code, als es zu überdecken. Diese Meldungen sind der schnellste Weg, das Modell zu lernen; sie sind präziser als die meisten Erklärungen dazu, diese hier eingeschlossen.

Verwandte Artikel