Walidacja referencji za pomocą czasów życia
Czasy życia to kolejny rodzaj generyków, których już używaliśmy. Zamiast zapewniać, że typ ma pożądane przez nas zachowanie, czasy życia zapewniają, że referencje są ważne tak długo, jak tego potrzebujemy.
Jednym szczegółem, o którym nie rozmawialiśmy w sekcji „Referencje i pożyczanie” w Rozdziale 4, jest to, że każda referencja w Rust ma czas życia, który jest zasięgiem, w którym ta referencja jest ważna. Przez większość czasu, czasy życia są domyślne i wnioskowane, tak jak przez większość czasu, typy są wnioskowane. Jesteśmy zobowiązani do adnotowania typów tylko wtedy, gdy możliwych jest wiele typów. W podobny sposób musimy adnotować czasy życia, gdy czasy życia referencji mogą być powiązane na kilka różnych sposobów. Rust wymaga od nas adnotowania relacji za pomocą ogólnych parametrów czasów życia, aby zapewnić, że rzeczywiste referencje używane w czasie wykonania będą z pewnością ważne.
Adnotowanie czasów życia nie jest nawet koncepcją, którą posiada większość innych języków programowania, więc będzie to wydawać się nieznane. Chociaż w tym rozdziale nie omówimy czasów życia w całości, to jednak przedstawimy typowe sposoby, w jakie można napotkać składnię czasów życia, aby można było się z nią oswoić.
Wiszące referencje
Głównym celem czasów życia jest zapobieganie wiszącym referencjom, które, gdyby były dozwolone, spowodowałyby, że program odnosiłby się do danych innych niż te, do których miał się odnosić. Rozważ program z Listingu 10-16, który ma zasięg zewnętrzny i zasięg wewnętrzny.
fn main() {
let r;
{
let x = 5;
r = &x;
}
println!("r: {r}");
}
Uwaga: Przykłady z Listingów 10-16, 10-17 i 10-23 deklarują zmienne bez nadawania im wartości początkowej, więc nazwa zmiennej istnieje w zasięgu zewnętrznym. Na pierwszy rzut oka może to wydawać się sprzeczne z tym, że Rust nie ma wartości null. Jednakże, jeśli spróbujemy użyć zmiennej przed nadaniem jej wartości, otrzymamy błąd kompilacji, co pokazuje, że Rust faktycznie nie zezwala na wartości null.
Zasięg zewnętrzny deklaruje zmienną r bez wartości początkowej, a zasięg wewnętrzny deklaruje zmienną x z wartością początkową 5. Wewnątrz zasięgu wewnętrznego próbujemy ustawić wartość r jako referencję do x. Następnie zasięg wewnętrzny kończy się, a my próbujemy wypisać wartość r. Ten kod nie skompiluje się, ponieważ wartość, do której odnosi się r, wyszła poza zasięg, zanim spróbowaliśmy jej użyć. Oto komunikat o błędzie:
$ 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
Komunikat o błędzie mówi, że zmienna x „nie żyje wystarczająco długo”. Powodem jest to, że x wyjdzie poza zasięg, gdy zasięg wewnętrzny zakończy się w wierszu 7. Ale r jest nadal ważne dla zasięgu zewnętrznego; ponieważ jego zasięg jest większy, mówimy, że „żyje dłużej”. Gdyby Rust pozwolił na działanie tego kodu, r odwoływałoby się do pamięci, która została zwolniona, gdy x wyszło poza zasięg, a wszystko, co próbowalibyśmy zrobić z r, nie działałoby poprawnie. Jak więc Rust ustala, że ten kod jest nieprawidłowy? Używa sprawdzacza pożyczeń.
Sprawdzacz pożyczeń
Kompilator Rust posiada sprawdzacz pożyczeń, który porównuje zasięgi, aby określić, czy wszystkie pożyczki są ważne. Listing 10-17 przedstawia ten sam kod co Listing 10-16, ale z adnotacjami pokazującymi czasy życia zmiennych.
fn main() {
let r; // ---------+-- 'a
// |
{ // |
let x = 5; // -+-- 'b |
r = &x; // | |
} // -+ |
// |
println!("r: {r}"); // |
} // ---------+
Tutaj oznaczyliśmy czas życia r jako 'a, a czas życia x jako 'b. Jak widać, wewnętrzny blok 'b jest znacznie mniejszy niż zewnętrzny blok czasu życia 'a. W czasie kompilacji Rust porównuje rozmiar tych dwóch czasów życia i widzi, że r ma czas życia 'a, ale odnosi się do pamięci z czasem życia 'b. Program jest odrzucany, ponieważ 'b jest krótsze niż 'a: podmiot referencji nie żyje tak długo, jak sama referencja.
Listing 10-18 naprawia kod, tak aby nie miał wiszącej referencji i kompilował się bez żadnych błędów.
fn main() {
let x = 5; // ----------+-- 'b
// |
let r = &x; // --+-- 'a |
// | |
println!("r: {r}"); // | |
// --+ |
} // ----------+
Tutaj x ma czas życia 'b, który w tym przypadku jest większy niż 'a. Oznacza to, że r może odwoływać się do x, ponieważ Rust wie, że referencja w r zawsze będzie ważna, dopóki x jest ważne.
Teraz, gdy wiesz, gdzie znajdują się czasy życia referencji i jak Rust analizuje czasy życia, aby zapewnić, że referencje zawsze będą ważne, przejdźmy do ogólnych czasów życia w parametrach funkcji i wartościach zwracanych.
Ogólne czasy życia w funkcjach
Napiszemy funkcję, która zwraca dłuższy z dwóch wycinków ciągów znaków. Funkcja ta przyjmie dwa wycinki ciągów znaków i zwróci pojedynczy wycinek ciągu znaków. Po zaimplementowaniu funkcji longest, kod z Listingu 10-19 powinien wypisać The longest string is abcd.
fn main() {
let string1 = String::from("abcd");
let string2 = "xyz";
let result = longest(string1.as_str(), string2);
println!("The longest string is {result}");
}
Zauważ, że chcemy, aby funkcja przyjmowała wycinki ciągów znaków, które są referencjami, a nie ciągami znaków, ponieważ nie chcemy, aby funkcja longest przejmowała własność swoich parametrów. Więcej informacji na temat tego, dlaczego parametry użyte w Listingu 10-19 są tymi, których chcemy, znajduje się w sekcji „Wycinki ciągów znaków jako parametry” w Rozdziale 4.
Jeśli spróbujemy zaimplementować funkcję longest tak, jak pokazano w Listingu 10-20, nie skompiluje się ona.
fn main() {
let string1 = String::from("abcd");
let string2 = "xyz";
let result = longest(string1.as_str(), string2);
println!("The longest string is {result}");
}
fn longest(x: &str, y: &str) -> &str {
if x.len() > y.len() { x } else { y }
}
Zamiast tego otrzymujemy następujący błąd, który dotyczy czasów życia:
$ 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
Tekst pomocy ujawnia, że typ zwracany wymaga parametru ogólnego czasu życia, ponieważ Rust nie może określić, czy zwracana referencja odnosi się do x czy do y. Tak naprawdę my też tego nie wiemy, ponieważ blok if w ciele tej funkcji zwraca referencję do x, a blok else zwraca referencję do y!
Kiedy definiujemy tę funkcję, nie znamy konkretnych wartości, które zostaną do niej przekazane, więc nie wiemy, czy wykona się przypadek if czy else. Nie znamy również konkretnych czasów życia referencji, które zostaną przekazane, więc nie możemy spojrzeć na zasięgi, jak to zrobiliśmy w Listingach 10-17 i 10-18, aby określić, czy zwracana referencja będzie zawsze ważna. Sprawdzacz pożyczeń również nie może tego ustalić, ponieważ nie wie, jak czasy życia x i y odnoszą się do czasu życia wartości zwracanej. Aby naprawić ten błąd, dodamy ogólne parametry czasów życia, które zdefiniują relację między referencjami, aby sprawdzacz pożyczeń mógł przeprowadzić swoją analizę.
Składnia adnotacji czasów życia
Adnotacje czasów życia nie zmieniają długości życia żadnych referencji. Raczej opisują one relacje między czasami życia wielu referencji, nie wpływając na same czasy życia. Tak jak funkcje mogą przyjmować dowolny typ, gdy sygnatura określa ogólny parametr typu, tak samo funkcje mogą przyjmować referencje z dowolnym czasem życia, określając ogólny parametr czasu życia.
Adnotacje czasów życia mają nieco nietypową składnię: nazwy parametrów czasu życia muszą zaczynać się od apostrofu (') i zazwyczaj są małymi literami i bardzo krótkie, podobnie jak typy ogólne. Większość ludzi używa nazwy 'a dla pierwszej adnotacji czasu życia. Adnotacje parametrów czasu życia umieszczamy po & referencji, używając spacji do oddzielenia adnotacji od typu referencji.
Oto kilka przykładów – referencja do i32 bez parametru czasu życia, referencja do i32 z parametrem czasu życia o nazwie 'a, oraz zmienna referencja do i32, która również ma czas życia 'a:
&i32 // referencja
&'a i32 // referencja z jawnym czasem życia
&'a mut i32 // zmienna referencja z jawnym czasem życia
Jedna adnotacja czasu życia sama w sobie nie ma większego znaczenia, ponieważ adnotacje mają na celu poinformowanie Rust, jak ogólne parametry czasu życia wielu referencji odnoszą się do siebie. Przyjrzyjmy się, jak adnotacje czasu życia odnoszą się do siebie w kontekście funkcji longest.
W sygnaturach funkcji
Aby używać adnotacji czasów życia w sygnaturach funkcji, musimy zadeklarować ogólne parametry czasów życia w nawiasach kątowych między nazwą funkcji a listą parametrów, tak jak robiliśmy to z ogólnymi parametrami typów.
Chcemy, aby sygnatura wyrażała następujące ograniczenie: zwracana referencja będzie ważna tak długo, jak długo oba parametry są ważne. Jest to relacja między czasami życia parametrów a wartością zwracaną. Nazwiemy czas życia 'a, a następnie dodamy go do każdej referencji, jak pokazano w Listingu 10-21.
fn main() {
let string1 = String::from("abcd");
let string2 = "xyz";
let result = longest(string1.as_str(), string2);
println!("The longest string is {result}");
}
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() { x } else { y }
}
Ten kod powinien się skompilować i dać pożądany rezultat, gdy użyjemy go z funkcją main z Listingu 10-19.
Sygnatura funkcji informuje teraz Rust, że dla pewnego czasu życia 'a, funkcja przyjmuje dwa parametry, z których oba są wycinkami ciągów znaków, które żyją co najmniej tak długo, jak czas życia 'a. Sygnatura funkcji informuje również Rust, że wycinek ciągu znaków zwrócony z funkcji będzie żył co najmniej tak długo, jak czas życia 'a. W praktyce oznacza to, że czas życia referencji zwróconej przez funkcję longest jest taki sam, jak krótszy z czasów życia wartości, do których odwołują się argumenty funkcji. Te relacje to to, czego chcemy, aby Rust używał podczas analizy tego kodu.
Pamiętaj, że kiedy określamy parametry czasu życia w sygnaturze tej funkcji, nie zmieniamy czasów życia żadnych wartości przekazanych ani zwracanych. Raczej określamy, że sprawdzacz pożyczeń powinien odrzucić wszelkie wartości, które nie przestrzegają tych ograniczeń. Zauważ, że funkcja longest nie musi dokładnie wiedzieć, jak długo będą żyły x i y, tylko że pewien zasięg może zostać podstawiony za 'a, który spełni tę sygnaturę.
Podczas adnotowania czasów życia w funkcjach, adnotacje umieszcza się w sygnaturze funkcji, a nie w jej ciele. Adnotacje czasów życia stają się częścią kontraktu funkcji, podobnie jak typy w sygnaturze. Posiadanie sygnatur funkcji zawierających kontrakt czasów życia oznacza, że analiza wykonywana przez kompilator Rust może być prostsza. Jeśli wystąpi problem z adnotacją funkcji lub sposobem jej wywołania, błędy kompilatora mogą wskazać precyzyjniej na część naszego kodu i ograniczenia. Gdyby kompilator Rust wyciągał więcej wniosków na temat tego, jakie relacje czasów życia zamierzaliśmy, kompilator mógłby wskazać tylko na użycie naszego kodu wiele kroków od przyczyny problemu.
Kiedy przekazujemy konkretne referencje do longest, konkretny czas życia, który jest podstawiany za 'a, to część zasięgu x, która nakłada się na zasięg y. Innymi słowy, ogólny czas życia 'a przyjmie konkretny czas życia, który jest równy krótszemu z czasów życia x i y. Ponieważ oznaczyliśmy zwracaną referencję tym samym parametrem czasu życia 'a, zwracana referencja będzie również ważna przez czas trwania krótszego z czasów życia x i y.
Przyjrzyjmy się, jak adnotacje czasów życia ograniczają funkcję longest poprzez przekazywanie referencji, które mają różne konkretne czasy życia. Listing 10-22 to prosty przykład.
fn main() {
let string1 = String::from("long string is long");
{
let string2 = String::from("xyz");
let result = longest(string1.as_str(), string2.as_str());
println!("The longest string is {result}");
}
}
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() { x } else { y }
}
W tym przykładzie string1 jest ważne do końca zewnętrznego zasięgu, string2 jest ważne do końca wewnętrznego zasięgu, a result odwołuje się do czegoś, co jest ważne do końca wewnętrznego zasięgu. Uruchom ten kod, a zobaczysz, że sprawdzacz pożyczeń zatwierdza; skompiluje się i wypisze The longest string is long string is long.
Następnie spróbujmy przykładu, który pokazuje, że czas życia referencji w result musi być krótszym czasem życia z dwóch argumentów. Przeniesiemy deklarację zmiennej result poza zasięg wewnętrzny, ale pozostawimy przypisanie wartości do zmiennej result wewnątrz zasięgu z string2. Następnie przeniesiemy println!, które używa result, poza zasięg wewnętrzny, po zakończeniu zasięgu wewnętrznego. Kod z Listingu 10-23 nie skompiluje się.
fn main() {
let string1 = String::from("long string is long");
let result;
{
let string2 = String::from("xyz");
result = longest(string1.as_str(), string2.as_str());
}
println!("The longest string is {result}");
}
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() { x } else { y }
}
Kiedy próbujemy skompilować ten kod, otrzymujemy następujący błąd:
$ 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!("The longest string is {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
Błąd pokazuje, że aby result było ważne dla instrukcji println!, string2 musiałoby być ważne do końca zasięgu zewnętrznego. Rust wie o tym, ponieważ oznaczyliśmy czasy życia parametrów funkcji i wartości zwracanych, używając tego samego parametru czasu życia 'a.
Jako ludzie, możemy spojrzeć na ten kod i zobaczyć, że string1 jest dłuższe niż string2, a zatem result będzie zawierać referencję do string1. Ponieważ string1 nie wyszło jeszcze poza zasięg, referencja do string1 będzie nadal ważna dla instrukcji println!. Jednak kompilator nie widzi, że referencja jest ważna w tym przypadku. Powiedzieliśmy Rust, że czas życia referencji zwróconej przez funkcję longest jest taki sam, jak krótszy z czasów życia referencji przekazanych. Dlatego sprawdzacz pożyczeń nie zezwala na kod z Listingu 10-23, ponieważ może on zawierać nieprawidłową referencję.
Spróbuj zaprojektować więcej eksperymentów, które zmieniają wartości i czasy życia referencji przekazywanych do funkcji longest oraz sposób użycia zwróconej referencji. Postaw hipotezy, czy Twoje eksperymenty przejdą sprawdzacz pożyczeń, zanim skompilujesz; następnie sprawdź, czy masz rację!
Relacje
Sposób, w jaki musisz określić parametry czasu życia, zależy od tego, co robi twoja funkcja. Na przykład, gdybyśmy zmienili implementację funkcji longest tak, aby zawsze zwracała pierwszy parametr zamiast najdłuższego wycinka ciągu znaków, nie musielibyśmy określać czasu życia dla parametru y. Poniższy kod skompiluje się:
fn main() {
let string1 = String::from("abcd");
let string2 = "efghijklmnopqrstuvwxyz";
let result = longest(string1.as_str(), string2);
println!("The longest string is {result}");
}
fn longest<'a>(x: &'a str, y: &str) -> &'a str {
x
}
Określiliśmy parametr czasu życia 'a dla parametru x i typu zwracanego, ale nie dla parametru y, ponieważ czas życia y nie ma żadnego związku z czasem życia x ani wartością zwracaną.
Podczas zwracania referencji z funkcji, parametr czasu życia dla typu zwracanego musi odpowiadać parametrowi czasu życia jednego z parametrów. Jeśli zwracana referencja nie odnosi się do jednego z parametrów, musi odnosić się do wartości utworzonej wewnątrz tej funkcji. Byłaby to jednak wisząca referencja, ponieważ wartość wyjdzie poza zasięg na końcu funkcji. Rozważ tę próbę implementacji funkcji longest, która się nie skompiluje:
fn main() {
let string1 = String::from("abcd");
let string2 = "xyz";
let result = longest(string1.as_str(), string2);
println!("The longest string is {result}");
}
fn longest<'a>(x: &str, y: &str) -> &'a str {
let result = String::from("really long string");
result.as_str()
}
Tutaj, mimo że określiliśmy parametr czasu życia 'a dla typu zwracanego, ta implementacja nie skompiluje się, ponieważ czas życia wartości zwracanej nie jest w ogóle powiązany z czasem życia parametrów. Oto komunikat o błędzie, który otrzymujemy:
$ 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
Problem polega na tym, że result wychodzi poza zasięg i zostaje oczyszczone na końcu funkcji longest. Próbujemy również zwrócić referencję do result z funkcji. Nie ma sposobu, abyśmy mogli określić parametry czasu życia, które zmieniłyby wiszącą referencję, a Rust nie pozwoli nam na utworzenie wiszącej referencji. W tym przypadku najlepszym rozwiązaniem byłoby zwrócenie własnego typu danych zamiast referencji, tak aby funkcja wywołująca była odpowiedzialna za oczyszczenie wartości.
Ostatecznie składnia czasów życia służy do łączenia czasów życia różnych parametrów i wartości zwracanych funkcji. Po ich połączeniu Rust ma wystarczające informacje, aby zezwalać na operacje bezpieczne dla pamięci i odrzucać operacje, które mogłyby stworzyć wiszące wskaźniki lub w inny sposób naruszyć bezpieczeństwo pamięci.
W definicjach struktur
Dotychczasowe struktury, które zdefiniowaliśmy, zawsze przechowywały typy posiadane. Możemy zdefiniować struktury, które przechowują referencje, ale w takim przypadku musielibyśmy dodać adnotację czasu życia do każdej referencji w definicji struktury. Listing 10-24 przedstawia strukturę o nazwie ImportantExcerpt, która przechowuje wycinek ciągu znaków.
struct ImportantExcerpt<'a> {
part: &'a str,
}
fn main() {
let novel = String::from("Call me Ishmael. Some years ago...");
let first_sentence = novel.split('.').next().unwrap();
let i = ImportantExcerpt {
part: first_sentence,
};
}
Ta struktura ma jedno pole part, które przechowuje wycinek ciągu znaków, czyli referencję. Podobnie jak w przypadku ogólnych typów danych, nazwę ogólnego parametru czasu życia deklarujemy w nawiasach kątowych po nazwie struktury, abyśmy mogli użyć parametru czasu życia w ciele definicji struktury. Ta adnotacja oznacza, że instancja ImportantExcerpt nie może przeżyć referencji, którą przechowuje w swoim polu part.
Funkcja main tworzy tutaj instancję struktury ImportantExcerpt, która przechowuje referencję do pierwszego zdania String będącego własnością zmiennej novel. Dane w novel istnieją przed utworzeniem instancji ImportantExcerpt. Ponadto novel nie wychodzi poza zasięg, dopóki ImportantExcerpt nie wyjdzie poza zasięg, więc referencja w instancji ImportantExcerpt jest ważna.
Elizja czasów życia
Dowiedziałeś się, że każda referencja ma czas życia i że musisz określić parametry czasu życia dla funkcji lub struktur, które używają referencji. Jednak w Listingu 4-9 mieliśmy funkcję, ponownie pokazaną w Listingu 10-25, która kompilowała się bez adnotacji czasów życia.
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("hello world");
// first_word works on slices of `String`s
let word = first_word(&my_string[..]);
let my_string_literal = "hello world";
// first_word works on slices of string literals
let word = first_word(&my_string_literal[..]);
// Because string literals *are* string slices already,
// this works too, without the slice syntax!
let word = first_word(my_string_literal);
}
Powodem, dla którego ta funkcja kompiluje się bez adnotacji czasów życia, jest historia: we wczesnych wersjach (przed 1.0) Rust, ten kod by się nie skompilował, ponieważ każda referencja wymagała jawnego czasu życia. W tamtym czasie sygnatura funkcji byłaby napisana tak:
fn first_word<'a>(s: &'a str) -> &'a str {
Po napisaniu dużej ilości kodu w Rust, zespół Rust odkrył, że programiści Rust wpisywali te same adnotacje czasów życia wielokrotnie w konkretnych sytuacjach. Sytuacje te były przewidywalne i postępowały zgodnie z kilkoma deterministycznymi wzorcami. Deweloperzy zaprogramowali te wzorce w kodzie kompilatora, aby sprawdzacz pożyczeń mógł wnioskować o czasach życia w tych sytuacjach i nie potrzebował jawnych adnotacji.
Ten kawałek historii Rust jest istotny, ponieważ możliwe jest, że pojawi się więcej deterministycznych wzorców i zostanie dodanych do kompilatora. W przyszłości może być wymagane jeszcze mniej adnotacji czasów życia.
Wzorce zaprogramowane w analizie referencji przez Rust nazywane są zasadami elizji czasów życia. Nie są to zasady, którymi programiści mają się kierować; to zbiór konkretnych przypadków, które kompilator weźmie pod uwagę, a jeśli twój kod pasuje do tych przypadków, nie musisz jawnie pisać czasów życia.
Zasady elizji nie zapewniają pełnego wnioskowania. Jeśli po zastosowaniu zasad nadal istnieje niejasność co do czasów życia referencji, kompilator nie zgadnie, jakie powinny być czasy życia pozostałych referencji. Zamiast zgadywać, kompilator wyświetli błąd, który można rozwiązać, dodając adnotacje czasów życia.
Czasy życia na parametrach funkcji lub metod nazywane są czasami życia wejściowymi, a czasy życia na wartościach zwracanych nazywane są czasami życia wyjściowymi.
Kompilator używa trzech zasad, aby ustalić czasy życia referencji, gdy nie ma jawnych adnotacji. Pierwsza zasada dotyczy czasów życia wejściowych, a druga i trzecia zasada dotyczą czasów życia wyjściowych. Jeśli kompilator dojdzie do końca trzech zasad, a nadal istnieją referencje, dla których nie może ustalić czasów życia, kompilator zatrzyma się z błędem. Zasady te mają zastosowanie zarówno do definicji fn, jak i bloków impl.
Pierwsza zasada mówi, że kompilator przypisuje parametr czasu życia każdemu parametrowi, który jest referencją. Innymi słowy, funkcja z jednym parametrem otrzymuje jeden parametr czasu życia: fn foo<'a>(x: &'a i32); funkcja z dwoma parametrami otrzymuje dwa oddzielne parametry czasu życia: fn foo<'a, 'b>(x: &'a i32, y: &'b i32); i tak dalej.
Druga zasada mówi, że jeśli istnieje dokładnie jeden parametr czasu życia wejściowego, to ten czas życia jest przypisywany do wszystkich parametrów czasu życia wyjściowego: fn foo<'a>(x: &'a i32) -> &'a i32.
Trzecia zasada mówi, że jeśli istnieje wiele parametrów czasu życia wejściowego, ale jeden z nich to &self lub &mut self, ponieważ jest to metoda, czas życia self jest przypisywany do wszystkich parametrów czasu życia wyjściowego. Ta trzecia zasada sprawia, że metody są znacznie przyjemniejsze w czytaniu i pisaniu, ponieważ potrzeba mniej symboli.
Udawajmy, że jesteśmy kompilatorem. Zastosujemy te zasady, aby ustalić czasy życia referencji w sygnaturze funkcji first_word z Listingu 10-25. Sygnatura zaczyna się bez żadnych czasów życia przypisanych do referencji:
fn first_word(s: &str) -> &str {
Następnie kompilator stosuje pierwszą zasadę, która określa, że każdy parametr otrzymuje swój własny czas życia. Nazwiemy go jak zwykle 'a, więc sygnatura wygląda teraz tak:
fn first_word<'a>(s: &'a str) -> &str {
Druga zasada ma zastosowanie, ponieważ istnieje dokładnie jeden czas życia wejściowego. Druga zasada określa, że czas życia jednego parametru wejściowego jest przypisywany do czasu życia wyjściowego, więc sygnatura wygląda teraz tak:
fn first_word<'a>(s: &'a str) -> &'a str {
Teraz wszystkie referencje w sygnaturze tej funkcji mają czasy życia, a kompilator może kontynuować swoją analizę bez potrzeby, aby programista adnotował czasy życia w sygnaturze tej funkcji.
Przyjrzyjmy się innemu przykładowi, tym razem używając funkcji longest, która nie miała parametrów czasu życia, kiedy zaczęliśmy z nią pracować w Listingu 10-20:
fn longest(x: &str, y: &str) -> &str {
Zastosujmy pierwszą zasadę: każdy parametr otrzymuje swój własny czas życia. Tym razem mamy dwa parametry zamiast jednego, więc mamy dwa czasy życia:
fn longest<'a, 'b>(x: &'a str, y: &'b str) -> &str {
Widzisz, że druga zasada nie ma zastosowania, ponieważ istnieje więcej niż jeden czas życia wejściowego. Trzecia zasada również nie ma zastosowania, ponieważ longest jest funkcją, a nie metodą, więc żaden z parametrów nie jest self. Po przejściu przez wszystkie trzy zasady, nadal nie ustaliliśmy, jaki jest czas życia typu zwracanego. Dlatego otrzymaliśmy błąd podczas próby kompilacji kodu z Listingu 10-20: kompilator przeanalizował zasady elizji czasów życia, ale nadal nie był w stanie ustalić wszystkich czasów życia referencji w sygnaturze.
Ponieważ trzecia zasada dotyczy tak naprawdę tylko sygnatur metod, przyjrzyjmy się teraz czasom życia w tym kontekście, aby zobaczyć, dlaczego trzecia zasada oznacza, że nie musimy zbyt często adnotować czasów życia w sygnaturach metod.
W definicjach metod
Kiedy implementujemy metody w strukturze z czasami życia, używamy tej samej składni co w przypadku ogólnych parametrów typów, jak pokazano w Listingu 10-11. Gdzie deklarujemy i używamy parametrów czasu życia, zależy od tego, czy są one związane z polami struktury, czy z parametrami metody i wartościami zwracanymi.
Nazwy czasów życia dla pól struktury zawsze muszą być deklarowane po słowie kluczowym impl, a następnie używane po nazwie struktury, ponieważ te czasy życia są częścią typu struktury.
W sygnaturach metod w bloku impl, referencje mogą być powiązane z czasem życia referencji w polach struktury, lub mogą być niezależne. Ponadto, zasady elizji czasów życia często powodują, że adnotacje czasów życia nie są konieczne w sygnaturach metod. Przyjrzyjmy się kilku przykładom, używając struktury ImportantExcerpt, którą zdefiniowaliśmy w Listingu 10-24.
Najpierw użyjemy metody o nazwie level, której jedynym parametrem jest referencja do self, a zwracana wartość to i32, która nie jest referencją do niczego:
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!("Attention please: {announcement}");
self.part
}
}
fn main() {
let novel = String::from("Call me Ishmael. Some years ago...");
let first_sentence = novel.split('.').next().unwrap();
let i = ImportantExcerpt {
part: first_sentence,
};
}
Deklaracja parametru czasu życia po impl i jego użycie po nazwie typu są wymagane, ale ze względu na pierwszą zasadę elizji, nie musimy adnotować czasu życia referencji do self.
Oto przykład, w którym ma zastosowanie trzecia zasada elizji czasu życia:
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!("Attention please: {announcement}");
self.part
}
}
fn main() {
let novel = String::from("Call me Ishmael. Some years ago...");
let first_sentence = novel.split('.').next().unwrap();
let i = ImportantExcerpt {
part: first_sentence,
};
}
Istnieją dwa wejściowe czasy życia, więc Rust stosuje pierwszą zasadę elizji czasu życia i nadaje zarówno &self, jak i announcement ich własne czasy życia. Następnie, ponieważ jeden z parametrów to &self, typ zwracany otrzymuje czas życia &self, a wszystkie czasy życia zostały uwzględnione.
Statyczny czas życia
Jeden specjalny czas życia, o którym musimy porozmawiać, to 'static, który oznacza, że dotknięta referencja może żyć przez cały czas trwania programu. Wszystkie literały ciągów znaków mają czas życia 'static, który możemy adnotować w następujący sposób:
#![allow(unused)]
fn main() {
let s: &'static str = "I have a static lifetime.";
}
Tekst tego ciągu znaków jest przechowywany bezpośrednio w pliku binarnym programu, który jest zawsze dostępny. Dlatego czas życia wszystkich literałów ciągów znaków jest 'static.
Możesz zobaczyć sugestie w komunikatach o błędach, aby użyć czasu życia 'static. Ale zanim określisz 'static jako czas życia dla referencji, zastanów się, czy referencja, którą masz, faktycznie żyje przez cały czas trwania twojego programu i czy tego chcesz. Przez większość czasu komunikat o błędzie sugerujący czas życia 'static wynika z próby utworzenia wiszącej referencji lub niezgodności dostępnych czasów życia. W takich przypadkach rozwiązaniem jest naprawienie tych problemów, a nie określanie czasu życia 'static.
Ogólne parametry typów, ograniczenia cech i czasy życia
Przyjrzyjmy się krótko składni określania ogólnych parametrów typów, ograniczeń cech i czasów życia w jednej funkcji!
fn main() {
let string1 = String::from("abcd");
let string2 = "xyz";
let result = longest_with_an_announcement(
string1.as_str(),
string2,
"Today is someone's birthday!",
);
println!("The longest string is {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!("Announcement! {ann}");
if x.len() > y.len() { x } else { y }
}
To funkcja longest z Listingu 10-21, która zwraca dłuższy z dwóch wycinków ciągów znaków. Ale teraz ma dodatkowy parametr o nazwie ann typu ogólnego T, który może być wypełniony dowolnym typem implementującym cechę Display, zgodnie z klauzulą where. Ten dodatkowy parametr zostanie wypisany za pomocą {}, dlatego konieczne jest ograniczenie cechy Display. Ponieważ czasy życia są rodzajem generyków, deklaracje parametru czasu życia 'a i ogólnego parametru typu T znajdują się na tej samej liście w nawiasach kątowych po nazwie funkcji.
Podsumowanie
W tym rozdziale omówiliśmy wiele zagadnień! Teraz, gdy znasz ogólne parametry typów, cechy i ograniczenia cech oraz ogólne parametry czasów życia, jesteś gotowy do pisania kodu bez powtórzeń, który działa w wielu różnych sytuacjach. Ogólne parametry typów pozwalają zastosować kod do różnych typów. Cechy i ograniczenia cech zapewniają, że mimo iż typy są ogólne, będą miały zachowanie, którego kod potrzebuje. Nauczyłeś się, jak używać adnotacji czasów życia, aby zapewnić, że ten elastyczny kod nie będzie miał żadnych wiszących referencji. I cała ta analiza odbywa się w czasie kompilacji, co nie wpływa na wydajność w czasie wykonania!
Wierz lub nie, ale jest znacznie więcej do nauczenia się na tematy, które omówiliśmy w tym rozdziale: Rozdział 18 omawia obiekty cech, które są innym sposobem używania cech. Istnieją również bardziej złożone scenariusze związane z adnotacjami czasów życia, które będą ci potrzebne tylko w bardzo zaawansowanych przypadkach; w ich przypadku powinieneś przeczytać Rust Reference. Ale następnie dowiesz się, jak pisać testy w Rust, aby upewnić się, że twój kod działa tak, jak powinien.