Was sind non-lexical lifetimes? Einführung in die wichtigste Verbesserung von Rust
\n\nJeder, der auch nur ein paar Wochen mit Rust gearbeitet hat, kennt die Situation: Der Code ist absolut logisch und sicher, aber der Compiler (genauer gesagt sein strenger Borrow Checker) weigert sich, ihn zu kompilieren. Sie sehen sich den Fehler an und denken: „Aber ich verwende diese Referenz doch gar nicht mehr! Warum meckert er?“.
\n\nBis zur Rust-Version 1.31 (2018 Edition) war die Antwort einfach: „Weil die Lebensdauer einer Variable (Lifetime) lexikalisch durch den Gültigkeitsbereich bestimmt wird.“ Das sind die Lexical Lifetimes. Seit Rust 2018 ist der Compiler schlauer geworden. Er hat Non-lexical Lifetimes (NLL) erhalten – nicht-lexikalische Lebensdauern.
\n\nIn diesem Artikel werden wir uns ansehen, was das für ein Konzept ist, warum seine Einführung eine Revolution für Rust-Entwickler war und wie es den Code sauberer und intuitiver macht. Wir werden uns nicht in den Tiefen der Typentheorie verlieren, sondern uns auf praktische Beispiele konzentrieren.
\n\nDas Problem der Lexical Lifetimes: Warum der Borrow Checker zu streng war
\n\nErinnern wir uns daran, wie das Ausleihen (Borrowing) in Rust funktioniert. Es gibt die Regeln:
\n- \n
- Zu jedem Zeitpunkt kann es entweder eine veränderbare Referenz (&mut T) oder eine beliebige Anzahl unveränderbarer Referenzen (&T) geben. \n
- Referenzen müssen immer gültig sein (man kann nicht auf gelöschte Daten verweisen). \n
Vor NLL bestimmte der Compiler die Lebensdauer einer Referenz anhand ihres lexikalischen Gültigkeitsbereichs. Das heißt von { bis }, wo die Variable deklariert ist. Wenn eine Referenz innerhalb eines Blocks erstellt wird, „lebt“ sie bis zum Ende dieses Blocks, selbst wenn Sie sie nicht mehr verwenden.
Sehen Sie sich dieses klassische Beispiel an, das in älteren Rust-Versionen nicht kompilierte:
\n\nfn main() {\n let mut data = vec![1, 2, 3];\n\n let x = &data[0]; // unveränderbare Referenz\n\n data.push(4); // Versuch einer veränderbaren Ausleihe\n\n println!("{}", x); // Verwendung der unveränderbaren Referenz\n}\n\nAus menschlicher Sicht: Wir haben eine Referenz x erstellt, dann den Vektor geändert und dann x verwendet. Aber der Vektor data hätte beim Aufruf von push den Speicher neu zuweisen können, und die Referenz x wäre ungültig geworden. Der Compiler hat recht – das ist gefährlich.
Was aber, wenn wir die Referenz vor der Änderung verwenden? Hier liegt der eigentliche Schmerz:
\n\nfn main() {\n let mut name = String::from("Alice");\n\n let first_char = &name[0..1]; // unveränderbare Referenz\n println!("First char: {}", first_char); // wir verwenden sie\n\n name.push_str(" Johnson"); // wir ändern den String\n println!("Full name: {}", name);\n}\n\nDieser Code ist sicher. Wir haben die Referenz first_char verwendet, ausgegeben und dann den String geändert. Aber vor Rust 2018 gab der Compiler einen Fehler aus! Warum? Weil die lexikalische Lebensdauer der Referenz first_char bis zum Ende der Funktion main dauerte, also bis zur schließenden geschweiften Klammer. Und eine veränderbare Ausleihe (push_str) innerhalb dieses Zeitraums ist verboten.
Entwickler mussten Workarounds verwenden: zusätzliche Blöcke {} einführen, um die Lebensdauer der Referenz manuell zu begrenzen:
fn main() {\n let mut name = String::from("Alice");\n\n {\n let first_char = &name[0..1];\n println!("First char: {}", first_char);\n } // Referenz first_char stirbt hier\n\n name.push_str(" Johnson");\n println!("Full name: {}", name);\n}\n\nDas funktionierte, war aber umständlich, verunreinigte den Code mit unnötigen Klammern und erschwerte das Lesen. Besonders in komplexen Funktionen mit mehreren Ausleihen.
\n\nNon-lexical Lifetimes (NLL): Wie es funktioniert
\n\nDie Idee von NLL ist in ihrer Einfachheit genial: Die Lebensdauer einer Referenz wird nicht durch den Gültigkeitsbereich bestimmt, sondern dort, wo sie tatsächlich verwendet wird.