Referenzen validieren mit Lebensdauern
Lebensdauer (lifetimes) sind eine weitere generische Funktionalität, die wir bereits verwendet haben. Anstatt sicherzustellen, dass ein Typ das von uns gewünschte Verhalten hat, stellen wir durch die Lebensdauer sicher, dass Referenzen so lange gültig sind, wie wir sie brauchen.
Ein Detail, das wir im Abschnitt „Referenzen und Ausleihen (borrowing)“ in Kapitel 4 nicht erörtert haben, ist, dass jede Referenz in Rust eine Lebensdauer (lifetime) hat, d.h. einen Gültigkeitsbereich, in dem diese Referenz gültig ist. In den meisten Fällen sind Lebensdauern implizit und abgeleitet, ebenso wie in den meisten Fällen Typen abgeleitet werden. Wir müssen Typen mit Annotationen versehen, wenn mehrere Typen möglich sind. In ähnlicher Weise müssen wir Lebensdauern annotieren, wenn die Lebensdauern von Referenzen auf verschiedene Weise miteinander in Beziehung gesetzt werden könnten. Rust verlangt von uns, die Beziehungen mit generischen Lebensdauerparametern zu annotieren, um sicherzustellen, dass die tatsächlich zur Laufzeit verwendeten Referenzen definitiv gültig sind.
Das Vermerken von Lebensdauern ist ein Konzept, das die meisten anderen Programmiersprachen nicht kennen, sodass es sich ungewohnt anfühlen wird. Auch wenn wir in diesem Kapitel die Lebensdauern nicht in ihrer Gesamtheit behandeln werden, so werden wir doch allgemeine Möglichkeiten erörtern, mit denen du dich mit der Syntax der Lebensdauer und den Konzepten vertraut machen kannst.
Verhindern hängender Referenzen mit Lebensdauern
Das Hauptziel der Lebensdauer ist es, hängende Referenzen (dangling references) zu verhindern, die dazu führen, dass ein Programm auf andere Daten referenziert als die, auf die es referenzieren soll. Betrachte das Programm in Codeblock 10-16, das einen äußeren und einen inneren Gültigkeitsbereich hat.
fn main() { let r; { let x = 5; r = &x; } println!("r: {r}"); }
Codeblock 10-16: Ein Versuch, eine Referenz zu verwenden, deren Wert außerhalb des Gültigkeitsbereichs liegt
Hinweis: Die Beispiele in den Codeblöcken 10-16, 10-17 und 10-23 deklarieren Variablen ohne Initialwert, sodass der Variablenname im äußeren Gültigkeitsbereich existiert. Auf den ersten Blick mag dies im Widerspruch dazu stehen, dass Rust keine Nullwerte hat. Wenn wir jedoch versuchen, eine Variable zu verwenden, bevor wir ihr einen Wert geben, erhalten wir einen Kompilierfehler, der zeigt, dass Rust tatsächlich keine Nullwerte zulässt.
Der äußere Gültigkeitsbereich deklariert eine Variable r
ohne Initialwert und
der innere Gültigkeitsbereich deklariert eine Variable x
mit dem Initialwert
5. Im inneren Gültigkeitsbereich versuchen wir, den Wert von r
als Referenz
auf x
zu setzen. Dann endet der innere Gültigkeitsbereich und wir versuchen,
den Wert in r
auszugeben. Dieser Code lässt sich nicht kompilieren, weil der
Wert, auf den sich r
bezieht, den Gültigkeitsbereich verlassen hat, bevor wir
versuchen, ihn zu verwenden. Hier ist die Fehlermeldung:
$ cargo run
Compiling chapter10 v0.1.0 (file:///projects/chapter10)
error[E0597]: `x` does not live long enough
--> src/main.rs:6:13
|
5 | let x = 5;
| - binding `x` declared here
6 | r = &x;
| ^^ borrowed value does not live long enough
7 | }
| - `x` dropped here while still borrowed
8 |
9 | println!("r: {r}");
| --- borrow later used here
For more information about this error, try `rustc --explain E0597`.
error: could not compile `chapter10` (bin "chapter10") due to 1 previous error
Die Variable x
lebt nicht „lange genug“. Der Grund dafür ist, dass x
den
Gültigkeitsbereich verlässt, da der innere Gültigkeitsbereich bei Zeile 7
endet. Aber r
ist im äußeren Gültigkeitsbereich immer noch gültig; da sein
Gültigkeitsbereich größer ist, sagen wir, dass es „länger lebt“. Wenn Rust
diesen Code funktionieren ließe, würde r
auf Speicher verweisen, der
freigegeben wurde, als x
den Gültigkeitsbereich verlassen hat, und alles, was
wir mit r
tun würden, würde nicht korrekt funktionieren. Wie stellt Rust also
fest, dass dieser Code ungültig ist? Es verwendet einen Ausleihenprüfer (borrow
checker).
Der Ausleihenprüfer
Der Rust-Compiler verfügt über einen Ausleihenprüfer (borrow checker), der Gültigkeitsbereiche vergleicht, um festzustellen, ob alle Ausleihen gültig sind. Codeblock 10-17 zeigt den gleichen Code wie Codeblock 10-16, jedoch mit Annotationen, die die Lebensdauer der Variablen angeben.
fn main() { let r; // ---------+-- 'a // | { // | let x = 5; // -+-- 'b | r = &x; // | | } // -+ | // | println!("r: {r}"); // | } // ---------+
Codeblock 10-17: Annotationen der Lebensdauern von r
und x
, genannt 'a
bzw. 'b
Hier haben wir die Lebensdauer von r
mit 'a
und die Lebensdauer von x
mit
'b
vermerkt. Wie du sehen kannst, ist der innere 'b
-Block viel kleiner als
der äußere 'a
-Lebensdauer-Block. Zur Kompilierzeit vergleicht Rust die Größe
der beiden Lebensdauern und stellt fest, dass r
eine Lebensdauer von 'a
hat, jedoch auf einen Speicherbereich mit Lebensdauern 'b
referenziert. Das
Programm wird abgelehnt, weil 'b
kürzer als 'a
ist: Der Referenzinhalt lebt
nicht so lange wie die Referenz selbst.
Mit Codeblock 10-18 wird der Code so korrigiert, dass er keine hängende Referenz hat und fehlerfrei kompiliert werden kann.
fn main() { let x = 5; // ----------+-- 'b // | let r = &x; // --+-- 'a | // | | println!("r: {r}"); // | | // --+ | } // ----------+
Codeblock 10-18: Eine gültige Referenz, da die Daten eine längere Lebensdauer als die Referenz haben
Hier hat x
die Lebensdauer 'b
, die in diesem Fall größer ist als 'a
. Das
bedeutet, dass r
auf x
referenzieren kann, weil Rust weiß, dass die
Referenz in r
immer gültig sein wird, solange x
gültig ist.
Da du nun weißt, wo die Lebensdauern von Referenzen sind und wie Rust die Lebensdauer analysiert, um sicherzustellen, dass Referenzen immer gültig sind, lass uns die generischen Lebensdauern von Parametern und Rückgabewerten im Kontext von Funktionen untersuchen.
Generische Lebensdauern in Funktionen
Wir schreiben eine Funktion, die den längeren von zwei
Zeichenkettenanteilstypen zurückgibt. Diese Funktion nimmt zwei
Zeichenkettenanteilstypen entgegen und gibt einen einzigen
Zeichenkettenanteilstyp zurück. Nachdem wir die Funktion longest
implementiert haben, sollte der Code in Codeblock 10-19 Die längere Zeichenkette ist abcd
ausgeben.
Datei: src/main.rs
fn main() {
let string1 = String::from("abcd");
let string2 = "xyz";
let result = longest(string1.as_str(), string2);
println!("Die längere Zeichenkette ist {result}");
}
Codeblock 10-19: Eine Funktion main
, die die Funktion
longest
aufruft, um die längere von zwei Zeichenkettenanteilstypen zu
bestimmen
Beachte, dass wir wollen, dass die Funktion Zeichenkettenanteilstypen nimmt,
die Referenzen sind und keine Zeichenketten, weil wir nicht wollen, dass die
Funktion longest
die Eigentümerschaft ihrer Parameter übernimmt. Lies den
Abschnitt „Zeichenkettenanteilstypen als
Parameter“ in Kapitel 4, um mehr darüber zu
erfahren, warum die Parameter, die wir in Codeblock 10-19 verwenden, die von
uns gewünschten sind.
Wenn wir versuchen, die Funktion longest
, wie in Codeblock 10-20 gezeigt, zu
implementieren, wird sie sich nicht kompilieren lassen.
Dateiname: src/main.rs
fn main() { let string1 = String::from("abcd"); let string2 = "xyz"; let result = longest(string1.as_str(), string2); println!("Die längere Zeichenkette ist {result}"); } fn longest(x: &str, y: &str) -> &str { if x.len() > y.len() { x } else { y } }
Codeblock 10-20: Eine Implementierung der Funktion
longest
, die die längere von zwei Zeichenkettenanteilstypen zurückgibt, aber
noch nicht kompiliert
Stattdessen erhalten wir folgenden Fehler, der von Lebensdauern spricht:
$ cargo run
Compiling chapter10 v0.1.0 (file:///projects/chapter10)
error[E0106]: missing lifetime specifier
--> src/main.rs:9:33
|
9 | fn longest(x: &str, y: &str) -> &str {
| ---- ---- ^ expected named lifetime parameter
|
= help: this function's return type contains a borrowed value, but the signature does not say whether it is borrowed from `x` or `y`
help: consider introducing a named lifetime parameter
|
9 | fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
| ++++ ++ ++ ++
For more information about this error, try `rustc --explain E0106`.
error: could not compile `chapter10` (bin "chapter10") due to 1 previous error
Aus dem Hilfetext geht hervor, dass der Rückgabetyp einen generischen
Lebensdauer-Parameter benötigt, da Rust nicht sagen kann, ob sich die
zurückgegebene Referenz auf x
oder auf y
bezieht. Eigentlich wissen wir es
auch nicht, weil der if
-Zweig im Funktionsrumpf eine Referenz auf x
und der
else
-Zweig eine Referenz auf y
zurückgibt!
Wenn wir diese Funktion definieren, kennen wir die konkreten Werte nicht, die
an diese Funktion übergeben werden, also wissen wir nicht, ob der if
-Zweig
oder der else
-Zweig ausgeführt wird. Wir kennen auch nicht die konkreten
Lebensdauern der Referenzen, die weitergegeben werden, sodass wir nicht wie in
den Codeblöcken 10-17 und 10-18 die Gültigkeitsbereiche betrachten können, um
festzustellen, ob die von uns zurückgegebene Referenz immer gültig sein wird.
Der Ausleihenprüfer kann dies auch nicht feststellen, weil er nicht weiß, wie
die Lebensdauer von x
und y
mit der Lebensdauer des Rückgabewertes
zusammenhängt. Um diesen Fehler zu beheben, geben wir generische
Lebensdauerparameter an, die die Beziehung zwischen den Referenzen definieren,
damit der Ausleihenprüfer seine Analyse durchführen kann.
Lebensdauer-Annotationssyntax
Lebensdauer-Annotationen ändern nichts daran, wie lange eine Referenz lebt. Vielmehr beschreiben sie die Beziehungen der Lebensdauern mehrerer Referenzen zueinander, ohne die Lebensdauern zu beeinflussen. Genauso wie Funktionen jeden Typ entgegennehmen können, wenn die Signatur einen generischen Typparameter angibt, können Funktionen Referenzen mit beliebiger Lebensdauer akzeptieren, indem sie einen generischen Lebensdauerparameter angeben.
Lebensdauer-Annotationen haben eine etwas ungewöhnliche Syntax: Die Namen der
Lebensdauer-Parameter müssen mit einem Apostroph ('
) beginnen und sind
normalerweise kleingeschrieben und sehr kurz, wie generische Typen. Die meisten
Menschen verwenden den Namen 'a
für die erste Lebensdauer-Annotationen. Wir
platzieren Lebensdauer-Parameter-Annotationen hinter dem &
einer Referenz,
wobei wir ein Leerzeichen verwenden, um die Annotation vom Typ der Referenz zu
trennen.
Hier sind einige Beispiele: Eine Referenz auf einen i32
ohne
Lebensdauer-Parameter, eine Referenz auf einen i32
, die einen
Lebensdauer-Parameter namens 'a
hat, und eine veränderbarer Referenz auf
einen i32
, die ebenfalls die Lebensdauer 'a
hat.
&i32 // eine Referenz
&'a i32 // eine Referenz mit expliziter Lebensdauer
&'a mut i32 // eine veränderbare Referenz mit expliziter Lebensdauer
Eine Lebensdauer-Annotation an sich hat nicht viel Bedeutung, da die
Annotationen Rust mitteilen sollen, wie sich generische
Lebensdauer-Parameter mehrerer Referenzen zueinander verhalten. Untersuchen
wir, wie sich die Lebensdauer-Annotationen im Zusammenhang mit der Funktion
longest
zueinander verhalten.
Lebensdauer-Annotationen in Funktionssignaturen
Um Lebensdauer-Annotationen in Funktionssignaturen zu verwenden, müssen wir die generischen Lebensdauer-Parameter in spitzen Klammern zwischen dem Funktionsnamen und der Parameterliste deklarieren, genau wie wir es mit den generischen Typ-Parametern gemacht haben.
Wir möchten, dass die Signatur die folgende Bedingung ausdrückt: Die
zurückgegebene Referenz ist gültig, solange die beiden Parameter gültig sind.
Dies ist die Beziehung zwischen den Lebensdauern der Parameter und des
Rückgabewerts. Wir nennen die Lebensdauer 'a
und fügen sie dann jeder
Referenz hinzu, wie in Codeblock 10-21 gezeigt.
Dateiname: src/main.rs
fn main() { let string1 = String::from("abcd"); let string2 = "xyz"; let result = longest(string1.as_str(), string2); println!("Die längere Zeichenkette ist {result}"); } fn longest<'a>(x: &'a str, y: &'a str) -> &'a str { if x.len() > y.len() { x } else { y } }
Codeblock 10-21: Die Funktionsdefinition longest
gibt
an, dass alle Referenzen in der Signatur die gleiche Lebensdauer 'a
haben
müssen
Dieser Code sollte kompilierbar sein und das gewünschte Ergebnis liefern, wenn
wir ihn mit der Funktion main
in Codeblock 10-19 verwenden.
Die Funktionssignatur sagt Rust, dass die Funktion für eine gewisse Lebensdauer
'a
zwei Parameter benötigt, die beide den Zeichenkettenanteilstyp haben und
mindestens so lange leben wie die Lebensdauer 'a
. Die Funktionssignatur sagt
Rust auch, dass der von der Funktion zurückgegebene Zeichenkettenanteilstyp
mindestens so lange leben wird wie die Lebensdauer 'a
. In der Praxis bedeutet
dies, dass die Lebensdauer der Referenz, die von der Funktion longest
zurückgegeben wird, der kleineren der Lebensdauern der Werte entspricht, auf
die sich die Funktionsargumente beziehen. Diese Beziehungen sollen von Rust
verwendet werden, wenn es diesen Code analysiert.
Denke daran, indem wir die Lebensdauerparameter in dieser Funktionssignatur
angeben, ändern wir nicht die Lebensdauer der übergebenen oder zurückgegebenen
Werte. Vielmehr legen wir fest, dass der Ausleihenprüfer alle Werte ablehnen
soll, die sich nicht an diese Bedingung halten. Beachte, dass die Funktion
longest
nicht genau wissen muss, wie lange x
und y
leben werden, nur dass
ein gewisser Gültigkeitsbereich für 'a
eingesetzt werden kann, der dieser
Signatur genügt.
Wenn Funktionen mit Lebensdauern annotiert werden, gehören die Annotationen zur Funktionssignatur, nicht zum Funktionsrumpf. Die Lebensdauer-Annotationen werden Teil des Funktionsvertrags, ähnlich wie die Typen in der Signatur. Wenn Funktionssignaturen den Lebensdauervertrag enthalten, kann die Analyse des Rust-Compilers einfacher sein. Wenn es ein Problem mit der Art und Weise gibt, wie eine Funktion annotiert ist oder wie sie aufgerufen wird, können die Compilerfehler auf den Teil unseres Codes und die Beschränkungen genauer hinweisen. Wenn der Rust-Compiler stattdessen mehr Rückschlüsse auf die von uns beabsichtigten Beziehungen der Lebensdauern ziehen würde, könnte der Compiler nur auf eine Verwendung unseres Codes hinweisen, die viele Schritte von der Ursache des Problems entfernt ist.
Wenn wir der Funktion longest
konkrete Referenzen übergeben, ist die konkrete
Lebensdauer, die an die Stelle von 'a
tritt, der Teil des Gültigkeitsbereichs
von x
, der sich mit dem Gültigkeitsbereich von y
überschneidet. Mit anderen
Worten bekommt die generische Lebensdauer 'a
die konkrete Lebensdauer, die
der kürzeren der Lebensdauern von x
und y
entspricht. Da wir die
zurückgegebene Referenz mit dem gleichen Lebensdauer-Parameter 'a
annotiert
haben, wird die zurückgegebene Referenz auch für die Dauer der kürzeren
Lebensdauer von x
und y
gültig sein.
Schauen wir uns an, wie die Lebensdauer-Annotationen die Funktion longest
beschränken, indem wir Referenzen mit unterschiedlichen konkreten Lebensdauern
übergeben. Codeblock 10-22 ist ein einfaches Beispiel.
Dateiname: src/main.rs
fn main() { let string1 = String::from("lange Zeichenkette ist lang"); { let string2 = String::from("xyz"); let result = longest(string1.as_str(), string2.as_str()); println!("Die längere Zeichenkette ist {result}"); } } fn longest<'a>(x: &'a str, y: &'a str) -> &'a str { if x.len() > y.len() { x } else { y } }
Codeblock 10-22: Verwenden der Funktion longest
mit
Referenzen auf Zeichenketten, die unterschiedliche konkrete Lebensdauern
haben
In diesem Beispiel ist string1
bis zum Ende des äußeren Gültigkeitsbereichs
gültig, string2
ist bis zum Ende des inneren Gültigkeitsbereichs gültig, und
result
referenziert auf etwas, das bis zum Ende des inneren
Gültigkeitsbereichs gültig ist. Führe diesen Code aus und du wirst sehen, dass
der Ausleihenprüfer diesen Code akzeptiert; er kompiliert und gibt Die längere Zeichenkette ist lange Zeichenkette ist lang
aus.
Versuchen wir als nächstes ein Beispiel, das zeigt, dass die Lebensdauer der
Referenz in result
die kürzere Lebensdauer der beiden Argumente sein muss.
Wir verschieben die Deklaration der Variable result
oberhalb des inneren
Gültigkeitsbereichs, lassen aber die Zuweisung des Wertes an die Variable
result
innerhalb des Gültigkeitsbereichs mit string2
. Dann verschieben wir
println!
, das result
verwendet, unterhalb des inneren Gültigkeitsbereichs.
Der Code in Codeblock 10-23 lässt sich nicht kompilieren.
Dateiname: src/main.rs
fn main() { let string1 = String::from("lange Zeichenkette ist lang"); let result; { let string2 = String::from("xyz"); result = longest(string1.as_str(), string2.as_str()); } println!("Die längere Zeichenkette ist {result}"); } fn longest<'a>(x: &'a str, y: &'a str) -> &'a str { if x.len() > y.len() { x } else { y } }
Codeblock 10-23: Der Versuch, result
zu verwenden,
nachdem string2
den Gültigkeitsbereich verlassen hat
Wenn wir versuchen, diesen Code zu kompilieren, erhalten wir folgenden Fehler:
$ cargo run
Compiling chapter10 v0.1.0 (file:///projects/chapter10)
error[E0597]: `string2` does not live long enough
--> src/main.rs:6:44
|
5 | let string2 = String::from("xyz");
| ------- binding `string2` declared here
6 | result = longest(string1.as_str(), string2.as_str());
| ^^^^^^^ borrowed value does not live long enough
7 | }
| - `string2` dropped here while still borrowed
8 | println!("Die längere Zeichenkette ist {result}");
| -------- borrow later used here
For more information about this error, try `rustc --explain E0597`.
error: could not compile `chapter10` (bin "chapter10") due to 1 previous error
Der Fehler zeigt, dass string2
bis zum Ende des äußeren Gültigkeitsbereichs
gültig sein müsste, damit result
in der Anweisung println!
noch gültig ist.
Rust weiß das, weil wir die Lebensdauer der Funktionsparameter und
Rückgabewerte mit dem gleichen Lebensdauerparameter 'a
annotiert haben.
Als Menschen können wir uns diesen Code ansehen und erkennen, dass string1
länger als string2
ist und deshalb wird result
eine Referenz auf string1
enthalten. Da string1
den Gültigkeitsbereich noch nicht verlassen hat, wird
eine Referenz auf string1
in der println!
-Anweisung noch gültig sein. Der
Compiler kann jedoch nicht sehen, dass die Referenz in diesem Fall gültig
ist. Wir haben Rust gesagt, dass die Lebensdauer der Referenz, die von der
Funktion longest
zurückgegeben wird, die gleiche ist wie die kürzere der
Lebensdauern der entgegengenommenen Referenzen. Daher lehnt der Ausleihenprüfer
den Code in Codeblock 10-23 als möglicherweise ungültige Referenz ab.
Versuche, dir weitere Experimente auszudenken, die die Werte und die
Lebensdauern der an die Funktion longest
übergebenen Referenzen variieren und
wie die zurückgegebene Referenz verwendet wird. Stelle Hypothesen darüber auf,
ob deine Experimente den Ausleihenprüfer bestehen oder nicht, bevor du
kompilierst; prüfe dann, ob du Recht hast!
Denken in Lebensdauern
Die Art und Weise, in der du Lebensdauerparameter angeben musst, hängt davon
ab, was deine Funktion tut. Wenn wir zum Beispiel die Implementierung der
Funktion longest
so ändern würden, dass sie immer den ersten Parameter
zurückgibt und nicht den längsten Zeichenkettenanteilstyp, bräuchten wir keine
Lebensdauer für den Parameter y
anzugeben. Der folgende Code wird
kompilieren:
Dateiname: src/main.rs
fn main() { let string1 = String::from("abcd"); let string2 = "efghijklmnopqrstuvwxyz"; let result = longest(string1.as_str(), string2); println!("Die längere Zeichenkette ist {result}"); } fn longest<'a>(x: &'a str, y: &str) -> &'a str { x }
Wir haben einen Lebensdauer-Parameter 'a
für den Parameter x
und den
Rückgabetyp angegeben, aber nicht für den Parameter y
, weil die Lebensdauer
von y
in keiner Beziehung zur Lebensdauer von x
oder dem Rückgabewert
steht.
Wenn eine Funktion eine Referenz zurückgibt, muss der Lebensdauerparameter für
den Rückgabetyp mit dem Lebensdauerparameter für einen der Parameter
übereinstimmen. Wenn sich die zurückgegebene Referenz nicht auf einen der
Parameter bezieht, muss er sich auf einen innerhalb dieser Funktion erzeugten
Wert beziehen. Dies wäre jedoch eine hängende Referenz, da der Wert am Ende der
Funktion den Gültigkeitsbereich verlässt. Betrachte diesen Versuch einer
Implementierung der Funktion longest
, die sich nicht kompilieren lässt:
Dateiname: src/main.rs
fn main() { let string1 = String::from("abcd"); let string2 = "xyz"; let result = longest(string1.as_str(), string2); println!("Die längere Zeichenkette ist {result}"); } fn longest<'a>(x: &str, y: &str) -> &'a str { let result = String::from("wirklich lange Zeichenkette"); result.as_str() }
Auch wenn wir hier einen Lebensdauer-Parameter 'a
für den Rückgabetyp
angegeben haben, wird diese Implementierung nicht kompilieren, weil die
Lebensdauer des Rückgabewerts überhaupt nicht mit der Lebensdauer der Parameter
zusammenhängt. Hier ist die Fehlermeldung, die wir erhalten:
$ cargo run
Compiling chapter10 v0.1.0 (file:///projects/chapter10)
error[E0515]: cannot return value referencing local variable `result`
--> src/main.rs:11:5
|
11 | result.as_str()
| ------^^^^^^^^^
| |
| returns a value referencing data owned by the current function
| `result` is borrowed here
For more information about this error, try `rustc --explain E0515`.
error: could not compile `chapter10` (bin "chapter10") due to 1 previous error
Das Problem ist, dass result
den Gültigkeitsbereich verlässt und am Ende der
Funktion longest
aufgeräumt wird. Wir versuchen auch, eine Referenz auf den
Wert in result
zurückzugeben. Es gibt keine Möglichkeit, Lebensdauerparameter
so anzugeben, dass die hängende Referenz beseitigt wird, Rust lässt uns also
keine hängende Referenz erstellen. In diesem Fall wäre die beste Lösung, einen
eigenen Datentyp statt einer Referenz zurückzugeben, sodass die aufrufende
Funktion dann für das Aufräumen des Wertes verantwortlich ist.
Letztlich geht es bei der Lebensdauersyntax darum, die Lebensdauern verschiedener Parameter und Rückgabewerte von Funktionen miteinander zu verbinden. Sobald sie verbunden sind, verfügt Rust über genügend Informationen, um speichersichere Operationen zu ermöglichen und Operationen zu unterbinden, die hängende Zeiger erzeugen oder anderweitig die Speichersicherheit verletzen würden.
Lebensdauer-Annotationen in Struktur-Definitionen
Bisher haben wir nur Strukturen (structs) definiert, die aneigenbare Typen
enthalten. Es ist möglich, dass Strukturen Referenzen enthalten, aber in diesem
Fall müssten wir Lebensdauer-Annotationen zu jeder Referenz in der
Strukturdefinition angeben. Codeblock 10-24 hat eine Struktur namens
ImportantExcerpt
, die einen Zeichenkettenanteilstyp enthält.
Dateiname: src/main.rs
struct ImportantExcerpt<'a> { part: &'a str, } fn main() { let novel = String::from("Nennen Sie mich Ishmael. Vor einigen Jahren ..."); let first_sentence = novel.split('.').next().expect("Konnte keinen '.' finden."); let i = ImportantExcerpt { part: first_sentence, }; }
Codeblock 10-24: Eine Struktur, die eine Referenz enthält, sodass ihre Definition eine Lebensdauer-Annotationen benötigt
Diese Struktur hat das einzige Feld part
, das einen Zeichenkettenanteilstyp
enthält, der eine Referenz ist. Wie bei generischen Datentypen deklarieren wir
den Namen des generischen Lebensdauerparameters innerhalb spitzer Klammern
hinter dem Strukturnamen, damit wir den Lebensdauerparameter im Rumpf der
Strukturdefinition verwenden können. Diese Annotation bedeutet, dass eine
Instanz von ImportantExcerpt
die Referenz, die sie in ihrem Feld part
enthält, nicht überleben kann.
Die Funktion main
erzeugt hier eine Instanz der Struktur ImportantExcerpt
,
die eine Referenz auf den ersten Satz des String
enthält, der der Variablen
novel
gehört. Die Daten in novel
existieren, bevor die Instanz
ImportantExcerpt
erzeugt wird. Darüber hinaus verlässt novel
den
Gültigkeitsbereich erst, nachdem ImportantExcerpt
den Gültigkeitsbereich
verlassen hat, sodass die Referenz in der ImportantExcerpt
-Instanz gültig
ist.
Lebensdauer-Elision
Du hast gelernt, dass jede Referenz eine Lebensdauer hat und dass du Lebensdauerparameter für Funktionen oder Strukturen angeben musst, die Referenzen verwenden. In Kapitel 4 hatten wir jedoch eine Funktion in Codeblock 4-9, die wiederum in Codeblock 10-25 gezeigt wird, die ohne Lebensdauer-Annotationen kompiliert.
Dateiname: src/lib.rs
fn first_word(s: &str) -> &str { let bytes = s.as_bytes(); for (i, &item) in bytes.iter().enumerate() { if item == b' ' { return &s[0..i]; } } &s[..] } fn main() { let my_string = String::from("Hallo Welt"); // first_word funktioniert mit Anteilstypen von `String` let word = first_word(&my_string[..]); let my_string_literal = "Hallo Welt"; // first_word funktioniert mit Anteilstypen von Zeichenkettenliteralen let word = first_word(&my_string_literal[..]); // Da Zeichenkettenliterale bereits Zeichenkettenanteilstypen sind, // funktioniert dies auch ohne die Anteilstypensyntax! let word = first_word(my_string_literal); }
Codeblock 10-25: Eine Funktion, die wir in Codeblock 4-9 definiert haben und die ohne Lebensdauer-Annotationen kompiliert, obwohl Parameter und Rückgabetyp Referenzen sind
Der Grund, warum diese Funktion ohne Lebensdauer-Annotationen kompiliert, ist historisch bedingt: In frühen Versionen (vor 1.0) von Rust hätte sich dieser Code nicht kompilieren lassen, da jede Referenz eine explizite Lebensdauer benötigte. Damals wäre die Funktionssignatur so geschrieben worden:
fn first_word<'a>(s: &'a str) -> &'a str {
Nachdem jede Menge Rust-Code geschrieben wurde, stellte das Rust-Team fest, dass die Rust-Programmierer in bestimmten Situationen immer wieder die gleichen Lebensdauer-Annotationen angaben. Diese Situationen waren vorhersehbar und folgten einigen wenigen deterministischen Mustern. Die Entwickler programmierten diese Muster in den Code des Compilers, sodass der Ausleihenprüfer in diesen Situationen auf die Lebensdauer schließen konnte und keine expliziten Annotationen benötigte.
Dieses Stück Rust-Geschichte ist relevant, weil es möglich ist, dass weitere deterministische Muster auftauchen und dem Compiler hinzugefügt werden. In Zukunft könnten noch weniger Lebensdauer-Annotationen erforderlich sein.
Die Muster, die in Rusts Referenzanalyse programmiert sind, werden die Lebensdauer-Elisionsregeln (lifetime elision rules) genannt. Dies sind keine Regeln, die Programmierer befolgen müssen; es handelt sich um eine Reihe besonderer Fälle, die der Compiler berücksichtigt, und wenn dein Code zu einem dieser Fälle passt, brauchst du die Lebensdauer nicht explizit anzugeben.
Die Elisionsregeln bieten keine vollständige Schlussfolgerung. Wenn Rust die Regeln deterministisch anwendet, aber immer noch Unklarheit darüber besteht, welche Lebensdauer die Referenzen haben, wird der Compiler nicht erraten, wie lang die Lebensdauer der verbleibenden Referenzen sein sollte. Statt einer Vermutung gibt dir der Compiler einen Fehler an, den du beheben kannst, indem du die Lebensdauer-Annotationen angibst, die festlegen, wie sich die Referenzen zueinander verhalten.
Die Lebensdauern der Funktions- oder Methodenparameter werden als Eingangslebensdauern (input lifetimes) bezeichnet, und die Lebensdauern der Rückgabewerte als Ausgangslebensdauern (output lifetimes) bezeichnet.
Der Compiler verwendet drei Regeln, um herauszufinden, welche Lebensdauer
Referenzen haben, wenn keine expliziten Annotationen vorhanden sind. Die erste
Regel gilt für Eingangslebensdauern und die zweite und dritte Regel gelten für
Ausgangslebensdauern. Wenn der Compiler das Ende der drei Regeln erreicht
und es immer noch Referenzen gibt, für die er keine Lebensdauern ermitteln
kann, bricht der Compiler mit einem Fehler ab. Diese Regeln gelten sowohl
für fn
-Definitionen als auch für impl
-Blöcke.
Die erste Regel ist, dass der Compiler jedem Parameter, der eine Referenz ist,
seinen eigenen Lebensdauerparameter zuweist. Mit anderen Worten, eine Funktion
mit einem Parameter erhält einen Lebensdauerparameter: fn foo<'a>(x: &'a i32)
; eine Funktion mit zwei Parametern erhält zwei separate
Lebensdauerparameter: fn foo<'a, 'b>(x: &'a i32, y: &'b i32)
; und so weiter.
Die zweite Regel lautet: Wenn es genau einen Eingangslebensdauer-Parameter
gibt, wird diese Lebensdauer allen Ausgangslebensdauer-Parametern zugewiesen:
fn foo<'a>(x: &'a i32) -> &'a i32
.
Die dritte Regel lautet: Wenn es mehrere Eingangslebensdauer-Parameter gibt,
aber einer davon &self
oder &mut self
ist, weil dies eine Methode ist, wird
die Lebensdauer von self
allen Ausgangslebensdauer-Parametern zugewiesen.
Diese dritte Regel macht Methoden viel angenehmer zu lesen und zu schreiben,
weil weniger Symbole erforderlich sind.
Tun wir so, als wären wir der Compiler. Wir werden diese Regeln anwenden, um
herauszufinden, wie lang die Lebensdauer der Referenzen in der Signatur der
Funktion first_word
in Codeblock 10-26 ist. Die Signatur beginnt ohne
Lebensdauern:
fn first_word(s: &str) -> &str {
Dann wendet der Compiler die erste Regel an, die festlegt, dass jeder
Parameter seine eigene Lebensdauer erhält. Wir nennen sie wie üblich 'a
, also
sieht die Signatur jetzt so aus:
fn first_word<'a>(s: &'a str) -> &str {
Die zweite Regel trifft zu, weil es genau eine Eingangslebensdauer gibt. Die zweite Regel legt fest, dass die Lebensdauer des einen Eingabeparameters der Ausgangslebensdauer zugeordnet wird, sodass die Signatur nun wie folgt aussieht:
fn first_word<'a>(s: &'a str) -> &'a str {
Jetzt haben alle Referenzen in dieser Funktionssignatur eine Lebensdauer und der Compiler kann seine Analyse fortsetzen, ohne dass der Programmierer die Lebensdauer in dieser Funktionssignatur annotieren muss.
Schauen wir uns ein anderes Beispiel an, diesmal mit der Funktion longest
,
die keine Lebensdauerparameter hatte, als wir in Codeblock 10-20 mit ihr zu
arbeiten begannen:
fn longest(x: &str, y: &str) -> &str {
Wenden wir die erste Regel an: Jeder Parameter erhält seine eigene Lebensdauer. Diesmal haben wir zwei Parameter anstelle von einem, also haben wir zwei Lebensdauern:
fn longest<'a, 'b>(x: &'a str, y: &'b str) -> &str {
Du siehst, dass die zweite Regel nicht gilt, weil es mehr als eine
Eingangslebensdauer gibt. Auch die dritte Regel trifft nicht zu, weil longest
eine Funktion ist, keine Methode, sodass keiner der Parameter self
ist.
Nachdem wir alle drei Regeln durchgearbeitet haben, haben wir immer noch nicht
herausgefunden, wie lang die Lebensdauer des Rückgabetyps ist. Aus diesem Grund
haben wir beim Versuch, den Code in Codeblock 10-20 zu kompilieren, einen
Fehler erhalten: Der Compiler arbeitete die Lebensdauer-Elisionsregeln
durch, konnte aber immer noch nicht alle Lebensdauern der Referenzen in der
Signatur ermitteln.
Da die dritte Regel eigentlich nur für Methodensignaturen gilt, werden wir uns als nächstes die Lebensdauern in diesem Zusammenhang ansehen, um zu sehen, warum die dritte Regel bedeutet, dass wir die Lebensdauer in Methodensignaturen nicht sehr oft annotieren müssen.
Lebensdauer-Annotationen in Methodendefinitionen
Wenn wir Methoden auf einer Struktur mit Lebensdauer implementieren, verwenden wir die gleiche Syntax wie die in Codeblock 10-11 gezeigten generischen Typparameter. Wo wir die Lebensdauerparameter deklarieren und verwenden, hängt davon ab, ob sie sich auf die Strukturfelder oder auf die Methodenparameter und Rückgabewerte beziehen.
Lebensdauer-Namen für Struktur-Felder müssen immer nach dem
impl
-Schlüsselwort deklariert und dann hinter dem Namen der Struktur verwendet
werden, da diese Lebensdauern Teil des Typs der Struktur sind.
In Methodensignaturen innerhalb des impl
-Blocks können Referenzen an die
Lebensdauern der Referenzen in den Feldern der Struktur gebunden sein oder sie
können unabhängig sein. Darüber hinaus sorgen die Lebensdauer-Elisionsregeln
oft dafür, dass Lebensdauer-Annotationen in Methodensignaturen nicht
erforderlich sind. Betrachten wir einige Beispiele mit der Struktur
ImportantExcerpt
, die wir in Codeblock 10-24 definiert haben.
Zuerst werden wir eine Methode namens level
verwenden, deren einziger
Parameter eine Referenz auf self
ist und deren Rückgabewert ein i32
ist,
was keine Referenz ist:
struct ImportantExcerpt<'a> { part: &'a str, } impl<'a> ImportantExcerpt<'a> { fn level(&self) -> i32 { 3 } } impl<'a> ImportantExcerpt<'a> { fn announce_and_return_part(&self, announcement: &str) -> &str { println!("Bitte um Aufmerksamkeit: {announcement}"); self.part } } fn main() { let novel = String::from("Nennen Sie mich Ishmael. Vor einigen Jahren ..."); let first_sentence = novel.split('.').next().expect("Konnte keinen '.' finden."); let i = ImportantExcerpt { part: first_sentence, }; }
Die Lebensdauer-Parameter-Deklaration nach impl
und ihre Verwendung hinter dem
Typnamen sind erforderlich, aber wir sind nicht verpflichtet, die Lebensdauer der
Referenz auf self
wegen der ersten Elisionsregel zu annotieren.
Hier ist ein Beispiel, bei dem die dritte Lebensdauer-Elisionsregel gilt:
struct ImportantExcerpt<'a> { part: &'a str, } impl<'a> ImportantExcerpt<'a> { fn level(&self) -> i32 { 3 } } impl<'a> ImportantExcerpt<'a> { fn announce_and_return_part(&self, announcement: &str) -> &str { println!("Bitte um Aufmerksamkeit: {announcement}"); self.part } } fn main() { let novel = String::from("Nennen Sie mich Ishmael. Vor einigen Jahren ..."); let first_sentence = novel.split('.').next().expect("Konnte keinen '.' finden."); let i = ImportantExcerpt { part: first_sentence, }; }
Es gibt zwei Eingangslebensdauern, sodass Rust die erste
Lebensdauer-Elisionsregel anwendet und sowohl &self
als auch announcement
ihre eigene Lebensdauer gibt. Da einer der Parameter &self
ist, erhält der
Rückgabetyp die Lebensdauer von &self
, und alle Lebensdauern sind
berücksichtigt worden.
Statische Lebensdauer
Eine besondere Lebensdauer, die wir besprechen müssen, ist 'static
, was
bedeutet, dass diese Referenz während der gesamten Dauer des Programms bestehen
kann. Alle Zeichenkettenliterale haben die Lebensdauer 'static
. Sie wird wie
folgt annotiert:
#![allow(unused)] fn main() { let s: &'static str = "Ich habe eine statische Lebensdauer."; }
Der Text dieser Zeichenkette wird direkt in der Binärdatei des Programms
gespeichert, die immer verfügbar ist. Daher ist die Lebensdauer aller
Zeichenkettenliterale 'static
.
Möglicherweise siehst du Hinweise zur Verwendung der Lebensdauer 'static
in
Fehlermeldungen. Aber bevor du 'static
als Lebensdauer für eine Referenz
angibst, denke darüber nach, ob deine Referenz tatsächlich während der gesamten
Lebensdauer deines Programms lebt oder nicht, und ob du das so willst. In den
meisten Fällen resultiert eine Fehlermeldung, die auf die Lebensdauer 'static
hindeutet, aus dem Versuch, eine hängende Referenz zu erstellen, oder aus einer
Nichtübereinstimmung der verfügbaren Lebensdauern. In solchen Fällen besteht
die Lösung darin, diese Probleme zu beheben und nicht darin, die Lebensdauer
als 'static
festzulegen.
Generische Typparameter, Merkmalsabgrenzungen und Lebensdauern zusammen
Schauen wir uns kurz die Syntax zu Angabe generischer Typparameter, Merkmalsabgrenzungen und Lebensdauern in einer Funktion an!
fn main() { let string1 = String::from("abcd"); let string2 = "xyz"; let result = longest_with_an_announcement( string1.as_str(), string2, "Heute hat jemand Geburtstag!", ); println!("Die längere Zeichenkette ist {result}"); } use std::fmt::Display; fn longest_with_an_announcement<'a, T>( x: &'a str, y: &'a str, ann: T, ) -> &'a str where T: Display, { println!("Bekanntmachung! {ann}"); if x.len() > y.len() { x } else { y } }
Dies ist die Funktion longest
aus Codeblock 10-21, die die längere von zwei
Zeichenkettenanteilstypen zurückgibt. Aber jetzt hat sie einen zusätzlichen
Parameter namens ann
vom generischen Typ T
, der jeder beliebige Typ sein
kann, der das Merkmal Display
implementiert, wie in der where
-Klausel
spezifiziert ist. Dieser zusätzliche Parameter wird unter Verwendung von
{ann}
ausgegeben, weshalb die Merkmalsabgrenzung Display
erforderlich ist.
Da die Lebensdauer ein generischer Typ ist, stehen die Deklarationen des
Lebensdauer-Parameters 'a
und des generischen Typ-Parameters T
in der
gleichen Liste innerhalb spitzer Klammern hinter dem Funktionsnamen.
Zusammenfassung
Wir haben in diesem Kapitel viel behandelt! Jetzt, da du über generische Typparameter, Merkmale und Merkmalsabgrenzungen sowie generische Lebensdauerparameter Bescheid weißt, bist du bereit, Code ohne Wiederholungen zu schreiben, der in vielen verschiedenen Situationen funktioniert. Merkmale und Merkmalsabgrenzungen stellen sicher, dass die Typen, auch wenn sie generisch sind, das Verhalten haben, das der Code benötigt. Du hast gelernt, wie man Lebensdauer-Annotationen verwendet, um sicherzustellen, dass dieser flexible Code keine hängenden Referenzen hat. Und all diese Analysen finden zur Kompilierzeit statt, was die Laufzeitperformanz nicht beeinträchtigt!
Ob du es glaubst oder nicht, es gibt zu den Themen, die wir in diesem Kapitel besprochen haben, noch viel mehr zu sagen: In Kapitel 17 werden Merkmalsobjekte erörtert, die eine weitere Möglichkeit zur Verwendung von Merkmalen darstellen. Es gibt auch komplexere Szenarien mit Lebensdauer-Annotationen, die du nur in sehr fortgeschrittenen Szenarien benötigst; für diese solltest du die Rust-Referenz lesen. Aber als Nächstes wirst du lernen, wie man Tests in Rust schreibt, damit du sicherstellen kannst, dass dein Code so funktioniert, wie er es soll.