Czym jest Własność?
Własność to zbiór zasad, które określają, w jaki sposób program w Rust zarządza pamięcią.Wszystkie programy muszą zarządzać sposobem, w jaki wykorzystują pamięć komputera podczas działania.Niektóre języki mają zbieranie śmieci, które regularnie szuka nieużywanej już pamięcipamięci podczas działania programu; w innych językach programista musi jawnieprzydzielić i zwolnić pamięć. Rust używa trzeciego podejścia: Pamięć jest zarządzanaprzez system własności z zestawem zasad, które kompilator sprawdza. Jeśliktórakolwiek z zasad zostanie naruszona, program się nie skompiluje. Żadna z funkcjiwłasności nie spowolni programu podczas jego działania.
Ponieważ własność jest nową koncepcją dla wielu programistów, przyzwyczajenie się do niej zajmuje trochę czasu.Dobrą wiadomością jest to, że im bardziej doświadczony będziesz w Rusti zasadach systemu własności, tym łatwiej będzie ci naturalnierozwinąć kod, który jest bezpieczny i wydajny. Trzymaj tak dalej!
Kiedy zrozumiesz własność, będziesz mieć solidne podstawy do zrozumieniafunkcji, które sprawiają, że Rust jest unikalny. W tym rozdziale poznasz własność,pracując nad przykładami, które koncentrują się na bardzo popularnej strukturze danych:ciągach znaków.
Stos i sterta
Wiele języków programowania nie wymaga od ciebie zbyt częstego myślenia o stosie i stercie.Ale w języku programowania systemowego, takim jak Rust, to, czy wartość znajduje się na stosie, czy na stercie,wpływa na zachowanie języka i na to, dlaczego musisz podejmować pewne decyzje.Części własności zostaną opisane w odniesieniu do stosu i stertyw dalszej części tego rozdziału, więc oto krótkie wyjaśnienie w przygotowaniu.
Zarówno stos, jak i sterta są częściami pamięci dostępnymi dla twojego kodu do użyciaw czasie wykonywania, ale są one strukturalizowane w różny sposób. Stos przechowuje wartościw kolejności, w jakiej je otrzymuje, i usuwa wartości w odwrotnej kolejności.Jest to określane jako ostatnie weszło, pierwsze wyszło (LIFO). Pomyśl o stosietalerzy: Kiedy dodajesz więcej talerzy, kładziesz je na wierzchu stosu, a kiedypotrzebujesz talerza, bierzesz jeden z góry. Dodawanie lub usuwanie talerzy ze środka lub z dołu nie działałoby tak dobrze!Dodawanie danych nazywa się wkładaniem na stos, a usuwanie danych nazywa się zdejmowaniem ze stosu.Wszystkie dane przechowywane na stosie muszą mieć znaną, stałą wielkość.Dane o nieznanym rozmiarze w czasie kompilacji lub rozmiarze, który może się zmienić, muszą być przechowywanena stercie.
Sterta jest mniej zorganizowana: Kiedy umieszczasz dane na stercie, żądaszwielkości miejsca. Alokator pamięci znajduje wolne miejsce na stercie,które jest wystarczająco duże, oznacza je jako zajęte i zwraca wskaźnik,który jest adresem tej lokalizacji. Ten proces nazywa się _alokacją na stercie_i jest czasami skracany do samego alokowania (wkładanie wartości na stos nie jest uważane za alokację).Ponieważ wskaźnik do sterty ma znaną, stałą wielkość, możesz przechowywać wskaźnik na stosie,ale kiedy chcesz uzyskać rzeczywiste dane, musisz podążać za wskaźnikiem.Pomyśl o tym, jakbyś siedział w restauracji. Kiedy wchodzisz, podajesz liczbęosób w twojej grupie, a host znajduje pusty stół, który pasuje do wszystkich,i prowadzi cię tam. Jeśli ktoś z twojej grupy spóźni się, może zapytać,gdzie zostałeś usadzony, aby cię znaleźć.
Wkładanie na stos jest szybsze niż alokowanie na stercie, ponieważ alokator nigdy nie musi szukaćmiejsca do przechowywania nowych danych; ta lokalizacja zawsze znajduje się na szczycie stosu.W porównaniu, alokacja miejsca na stercie wymaga więcej pracy, ponieważ alokator musinajpierw znaleźć wystarczająco dużo miejsca, aby pomieścić dane, a następnie wykonać księgowość,aby przygotować się do następnej alokacji.
Dostęp do danych na stercie jest zazwyczaj wolniejszy niż dostęp do danych na stosie,ponieważ musisz podążać za wskaźnikiem, aby tam dotrzeć. Współczesne procesorysą szybsze, jeśli mniej skaczą po pamięci. Kontynuując analogię,rozważ kelnera w restauracji przyjmującego zamówienia od wielu stolików.Najbardziej efektywne jest zebranie wszystkich zamówień przy jednym stole, zanim przejdzie się do następnego.Przyjmowanie zamówienia ze stołu A, następnie zamówienia ze stołu B,następnie ponownie z A, a następnie ponownie z B, byłoby znacznie wolniejszym procesem.W ten sam sposób procesor zazwyczaj lepiej wykonuje swoją pracę, jeślidziała na danych, które są blisko innych danych (jak na stosie),a nie dalej (jak może być na stercie).
Kiedy twój kod wywołuje funkcję, wartości przekazane do funkcji(w tym, potencjalnie, wskaźniki do danych na stercie) i zmienne lokalne funkcji sąwkładane na stos. Kiedy funkcja się kończy, te wartości są usuwane ze stosu.
Śledzenie, które części kodu używają jakich danych na stercie,minimalizowanie ilości zduplikowanych danych na stercie i czyszczenie nieużywanychdanych na stercie, aby nie zabrakło miejsca, to problemyczne kwestie,które adresuje system własności. Kiedy zrozumiesz własność,nie będziesz musiał często myśleć o stosie i stercie.Ale wiedząc, że głównym celem własności jest zarządzanie danymi na stercie,może to pomóc wyjaśnić, dlaczego działa to tak, jak działa.
Zasady Własności
Najpierw przyjrzyjmy się zasadom własności. Pamiętaj o nich,gdy będziemy przechodzić przez przykłady, które je ilustrują:
- Każda wartość w Rust ma właściciela.
- W danym momencie może być tylko jeden właściciel.
- Gdy właściciel wyjdzie poza zasięg, wartość zostanie usunięta (dropped).
Zasięg zmiennych
Teraz, gdy podstawowa składnia Rust jest już za nami, nie będziemy zawierać całego kodu fn main() { w przykładach, więc jeśli śledzisz, upewnij się, że umieściłeś poniższe przykłady ręcznie w funkcji main. W rezultacie nasze przykłady będą nieco bardziej zwięzłe, pozwalając nam skupić się na rzeczywistych szczegółach, a nie na kodzie szablonowym.
Jako pierwszy przykład własności, przyjrzymy się zasięgowi niektórych zmiennych. Zasięg to zakres w programie, w którym element jest ważny. Weźmy następującą zmienną:
#![allow(unused)]
fn main() {
let s = "hello";
}
Zmienna s odnosi się do literału ciągu znaków, gdzie wartość ciągu jest zakodowana bezpośrednio w tekście naszego programu. Zmienna jest ważna od momentu jej zadeklarowania do końca bieżącego zasięgu. Listing 4-1 przedstawia program z komentarzami oznaczającymi, gdzie zmienna s byłaby ważna.
fn main() {
{ // s jest tutaj nieważne, ponieważ nie zostało jeszcze zadeklarowane
let s = "hello"; // s jest ważne od tego momentu
// rób coś z s
} // ten zasięg się skończył, a s jest już nieważne
}
Innymi słowy, są tutaj dwa ważne momenty w czasie:
- Kiedy
swchodzi w zasięg, jest ważne. - Pozostaje ważne, dopóki nie wyjdzie z zasięgu.
Na tym etapie, związek między zasięgami a ważnością zmiennych jest podobny jak w innych językach programowania. Teraz będziemy budować na tym zrozumieniu, wprowadzając typ String.
Typ String
Aby zilustrować zasady własności, potrzebujemy typu danych, który jest bardziej złożony niż te, które omówiliśmy w sekcji „Typy danych” w Rozdziale 3. Wcześniej omówione typy mają znany rozmiar, mogą być przechowywane na stosie i zdejmowane ze stosu po zakończeniu ich zasięgu, oraz mogą być szybko i trywialnie kopiowane, aby utworzyć nową, niezależną instancję, jeśli inna część kodu musi użyć tej samej wartości w innym zasięgu. Chcemy jednak przyjrzeć się danym przechowywanym na stercie i zbadać, w jaki sposób Rust wie, kiedy te dane posprzątać, a typ String jest doskonałym przykładem.
Skoncentrujemy się na częściach String, które odnoszą się do własności. Te aspekty dotyczą również innych złożonych typów danych, niezależnie od tego, czy są dostarczane przez bibliotekę standardową, czy stworzone przez ciebie. Aspekty String niezwiązane z własnością omówimy w Rozdziale 8.
Widzieliśmy już literały ciągów znaków, gdzie wartość ciągu jest zakodowana na stałe w naszym programie. Literały ciągów znaków są wygodne, ale nie nadają się do każdej sytuacji, w której możemy chcieć użyć tekstu. Jednym z powodów jest to, że są one niezmienne. Inną przyczyną jest to, że nie każda wartość ciągu może być znana, gdy piszemy nasz kod: na przykład, co jeśli chcemy pobrać dane od użytkownika i je zapisać? Właśnie dla takich sytuacji Rust ma typ String. Ten typ zarządza danymi alokowanymi na stercie i jako taki jest w stanie przechowywać ilość tekstu, która jest nam nieznana w czasie kompilacji. Możesz utworzyć String z literału ciągu znaków za pomocą funkcji from, w ten sposób:
#![allow(unused)]
fn main() {
let s = String::from("hello");
}
Operator podwójnego dwukropka :: pozwala nam na umieszczenie funkcji from w przestrzeni nazw typu String, zamiast używać nazwy takiej jak string_from. Więcej o tej składni omówimy w sekcji „Metody” w Rozdziale 5, oraz gdy będziemy mówić o przestrzeniach nazw z modułami w sekcji „Ścieżki do odwoływania się do elementu w drzewie modułów” w Rozdziale 7.
Tego rodzaju ciąg może być mutowalny:
fn main() {
let mut s = String::from("hello");
s.push_str(", world!"); // push_str() dodaje literał do String
println!("{s}"); // to wyświetli `hello, world!`
}
Więc, jaka jest tutaj różnica? Dlaczego String może być mutowalny, a literały nie? Różnica tkwi w sposobie, w jaki te dwa typy obsługują pamięć.
Pamięć i Alokacja
W przypadku literału ciągu znaków, znamy jego zawartość w czasie kompilacji, więc tekst jest zakodowany bezpośrednio w finalnym pliku wykonywalnym. Dlatego literały ciągów znaków są szybkie i wydajne. Ale te właściwości wynikają tylko z niezmienności literału ciągu znaków. Niestety, nie możemy umieścić bloku pamięci w pliku binarnym dla każdego fragmentu tekstu, którego rozmiar jest nieznany w czasie kompilacji i którego rozmiar może zmieniać się podczas działania programu.
Z typem String, aby obsługiwać zmienny, rozszerzalny fragment tekstu, musimy zaalokować na stercie ilość pamięci, nieznaną w czasie kompilacji, aby pomieścić zawartość. Oznacza to:
- Pamięć musi być żądana od alokatora pamięci w czasie wykonywania.
- Potrzebujemy sposobu na zwrócenie tej pamięci alokatorowi, gdy skończymy z naszym
String.
Ta pierwsza część jest wykonywana przez nas: Kiedy wywołujemy String::from, jego implementacja żąda potrzebnej pamięci. Jest to dość powszechne w językach programowania.
Jednak druga część jest inna. W językach z garbage collector (GC), GC śledzi i usuwa pamięć, która nie jest już używana, i nie musimy o tym myśleć. W większości języków bez GC, to nasza odpowiedzialność, aby zidentyfikować, kiedy pamięć nie jest już używana i wywołać kod, aby jawnie ją zwolnić, tak jak robiliśmy to, aby ją zażądać. Robienie tego poprawnie było historycznie trudnym problemem programistycznym. Jeśli zapomnimy, zmarnujemy pamięć. Jeśli zrobimy to zbyt wcześnie, będziemy mieć nieprawidłową zmienną. Jeśli zrobimy to dwukrotnie, to też jest błąd. Musimy sparować dokładnie jedną allocate z dokładnie jedną free.
Rust obiera inną ścieżkę: Pamięć jest automatycznie zwracana, gdy zmienna, która ją posiada, wyjdzie poza zasięg. Oto wersja naszego przykładu zasięgu z Listingu 4-1, używająca String zamiast literału ciągu znaków:
fn main() {
{
let s = String::from("hello"); // s jest ważne od tego momentu
// rób coś z s
} // ten zasięg się skończył, a s jest już
// nieważne
}
Istnieje naturalny moment, w którym możemy zwrócić pamięć potrzebną naszej String alokatorowi: kiedy s wyjdzie poza zasięg. Kiedy zmienna wychodzi poza zasięg, Rust wywołuje dla nas specjalną funkcję. Funkcja ta nazywa się drop i to w niej autor String może umieścić kod do zwracania pamięci. Rust automatycznie wywołuje drop przy zamykającym nawiasie klamrowym.
Uwaga: W C++ ten wzorzec dealokowania zasobów na końcu życia elementu jest czasami nazywany Resource Acquisition Is Initialization (RAII). Funkcja
dropw Rust będzie ci znana, jeśli używałeś wzorców RAII.
Ten wzorzec ma głęboki wpływ na sposób pisania kodu w Rust. Może wydawać się prosty teraz, ale zachowanie kodu może być nieoczekiwane w bardziej skomplikowanych sytuacjach, gdy chcemy, aby wiele zmiennych używało danych, które zaalokowaliśmy na stercie. Przyjrzyjmy się teraz kilku z tych sytuacji.
Zmienne i Dane w Interakcji przez Przeniesienie (Move)
W Rust wiele zmiennych może wchodzić w interakcję z tymi samymi danymi na różne sposoby. Listing 4-2 przedstawia przykład użycia liczby całkowitej.
fn main() {
let x = 5;
let y = x;
}
Prawdopodobnie możemy zgadnąć, co to robi: „Przypisz wartość 5 do x; następnie, utwórz kopię wartości z x i przypisz ją do y.” Mamy teraz dwie zmienne, x i y, i obie są równe 5. Tak właśnie się dzieje, ponieważ liczby całkowite są prostymi wartościami o znanym, stałym rozmiarze, a te dwie wartości 5 są umieszczane na stosie.
Teraz przyjrzyjmy się wersji String:
fn main() {
let s1 = String::from("hello");
let s2 = s1;
}
Wygląda to bardzo podobnie, więc moglibyśmy założyć, że działałoby tak samo: to znaczy, że druga linia utworzyłaby kopię wartości z s1 i przypisała ją do s2. Ale tak się nie dzieje.
Przyjrzyj się Rysunkowi 4-1, aby zobaczyć, co dzieje się z String pod maską. String składa się z trzech części, pokazanych po lewej stronie: wskaźnika do pamięci, która przechowuje zawartość ciągu, długości i pojemności. Ta grupa danych jest przechowywana na stosie. Po prawej stronie znajduje się pamięć na stercie, która przechowuje zawartość.
Rysunek 4-1: Reprezentacja w pamięci String zawierającego wartość "hello" przypisaną do s1
Długość to ilość pamięci, w bajtach, którą aktualnie wykorzystuje zawartość String. Pojemność to całkowita ilość pamięci, w bajtach, którą String otrzymał od alokatora. Różnica między długością a pojemnością ma znaczenie, ale nie w tym kontekście, więc na razie można ją zignorować.
Kiedy przypisujemy s1 do s2, dane String są kopiowane, co oznacza, że kopiujemy wskaźnik, długość i pojemność, które znajdują się na stosie. Nie kopiujemy danych na stercie, do których odwołuje się wskaźnik. Innymi słowy, reprezentacja danych w pamięci wygląda jak na Rysunku 4-2.
Rysunek 4-2: Reprezentacja w pamięci zmiennej s2, która zawiera kopię wskaźnika, długości i pojemności s1
Reprezentacja nie wygląda jak na Rysunku 4-3, który przedstawiałby pamięć, gdyby Rust zamiast tego skopiował również dane na stercie. Gdyby Rust tak zrobił, operacja s2 = s1 mogłaby być bardzo kosztowna pod względem wydajności, gdyby dane na stercie były duże.
Rysunek 4-3: Inna możliwość tego, co s2 = s1 mógłby zrobić, gdyby Rust skopiował również dane ze sterty
Wcześniej powiedzieliśmy, że gdy zmienna wychodzi poza zasięg, Rust automatycznie wywołuje funkcję drop i zwalnia pamięć sterty dla tej zmiennej. Ale Rysunek 4-2 pokazuje, że oba wskaźniki danych wskazują na tę samą lokalizację. To jest problem: kiedy s2 i s1 wyjdą poza zasięg, oba będą próbowały zwolnić tę samą pamięć. Jest to znane jako błąd podwójnego zwolnienia (double free) i jest jednym z błędów bezpieczeństwa pamięci, o których wspomnieliśmy wcześniej. Dwukrotne zwolnienie pamięci może prowadzić do uszkodzenia pamięci, co potencjalnie może prowadzić do luk w zabezpieczeniach.
Aby zapewnić bezpieczeństwo pamięci, po linii let s2 = s1;, Rust uważa s1 za już nieprawidłowe. Dlatego Rust nie musi zwalniać niczego, gdy s1 wychodzi poza zasięg. Sprawdź, co się stanie, gdy spróbujesz użyć s1 po utworzeniu s2; to nie zadziała:
fn main() {
let s1 = String::from("hello");
let s2 = s1;
println!("{s1}, world!");
}
Otrzymasz błąd podobny do tego, ponieważ Rust uniemożliwia użycie unieważnionej referencji:
$ cargo run
Compiling ownership v0.1.0 (file:///projects/ownership)
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
4 |
5 | println!("{s1}, world!");
| ^^ value borrowed here after move
|
= note: this error originates in the macro `$crate::format_args_nl` which comes from the expansion of the macro `println` (in Nightly builds, run with -Z macro-backtrace for more info)
help: consider cloning the value if the performance cost is acceptable
|
3 | let s2 = s1.clone();
| ++++++++
For more information about this error, try `rustc --explain E0382`.
error: could not compile `ownership` (bin "ownership") due to 1 previous error
Jeśli słyszałeś o pojęciach płytka kopia i głęboka kopia pracując z innymi językami, koncepcja kopiowania wskaźnika, długości i pojemności bez kopiowania danych prawdopodobnie brzmi jak tworzenie płytkiej kopii. Ale ponieważ Rust również unieważnia pierwszą zmienną, zamiast nazywać to płytką kopią, nazywa się to przeniesieniem. W tym przykładzie powiedzielibyśmy, że s1 zostało przeniesione do s2. Zatem to, co faktycznie się dzieje, pokazano na Rysunku 4-4.
Rysunek 4-4: Reprezentacja w pamięci po unieważnieniu s1
To rozwiązuje nasz problem! Gdy tylko s2 jest ważne, gdy wyjdzie ono poza zasięg, samo zwolni pamięć, i gotowe.
Dodatkowo, istnieje wynikająca z tego decyzja projektowa: Rust nigdy nie stworzy automatycznie „głębokich” kopii twoich danych. Dlatego wszelkie automatyczne kopiowanie można uznać za tanie pod względem wydajności czasu wykonania.
Zasięg i przypisanie
Odwrotność tego jest również prawdziwa dla związku między zasięgiem, własnością i zwalnianiem pamięci za pomocą funkcji drop. Kiedy przypisujesz całkowicie nową wartość do istniejącej zmiennej, Rust natychmiast wywoła drop i zwolni pamięć oryginalnej wartości. Rozważmy na przykład ten kod:
fn main() {
let mut s = String::from("hello");
s = String::from("ahoy");
println!("{s}, world!");
}
Początkowo deklarujemy zmienną s i wiążemy ją z String o wartości "hello". Następnie, natychmiast tworzymy nowy String o wartości "ahoy" i przypisujemy go do s. W tym momencie nic nie odnosi się do oryginalnej wartości na stercie. Rysunek 4-5 ilustruje teraz dane stosu i sterty:
Rysunek 4-5: Reprezentacja w pamięci po całkowitym zastąpieniu wartości początkowej
Oryginalny ciąg znaków natychmiast wychodzi więc poza zasięg. Rust uruchomi na nim funkcję drop i jego pamięć zostanie natychmiast zwolniona. Kiedy na końcu wydrukujemy wartość, będzie ona wynosić „ahoy, world!”.
Zmienne i Dane w Interakcji przez Klonowanie (Clone)
Jeśli chcemy głęboko skopiować dane String ze sterty, a nie tylko dane ze stosu, możemy użyć wspólnej metody clone. Składnię metod omówimy w Rozdziale 5, ale ponieważ metody są wspólną cechą wielu języków programowania, prawdopodobnie widziałeś je już wcześniej.
Oto przykład metody clone w działaniu:
fn main() {
let s1 = String::from("hello");
let s2 = s1.clone();
println!("s1 = {s1}, s2 = {s2}");
}
To działa bez problemów i wyraźnie tworzy zachowanie pokazane na Rysunku 4-3, gdzie dane na stercie są kopiowane.
Kiedy widzisz wywołanie clone, wiesz, że wykonywany jest jakiś dowolny kod i że ten kod może być kosztowny. To wizualny wskaźnik, że dzieje się coś innego.
Dane tylko na stosie: Kopia
Jest jeszcze jedna kwestia, o której jeszcze nie mówiliśmy. Ten kod używający liczb całkowitych — część z nich pokazana w Listingu 4-2 — działa i jest prawidłowy:
fn main() {
let x = 5;
let y = x;
println!("x = {x}, y = {y}");
}
Ale ten kod wydaje się zaprzeczać temu, czego się właśnie nauczyliśmy: Nie mamy wywołania clone, ale x jest nadal ważne i nie zostało przeniesione do y.
Powodem jest to, że typy takie jak liczby całkowite, które mają znany rozmiar w czasie kompilacji, są przechowywane w całości na stosie, więc kopie rzeczywistych wartości są szybkie do wykonania. Oznacza to, że nie ma powodu, dla którego chcielibyśmy uniemożliwić x bycie ważnym po utworzeniu zmiennej y. Innymi słowy, nie ma tutaj różnicy między kopiowaniem głębokim a płytkim, więc wywołanie clone nie zrobiłoby nic innego niż zwykłe kopiowanie płytkie, i możemy to pominąć.
Rust posiada specjalną adnotację zwaną cechą Copy, którą możemy umieszczać na typach przechowywanych na stosie, tak jak to jest w przypadku liczb całkowitych (więcej o cechach omówimy w Rozdziale 10). Jeśli typ implementuje cechę Copy, zmienne, które jej używają, nie są przenoszone, lecz są trywialnie kopiowane, co sprawia, że pozostają ważne po przypisaniu do innej zmiennej.
Rust nie pozwoli nam zaadnotować typu jako Copy, jeśli typ, lub którakolwiek z jego części, zaimplementował cechę Drop. Jeśli typ potrzebuje, aby coś specjalnego się stało, gdy wartość wyjdzie poza zasięg, a my dodamy adnotację Copy do tego typu, otrzymamy błąd kompilacji. Aby dowiedzieć się, jak dodać adnotację Copy do swojego typu w celu zaimplementowania cechy, zobacz „Cechy pochodne” w Dodatku C.
Więc, jakie typy implementują cechę Copy? Aby być pewnym, możesz sprawdzić dokumentację dla danego typu, ale ogólnie rzecz biorąc, każda grupa prostych wartości skalarnych może implementować Copy, a nic, co wymaga alokacji lub jest jakąś formą zasobu, nie może implementować Copy. Oto niektóre typy, które implementują Copy:
- Wszystkie typy całkowite, takie jak
u32. - Typ boolowski,
bool, z wartościamitrueifalse. - Wszystkie typy zmiennoprzecinkowe, takie jak
f64. - Typ znakowy,
char. - Krotki, jeśli zawierają tylko typy, które również implementują
Copy. Na przykład(i32, i32)implementujeCopy, ale(i32, String)nie.
Własność i Funkcje
Mechanizmy przekazywania wartości do funkcji są podobne do tych, które występują przy przypisywaniu wartości do zmiennej. Przekazanie zmiennej do funkcji spowoduje przeniesienie lub skopiowanie, tak jak przypisanie. Listing 4-3 zawiera przykład z adnotacjami pokazującymi, gdzie zmienne wchodzą w zasięg i wychodzą z niego.
fn main() {
let s = String::from("hello"); // s wchodzi w zasięg
takes_ownership(s); // wartość s przenosi się do funkcji...
// ... i dlatego jest tutaj nieważna
let x = 5; // x wchodzi w zasięg
makes_copy(x); // Ponieważ i32 implementuje cechę Copy,
// x NIE przenosi się do funkcji,
// więc można używać x później.
} // Tutaj x wychodzi z zasięgu, potem s. Jednakże, ponieważ wartość s została przeniesiona,
// nic specjalnego się nie dzieje.
fn takes_ownership(some_string: String) { // some_string wchodzi w zasięg
println!("{some_string}");
} // Tutaj some_string wychodzi z zasięgu i wywoływane jest `drop`. Pamięć bazowa
// jest zwalniana.
fn makes_copy(some_integer: i32) { // some_integer wchodzi w zasięg
println!("{some_integer}");
} // Tutaj some_integer wychodzi z zasięgu. Nic specjalnego się nie dzieje.
Jeśli spróbowalibyśmy użyć s po wywołaniu takes_ownership, Rust wyrzuciłby błąd kompilacji. Te statyczne sprawdzenia chronią nas przed błędami. Spróbuj dodać kod do main, który używa s i x, aby zobaczyć, gdzie można ich używać, a gdzie zasady własności uniemożliwiają to.
Wartości zwracane i zasięg
Zwracanie wartości może również przenosić własność. Listing 4-4 przedstawia przykład funkcji, która zwraca pewną wartość, z podobnymi adnotacjami jak te z Listingu 4-3.
fn main() {
let s1 = gives_ownership(); // gives_ownership przenosi swoją wartość
// zwracaną do s1
let s2 = String::from("hello"); // s2 wchodzi w zasięg
let s3 = takes_and_gives_back(s2); // s2 jest przenoszone do
// takes_and_gives_back, które również
// przenosi swoją wartość zwracaną do s3
} // Tutaj s3 wychodzi z zasięgu i jest usuwane. s2 zostało przeniesione, więc nic
// się nie dzieje. s1 wychodzi z zasięgu i jest usuwane.
fn gives_ownership() -> String { // gives_ownership przeniesie swoją
// wartość zwracaną do funkcji,
// która ją wywoła
let some_string = String::from("yours"); // some_string wchodzi w zasięg
some_string // some_string jest zwracane i
// przenosi się do funkcji
// wywołującej
}
// Ta funkcja przyjmuje String i zwraca String.
fn takes_and_gives_back(a_string: String) -> String {
// a_string wchodzi w
// zasięg
a_string // a_string jest zwracane i przenosi się do funkcji wywołującej
}
Własność zmiennej zawsze podlega temu samemu wzorcowi: przypisanie wartości do innej zmiennej powoduje jej przeniesienie. Gdy zmienna zawierająca dane na stercie wyjdzie poza zasięg, wartość zostanie posprzątana przez drop, chyba że własność danych została przeniesiona do innej zmiennej.
Chociaż to działa, przejmowanie własności, a następnie zwracanie własności przy każdej funkcji jest trochę męczące. Co, jeśli chcemy pozwolić funkcji użyć wartości, ale nie przejmować własności? Jest to dość denerwujące, że wszystko, co przekazujemy, musi być również zwrócone, jeśli chcemy użyć tego ponownie, oprócz wszelkich danych wynikających z treści funkcji, które również możemy chcieć zwrócić.
Rust pozwala nam zwracać wiele wartości za pomocą krotki, jak pokazano w Listingu 4-5.
fn main() {
let s1 = String::from("hello");
let (s2, len) = calculate_length(s1);
println!("Długość '{s2}' wynosi {len}.");
}
fn calculate_length(s: String) -> (String, usize) {
let length = s.len(); // len() zwraca długość String
(s, length)
}
Ale to zbyt wiele ceremonii i dużo pracy dla koncepcji, która powinna być powszechna. Na szczęście Rust ma funkcję do używania wartości bez przenoszenia własności: referencje.