Lebenszeiten von Referenzen
Der folgende Schnipsel wird in Rust bereits zur Kompilierzeit abgefangen, damit es nicht zu Speicherzugriffsverletzungen zur Laufzeit kommt.
#![allow(unused)] fn main() { let r; { let s = String::from("Welt"); r = &s; } println!("{r}"); }
Quiz
Warum würde es hier zu einer Speicherzugriffsverletzung kommen, wenn der Compiler die Übersetzung nicht verweigert hätte?
Lebenszeiten (engl. lifetimes) von Referenzen oder Zeigern auf Speicheradressen sind in vielen Programmiersprachen wichtig. In Rust wird im Gegensatz zu anderen Programmiersprachen die Gültigkeit einer Referenz bereits zur Kompilierzeit überprüft. Dazu braucht der Compiler in mehrdeutigen Situationen Hilfe. Teile des Typs müssen dann entsprechend annotieren. Das Ziel von Lifetimes ist es, baumelnde Referenzen auf ungültige Speicherbereiche (engl. dangling reference) zu verhindern. Zu diesem Zweck zeigt ein Liftime-Parameter dem Compiler an, wie lange eine Referenz benötigt wird. Der Compiler kann dann entscheiden, ob das Programm diese Anforderung unter allen Umständen erfüllen kann. Diese Überprüfung kann zu restritktiv sein, denn der Rust Compiler verfährt nach dem Motto better safe than sorry.
Generische Lifetime-Parameter von Funktionen
Im folgenden Schnispel versuchen wir eine Referenz auf eine lokale Variable zurückzugeben. Der Compiler verweigert glücklicherweise die Übersetzung.
#![allow(unused)] fn main() { // kompiliert nicht fn f() -> &f32 { let x = 0.0; &x } }
Während man in C++ mit einer entsprechenden Funktion eine Speicherzugriffsverletzung zur Laufzeit produziert hätte, bekommt man von einem bestimmten Teil des Rust Compilers, dem sogenannten Borrow-Checker, die folgende Meldung.
4 | fn f() -> &f32 {
| ^ expected named lifetime parameter
Lifetimes von Referenzen werden mit einem Parameter annotiert. Die Syntax ist etwas unüblich. Lifetime-Parameter
starten immer mit einem '
und sind üblicherweise kurz und klein benamt. Wenn in einer Funktion ein Lifetime-Parameter
verwendet wird, muss dieser Parameter einmal in spitzen Klammern direkt nach dem Funktionsnamen deklariert werden und in
allen benötigten Typen der Argumente und möglicherweise im Rückgabetypen annotiert werden.
#![allow(unused)] fn main() { // kompiliert nicht fn f<'a>() -> &'a f32 { let x = 0.0; &x } }
In diesem Fall sind wir dem Wunsch des Compilers nachgekommen und haben den Liftetime-Parameter 'a
hinzugefügt. Nun sagt
uns der Compiler richtigerweise er könne keine Referenz auf eine lokale Variable zurückgeben. Wenn eine Funktion Referenzen
zurückgeben möchte, muss sicher gestellt sein, dass nach Beendigung der Funktion die Referenzen noch gültig sind. Beispielsweise
können wir den kleineren zweier String
s bestimmen, ohne die Strings zu verschieben oder zu klonen.
#![allow(unused)] fn main() { fn smallest<'a>(s1: &'a str, s2: &'a str) -> &'a str { if s1.len() < s2.len() { s1 } else { s2 } } let s1 = String::from("Hallo"); let s2 = String::from("Welt"); println!("{}", smallest(&s1, &s2)); }
Der Lifetime-Parameter 'a
verbindet die Argumente s1
und s2
mit dem Rückgabewert. Er besagt also,
dass der Rückgabewert solange wie beide Eingabeparameter valide sein muss. Eigentlich muss der Rückgabewert
nur solange valide sein wie der kürzere der beiden String
s. Welcher das bei Aufruf der Funktion sein wird,
kann der Compiler aber im Allgemeinen nicht wissen. Dadurch, dass wir die Lebenszeiten aller 3 Referenzen
verknüpfen, verändern wir nicht, wie lange die dazugehörigen Referenzen valide sind. Wir sagen nur dem Borrow-Checker,
dass er alle Referenzen ablehnen soll, die unsere Anforderungen an Lifetimes nicht erfüllen. Den Parameter 'a
nennt
man auch generischen Parameter. Wenn die Funktion konkret verwendet wird, bekommt 'a
einen konkreten Wert und zwar
die kürzerer der Lebenszeiten der Eingabereferenzen.
Im folgenden Beispiel verwenden wir Eingabereferenzen mit unterschiedlichen Lebenszeiten.
fn smallest<'a>(s1: &'a str, s2: &'a str) -> &'a str { if s1.len() < s2.len() { s1 } else { s2 } } fn main() { let s1 = String::from("Hallo"); { let s2 = String::from("Welt"); println!("{}", smallest(&s1, &s2)); } }
Während Referenzen auf s1
bis ans Ende der Mainfunktion valide sind, begrenzt sich die Lebenszeit von s2
-Referenzen
auf den inneren Geltungsbereich. Die generische Lebenszeit 'a
entspricht in diesem Fall der Lebenszeit von &s2
, da diese
die kürzere der beiden ist. Um das zu verfizieren versuchen wir im folgenden Beispiel auf das Ergebnis der Funktion
außerhalb des Geltungsbereichs von s2
zuzugreifen.
//kompiliert nicht fn smallest<'a>(s1: &'a str, s2: &'a str) -> &'a str { if s1.len() < s2.len() { s1 } else { s2 } } fn main() { let s2 = String::from("Welt"); let result; { let s1 = String::from("Hallo"); result = smallest(&s1, &s2); } println!("{}", result); }
Der Borrow-Checker teilt uns mit, dass s1
nicht lange genug lebt. Wir sehen natürlich, dass die Funktion
smallest
gar nicht s1
sondern s2
zurückgegeben hätte. Der Compiler kann das aber nicht erkennen und ist
an dieser Stelle zu restriktiv.
Aufgabe
Erstelle verschiedene Versionen und Variationen der Funktion
smallest
mit unterschiedlichen Lifetime-Annotationen und versuche vor Ausführung vorherzusagen, ob der Compiler das akzeptieren wird.
Statische Lebenszeiten
Wenn kein Argument sondern nur der Rückgabewert einer Funktion eine Referenz ist, gibt es 2 Möglichkeiten:
- Das Programm wird nicht übersetzt, da eine Referenz auf eine lokale Variable der Funktion zurückgegeben wird.
- Es handelt sich um eine Referenz auf ein Literal mit statischer Lebenszeit. Das wird dem Compiler durch die
Annotation
'static
mitgeteilt.
#![allow(unused)] fn main() { fn f() -> &'static str { let literal = &"Hallo Welt"; literal } println!("{}", f()); }
Wie alle Literale von Zeichenketten hat die Variable literal
den Typ &'static str
.
In den wenigsten Fällen wird eine statische Lebenszeit wirklich benötigt. Das kann
durchaus auch dann der Fall sein, wenn der Compiler vorschlägt eine Lebenszeit als 'static
zu annotieren.
Auslassung von Lebenszeiten
Das folgende Beispiel funktioniert, obwohl keine Lifetime-Parameter verwendet werden.
#![allow(unused)] fn main() { fn f(s: &str, i: usize) -> &str { &s[i..] } println!("{}", f(&"ABC", 1)); }
Dieser Fall ist eindeutig. Da es nur eine Eingabe- und einen Ausgabereferenz gibt, nimmt der Compiler automatisch an, dass die Lebenszeiten identisch sind. Das Weglassen von Lifetime-Parametern wird auch Lifetime Elision genannt. Es gibt 3 Regeln für das Weglassen von Lifetime-Parametern:
- Jedes Argument dessen Typ eine Referenz ist, bekommt einen eigenen Lifetime-Parameter. Das heißt
f(n: &i32)
entsprichtf<'a>(n: &'a i32)
,f(n: &i32, m: &i32)
entsprichtf<'a, 'b>(n: &'a i32, m: &'b i32)
, ... . - Wenn es nur ein Referenz-Argument gibt, werden alle Rückgabereferenzen mit dem Lifetime-Parameter des Arguments
versehen. Beispielsweise erhalten wir
f<'a>(n: &'a i32) -> (&'a i32, &'a i32)
. - Bei Methoden wird der Lifetime-Parameter von
&self
für alle relevanten Rückgabewerte verwendet.
Falls sich aus den drei Regeln eine nicht-eindeutige Situation ergibt, verweigert der Compiler die Übersetzung.
Strukturtypen mit Referenzen
Wenn wir eine Referenz in einem Strukturtypen verwenden wollen, benötigen wir zwingend generische Liftime-Parameter. Das heißt,
#![allow(unused)] fn main() { struct Words { data: Vec<&str>, } }
kompiliert nicht. Wir müssen bei Strukturtypen und auch Aufzählungstypen immer Referenzen angeben.
struct Words<'a> { data: Vec<&'a str>, } fn main() { let data = vec!["Today", "is", "a", "good", "day"]; let words = Words{ data }; }
Damit sagen wir, das Instanzen des Typs Words
nicht länger im Geltungsbereich sein dürfen, als
die Variable data
. Implementierungsblöcke von Aufzählungs- und Strukturtypen benötigen ebenfalls
Lifetime-Parameter.
struct Words<'a> { data: Vec<&'a str> } impl<'a> Words<'a> { fn sort_alphabetically(&'a mut self) { self.data.sort(); } fn sort_by_len(&'a mut self) { self.data.sort_by_key(|v| v.len()); } } fn main() { let data = vec!["Today", "is", "a", "good", "day"]; let mut words = Words{ data }; words.sort_by_len(); words.sort_alphabetically(); }
Das vorherige Beispiel kompiliert allerdings nicht.
error[E0499]: cannot borrow `words` as mutable more than once at a time
Wir haben fälschlicherweise den Lifetime-Parameter 'a
zur Markierung der &self
-Parameter
verwendet. Damit teilen wir dem Compiler mit, dass die Lebenszeiten der veränderlichen Referenzen
auf self
der Länge den Lebenszeiten der Elemente von data
gleichen. Damit denkt der Compiler
zwei veränderliche Referenzen sind im kompletten Geltungsbereich von main
aktiv und mehrere Referenzen
auf eine Instanz sind nicht zulässig, sobald eine veränderlich ist. Wir können die Referenzen von Methoden
unabhängig von der Lebenszeit des Strukturtypen vergeben. In diesem Fall brauchen wir sie gar nicht angeben,
da die Situation eindeutig ist und der Compiler sie herleiten kann. Der folgende Schnipsel kompiliert dementsprechend.
struct Words<'a> { data: Vec<&'a str> } impl<'a> Words<'a> { fn sort_alphabetically(&mut self) { self.data.sort(); } fn sort_by_len(&mut self) { self.data.sort_by_key(|word| word.len()); } } fn main() { let data = vec!["today", "is", "a", "good", "day"]; let mut words = Words{ data }; words.sort_by_len(); println!("{:?}", words.data); words.sort_alphabetically(); println!("{:?}", words.data); }
Übrigens werden die Vec
-Methoden sort
und sort_by_key
freundlicherweise von der Rust Standardbibliothek
bereit gestellt. Während sort
eine dem Datentyp entsprechende Sortierung vornimmt, kann der Programmierer
bei Verwendung von sort_by_key
durch eine anonyme Funktion selber entscheiden, wonach sortiert werden soll.
Die anonyme Funktion bildet ein Element des Vektors auf das gewünschte Krieterium ab. Hier verwenden wir
|word: &str| word.len()
um die Länge der einzelnen Wörter zu berechnen.