Czym jest własność?
Własność to zestaw reguł, które determinują, jak program Rusta zarządza pamięcią. Wszystkie programy muszą kontrolować sposób wykorzystywania pamięci komputera podczas swojego działania. Niektóre języki korzystają z automatycznego systemu odśmiecania (garbage collection), który regularnie poszukuje fragmentów pamięci, których działający program już nie używa. W innych językach, sam programista ręcznie zajmuje i zwalnia pamięć. Rust wykorzystuje trzecie podejście: pamięć jest zarządzana przez system własności, obejmujący zestaw zasad sprawdzanych przez kompilator w trakcie kompilacji. Jeśli którakolwiek z reguł zostanie naruszona, program nie skompiluje się. Jednocześnie żaden z aspektów związanych z systemem własności nie spowalnia działania programu.
Ponieważ własność jest nowym pojęciem dla wielu programistów, przyzwyczajenie się do niej zabiera trochę czasu. Dobra wiadomość jest taka, że w miarę nabywania doświadczenia z Rustem i zasadami systemu własności, rośnie też twoja zdolność do naturalnego tworzenia bezpiecznego i wydajnego kodu. Tak trzymaj!
Kiedy zrozumiesz system własności, będziesz mieć solidną podstawę ku zrozumieniu innych, unikatowych funkcjonalności Rusta. W tym rozdziale nauczysz się, czym jest własność za pomocą kilku przykładów, które skupiają się na bardzo często spotykanej strukturze danych: łańcuchach znaków (string).
Stos i sterta
W wielu językach programowania nie trzeba zbyt często myśleć o stosie i o stercie. Ale w przypadku języka systemowego takiego jak Rust, to, czy zmienna zapisana jest na stosie czy na stercie, ma wpływ na zachowanie całego języka i określa, dlaczego należy podjąć niektóre decyzje. W dalszej części rozdziału opiszemy części systemu własności w odniesieniu do stosu i sterty, zaczynamy więc od krótkiego, przygotowawczego wyjaśnienia.
Zarówno stos jak i sterta są częściami pamięci dostępnymi dla programu w trakcie działania, ale charakteryzują się one odmienną strukturą. Stos przechowuje dane w takiej kolejności, w jakiej tam trafiają, a usuwane są z niego w kolejności odwrotnej. Działanie te określa się nazwą last in, first out (ostatni na wejściu, pierwszy na wyjściu). Pomyśl o stosie talerzy: kiedy kładziesz na nim nowe talerze, umieszczasz je na szczycie stosu, a kiedy jest ci jakiś potrzebny, zdejmujesz go ze szczytu. Dodawanie lub usuwanie talerzy ze środka lub ze spodu stosu nie jest już takie łatwe. Dodawanie danych nosi nazwę odkładania na stos (pushing onto the stack), a usuwanie ich nazywane jest zdejmowaniem ze stosu (popping off the stack).
Każda dana umieszczona na stosie musi mieć znany, stały rozmiar. Dane, których rozmiar jest nieznany na etapie kompilacji lub może ulegać zmianie, muszą być przechowywane na stercie. Sterta jest mniej zorganizowana: kiedy coś się na niej umieszcza, należy poprosić o przydzielenie pewnego jej obszaru. Alokator pamięci znajduje na stercie wolne, wystarczająco duże miejsce, oznacza je jako będące w użyciu i zwraca wskaźnik zawierający adres wybranej lokalizacji. Proces ten nazywamy alokacją na stercie lub po prostu alokacją. Umieszanie danych na stosie nie jest uznawane za alokację. Ze względu na to, że zwrócony wskaźnik posiada znany, ustalony rozmiar, możemy przechować go na stosie. Jednak gdy chcemy dostać się do właściwych danych, musimy podążyć za wskaźnikiem.
Pomyśl o byciu rozsadzanym w restauracji. Przy wejściu podajesz ilość osób w swojej grupie, a pracownik znajduje pusty stolik, przy którym wszyscy się pomieszczą i prowadzi ich na miejsce. Jeśli ktoś z twojej grupy sie spóźni, aby was znaleźć, może zapytać, gdzie was posadzono.
Odkładanie na stosie jest szybsze od alokacji na stercie, ponieważ alokator nigdy nie musi szukać miejsca na dodanie nowych danych; to miejsce znajduje się zawsze na szczycie stosu. Alokacja na stercie natomiast wymaga więcej pracy, ponieważ system operacyjny musi w pierwszej kolejności znaleźć wystarczająco dużo miejsca, aby dane się zmieściły. a następnie przeprowadzić niezbędne operacje, by przygotować się na następną alokację.
Dostęp do danych na stercie jest wolniejszy od dostępu do danych na stosie, ponieważ należy je zlokalizować korzystając ze wskaźnika. Nowoczesne procesory działają szybciej, jeżeli nie muszą dużo skakać po pamięci. Kontynuując analogię, załóżmy, że kelner w restauracji zbiera zamówienia z wielu stolików. Bardziej wydajne jest zebranie wszystkich zamówień z jednego stolika, zanim przejdzie się do kolejnego. Zebranie pojedynczego zamówienia ze stolika A, następnie jednego ze stolika B, kolejnego znów ze stolika A i powtórnie ze stolika B, byłoby zdecydowanie wolniejszym procesem. Z tego samego względu, procesor wykonuje swoje zadanie lepiej, operując na danych sąsiadujących z innymi danymi (jak ma to miejsce na stosie), niż gdyby operował na danych oddalonych od siebie (co może się zdarzyć w przypadku sterty). Alokacja sporego obszaru pamięci na stercie również może potrwać.
Kiedy twój kod wywołuje funkcję, przekazywane do niej argumenty (łącznie z potencjalnymi wskaźnikami do danych na stercie) oraz jej wewnętrzne zmienne są odkładane na stosie. Gdy funkcja się kończy, wartości te są zdejmowane ze stosu.
Do problemów, którym przeciwstawia się system własności, należą: śledzenie, które fragmenty kodu używają których danych na stercie, minimalizowanie duplikowania się danych na stercie, a także pozbywanie się ze sterty nieużywanych danych, celem uniknięcia wyczerpania się pamięci. Po zrozumieniu pojęcia własności, nie będziesz juz musiał zbyt często myśleć o stosie czy o stercie. Jednak świadomość tego, że zarządzanie danymi na stercie jest istotą istnienia systemu własności, pomaga wyjaśnić, dlaczego działa on tak, jak działa.
Zasady systemu własności
W pierwszej kolejności przyjrzyjmy się zasadom systemu własności. Proszę mieć je na uwadze, kiedy będziemy omawiać ilustrujące je przykłady:
- Każda wartość w Ruście ma właściciela.
- W danym momencie może istnieć tylko jeden właściciel.
- Kiedy sterowanie wychodzi poza zasięg właściciela, wartość zostaje zwolniona.
Zasięg zmiennych
Teraz, kiedy znamy już podstawy składni, nie będziemy umieszczać w treści przykładów kodu fn main() {
. Jeśli zatem przepisujesz kod na bieżąco, musisz ręcznie umieszczać zaprezentowane dalej fragmenty wewnątrz funkcji main
. Dzięki temu, przykłady będą nieco bardziej zwięzłe, pozwalając nam skupić się na istocie sprawy zamiast na powtarzalnych frazach.
W pierwszym przykładzie systemu własności, przyjrzymy się zasięgowi kilku zmiennych. Zasięgiem elementu nazywamy obszar programu, wewnątrz którego dany element zachowuje ważność (istnieje). Powiedzmy, że mamy zmienną, która wygląda tak:
#![allow(unused)] fn main() { let s = "witaj"; }
Zmienna s
odnosi się do literału łańcuchowego, którego wartość jest ustalona w samym kodzie programu. Zmienna zachowuje ważność od miejsca, w którym ją zadeklarowano, do końca bieżącego zasięgu. Listing 4-1 zawiera komentarze
wyjaśniające, gdzie zmienna s
zachowuje ważność.
fn main() { { // s nie ma tu jeszcze ważności - jeszcze jej nie zadeklarowano let s = "witaj"; // od tego momentu s ma ważność // jakieś operacje na s } // bieżący zasięg się kończy - s traci ważność }
Innymi słowy, mamy do czynienia z dwoma istotnymi momentami w czasie:
- Kiedy zmienna
s
wchodzi w zasięg, zyskuje ważność. - Zmienna pozostaje ważna, dopóki nie wyjdzie z zasięgu.
Na tę chwilę zależność między zasięgiem a ważnością zmiennych jest podobna do
sytuacji w innych językach programowania. Posłużymy się tą wiedzą, wprowadzając
nowy typ danych: String
(łańcuch znaków).
Typ String
Aby zilustrować zasady systemu własności, potrzebujemy typu danych, który jest bardziej złożony od tych, które omawiane były w sekcji „Typy danych” rozdziału 3. Wszystkie opisane tam typy przechowywane są na stosie i są z niego zdejmowane, kiedy skończy się ich zasięg. Potrzebny jest nam natomiast typ przechowujący zawarte w nim dane na stercie. Dowiemy się wówczas, skąd Rust wie, kiedy te dane usunąć.
W przykładzie użyjemy typu String
, koncentrując się na tych jego elementach,
które odnoszą się do systemu własności. Te same elementy mają znaczenie dla
innych złożonych typów, które dostarcza biblioteka standardowa oraz tych, które
stworzysz sam. Typ String
omawiany będzie dogłębnie w rozdziale 8.
Widzieliśmy już literały łańcuchowe, których dane na stałe umieszczone są w treści
programu. Takie zmienne są wygodne w użyciu, ale nieprzydatne w wielu
sytuacjach, w których używa się danych tekstowych. Jednym z powodów jest to, że
są one niemodyfikowalne. Innym, że nie każda zawartość łańcucha tekstowego jest
znana podczas pisania programu. Na przykład: co zrobić, jeśli chcemy pobrać dane
od użytkownika i je przechować? Dla takich sytuacji Rust przewiduje drugi typ
łańcuchowy: String
. Typ ten alokowany jest na stercie i z tego względu może
przechowywać dane, których ilość jest nieznana podczas kompilacji. Można
przekształcić niemodyfikowalny literał łańcuchowy w zmienną typu String
za pomocą
funkcji from
. Wygląda to tak:
#![allow(unused)] fn main() { let s = String::from("witaj"); }
Podwójny dwukropek ::
jest operatorem umożliwiającym wykorzystanie funkcji
from
z przestrzeni nazw typu String
, zamiast konieczności utworzenia
ogólnej funkcji o przykładowej nazwie string_from
. Ten rodzaj składni będzie
szerzej omawiany w sekcji „Składnia metod” w
rozdziale 5 oraz podczas rozważań o przestrzeniach nazw modułów w sekcji
„Ścieżki odnoszenia się do elementów w hierarchii modułów”
w rozdziale 7.
Ten rodzaj łańcucha znaków można modyfikować:
fn main() { let mut s = String::from("witaj"); s.push_str(", świecie!"); // push_str() dodaje literał do zmiennej String println!("{}", s); // To spowoduje wyświetlenie tekstu: `hello, world!` }
Jaka jest zatem różnica? Dlaczego String
może być modyfikowalny, a literał
nie? Różnica polega na sposobie, w jakim oba te typy korzystają z pamięci.
Pamięć i alokacja
W przypadku literału łańcuchowego, jego wartość znana jest już w czasie kompilacji, więc przechowywany tekst jest na stałe zakodowany w docelowym pliku wykonywalnym, co czyni literały szybkimi i wydajnymi. Ale cechy te wynikają z niemodyfikowalności literałów. Niestety, nie możemy w pliku binarnym umieścić bańki pamięci pod każdy potrzebny tekst, którego rozmiar jest nieznany podczas kompilacji i może się zmienić w trakcie działania programu.
Mając typ String
, w celu obsługi modyfikowalnego i potencjalnie rosnącego
tekstu, musimy zaalokować pewną ilość pamięci na stercie, nieznaną podczas
kompilacji. To oznacza, że:
- O przydział pamięci należy poprosić alokator w trakcie wykonywania programu.
- Potrzebny jest sposób na oddanie pamięci do alokatora, kiedy
String
nie będzie już potrzebny.
Pierwszą część robimy sami, wywołując funkcję String::from
, której
implementacja zawiera prośbę o wymaganą pamięć. Podobne rozwiązanie jest w
w wielu innych językach programowania.
Druga część znacznie się za to różni. W językach wyposażonych w systemy
odśmiecania (garbage collector - GC), GC śledzi i zwalnia pamięć, która nie
jest już używana, a my nie musimy już o tym myśleć. W językach pozbawionych GC,
naszą odpowiedzialnością jest identyfikowanie nieużywanej już pamięci i
bezpośrednie wywoływanie zarówno kodu, który tę pamięć zwalnia, jak i tego,
który ją alokuje. Poprawne wykonanie tej operacji stanowiło historycznie trudny,
programistyczny problem. Jeśli zapomnimy, marnujemy pamięć. Jeśli zrobimy to za
wcześnie, zostaniemy z unieważnioną zmienną. Zrobimy to dwukrotnie - to też błąd.
Musimy połączyć w pary dokładnie jedną alokację
z dokładnie jednym
zwolnieniem
.
Rust prezentuje inne podejście: pamięć jest automatycznie zwalniana,
kiedy skończy się zasięg zmiennej będącej jej właścicielem. Oto wersja naszego
przykładu z listingu 4-1, który używa typu String
zamiast literału:
fn main() { { let s = String::from("witaj"); // s ma ważność od tego momentu // jakieś operacje na s } // bieżący zasięg się kończy - s traci // ważność }
Istnieje naturalny moment, w którym można oddać pamięć wykorzystywaną przez
nasz String
do alokatora - kiedy kończy się zasięg zmiennej s
.
Kiedy zasięg jakiejś zmiennej się kończy, Rust wywołuje za nas specjalną
funkcję. Funkcja ta nosi nazwę drop
(porzuć, upuść), a w jej treści autor typu
String
umieścił kod zwalniający pamięć. Funkcja drop
zostaje wywołana przez
Rusta automatycznie, przy klamrze zamykającej.
Uwaga: W C++ schemat dealokacji zasobów przy końcu czasu życia jakiegoś elementu jest czasem nazywany Inicjowaniem Przy Pozyskaniu Zasobu (Resource Acquisition Is Initialization (RAII)). Funkcja
drop
z Rusta może wydać się znajoma osobom, które miały styczność ze schematami RAII.
Schemat ten ma ogromny wpływ na sposób pisania kodu w Ruście. Na tym etapie może wydawać się to proste, ale program może zachować się niespodziewanie w bardziej złożonych przypadkach, kiedy chcemy, aby kilka zmiennych używało tej samej danej, alokowanej na stercie. Zbadajmy teraz kilka takich sytuacji.
Przenoszenie Zmiennych i Danych
Kilka zmiennych może w Ruście odnosić się do tej samej danej na różne sposoby. Spójrzmy na przykład w listingu 4-2, z wykorzystaniem liczby całkowitej:
fn main() { let x = 5; let y = x; }
Z całą pewnością możemy odgadnąć, co ten kod robi: „przypisuje 5
do x
,
a następnie wykonuje kopię wartości przechowywanej w x
i przypisuje ją do
y
.”. Mamy teraz dwie zmienne: x
i y
, obie o wartości 5
. Dzieje się
dokładnie tak, ponieważ liczby całkowite są prostymi wartościami o
stałym rozmiarze, więc obie wartości 5
zostają odłożone na stos.
Teraz przyjrzyjmy się wersji z typem String
:
fn main() { let s1 = String::from("witaj"); let s2 = s1; }
Wygląda to bardzo podobnie do wcześniejszego kodu, więc możemy zakładać, że jego
działanie też będzie podobne: w drugiej linii powstaje kopia wartości w s1
i
zostaje ona przypisana do s2
. Ale tak się akurat nie dzieje.
Rysunek 4-1 objaśnia, co dzieje się we wnętrzu typu String
. Typ String
składa się z trzech części, pokazanych po lewej stronie. Są to: wskaźnik do
pamięci przechowującej właściwy łańcuch znaków, znacznik jego długości
(length) i dane o ilości pamięci dostępnej dla danego ciągu (capacity). Ta
grupa danych przechowywana jest na stosie. Po prawej pokazano obszar pamięci na
stercie, który zawiera tekst.
Length
(długość) wskazuje, ile bajtów pamięci zajmuje bieżący ciąg znaków w
zmiennej typu String
, natomiast capacity
(pojemność) przechowuje dane o całkowitej
ilości pamięci, jaką alokator dla tej zmiennej przydzielił. Różnica
między długością i pojemnością ma znaczenie, ale nie w tym kontekście. Dlatego na
razie możemy zignorować pojemność.
Kiedy przypisujemy s1
do s2
, dane ze zmiennej typu String
zostają
skopiowane. Dotyczy to przechowywanych na stosie: wskaźnika, długości i
pojemności. Dane tekstowe, do których odnosi się wskaźnik nie są kopiowane.
Innymi słowy, reprezentację pamięci w tej sytuacji ilustruje Rysunek 4-2.
Rysunek 4-3 ukazuje nieprawdziwą reprezentację pamięci, w której Rust również
skopiował dane na stercie. Gdyby taka sytuacja miała miejsce, operacja
s2 = s1
mogłaby potencjalnie zająć dużo czasu, w przypadku sporej ilości
danych na stercie.
Wcześniej powiedzieliśmy, że kiedy zasięg zmiennej się kończy, Rust wywołuje
automatycznie funkcję drop
i zwalnia obszar na stercie dla tej zmiennej. Ale
na Rysunku 4-2 przedstawiono sytuację, w której oba wskaźniki wskazują na ten
sam obszar. Jest to problematyczne: kiedy zasięg s2
i s1
się skończy,
nastąpi próba dwukrotnego zwolnienia tej samej pamięci. Sytuacja ta jest znana
jako błąd podwójnego zwolnienia i należy do grupy bugów bezpieczeństwa
pamięci, o których wcześniej wspomnieliśmy. Podwójne zwalnianie pamięci może
prowadzić do jej zepsucia, a w efekcie do potencjalnych luk bezpieczeństwa.
Aby zapewnić bezpieczeństwo pamięci, po linii let s2 = s1;
, zamiast próbować skopiować zaalokowaną pamięć, Rust
traktuje zmienną s1
jako unieważnioną i, tym samym, nie musi nic zwalniać, kiedy zasięg s1
się kończy. Zobaczmy, co stanie się przy próbie użycia zmiennej s1
po utworzeniu zmiennej s2
. Próba się nie powiedzie:
fn main() {
let s1 = String::from("witaj");
let s2 = s1;
println!("{}, świecie!", s1);
}
Rust zwróci poniższy błąd, ponieważ nie zezwala na odnoszenie się do elementów przy użyciu unieważnionych zmiennych:
$ cargo run
Compiling ownership v0.1.0 (file:///projects/ownership)
error[E0382]: borrow of moved value: `s1`
--> src/main.rs:5:28
|
2 | let s1 = String::from("witaj");
| -- move occurs because `s1` has type `String`, which does not implement the `Copy` trait
3 | let s2 = s1;
| -- value moved here
4 |
5 | println!("{}, świecie!", s1);
| ^^ 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` due to previous error
Jeśli słyszałeś terminy „płytka kopia” oraz „głęboka kopia” pracując
z innymi językami, to z pewnością wiesz, że skopiowanie wskaźnika ze znacznikami długości
i pojemności, ale bez kopiowania danych, przypomina tworzenie płytkiej kopii.
Ale ponieważ Rust jednocześnie unieważnia źródłową zmienną, zamiast nazywać taki
proces płytką kopią, używa się terminu przeniesienie. W tym przypadku
moglibyśmy powiedzieć, że zmienna s1
została przeniesiona do s2
. Rysunek
4-4 ilustruje, co tak naprawdę dzieje się w pamięci.
To rozwiązuje nasz problem! Jeśli jedynie zmienna s2
zachowuje ważność, to w
momencie wyjścia z zasięgu, jako jedyna zwolni zajmowaną pamięć i po sprawie.
Dodatkowo, implikuje to decyzję w budowie języka: Rust nigdy automatycznie nie tworzy „głębokich” kopii twoich danych. Można zatem założyć, że automatyczny proces kopiowania nie będzie drogą operacją w sensie czasu jej trwania.
Klonowanie zmiennych i danych
W przypadku gdy chcemy wykonać głęboką kopię danych ze sterty dla typu
String
, a nie tylko danych ze stosu, możemy skorzystać z często stosowanej
metody o nazwie clone
(klonuj). Składnia metod będzie omawiana w rozdziale
5, ale ponieważ metody są popularnymi funkcjonalnościami wielu języków, zapewne
już je wcześniej widziałeś.
Oto przykład działania metody clone
:
fn main() { let s1 = String::from("witaj"); let s2 = s1.clone(); println!("s1 = {}, s2 = {}", s1, s2); }
Ten przykład działa bez problemu i ilustruje on celowe odtworzenie zachowania pokazanego na Rysunku 4-3, na którym dane ze sterty są kopiowane.
Kiedy widzisz odwołanie do metody clone
, możesz się spodziewać, że wykonywana
operacja będzie kosztowna czasowo.
Dane przechowywane wyłącznie na stosie: Copy (kopiowanie)
Jest jeszcze jeden szczegół, którego nie omówiliśmy. Kod korzystający z liczb całkowitych, którego treść pokazano na listingu 4-2, działa i jest prawidłowy:
fn main() { let x = 5; let y = x; println!("x = {}, y = {}", x, y); }
Zdaje się on przeczyć temu, czego przed chwilą się nauczyliśmy: nie mamy
wywołania clone
, ale zmienna x
zachowuje ważność i nie zostaje
przeniesiona do y
.
Przyczyną jest to, że typy takie jak liczby całkowite, które mają znany rozmiar
już podczas kompilacji, są w całości przechowywane na stosie. Tworzenie kopii
ich wartości jest więc szybkie. To oznacza, że nie ma powodu unieważniać zmiennej
x
po stworzeniu zmiennej y
. Innymi słowy, w tym wypadku nie ma różnicy
między głęboką i płytką kopią, więc wywołanie metody clone
nie różniłoby się
od zwykłego płytkiego kopiowania i można je zatem pominąć.
Rust zawiera specjalną adnotację zwaną „cechą Copy
”, którą można
zaimplementować dla typów przechowywanych na stosie, takich jak liczby
całkowite (więcej o cechach będzie w rozdziale 10). Jeśli dany typ ma
zaimplementowaną cechę Copy
, zmienną, którą przypisano do innej zmiennej,
można dalej używać.
Rust nie pozwoli dodać adnotacji Copy
do żadnego typu, dla którego zaimplementowano (lub zaimplementowano dla jakiejkolwiek jego części tego typu) cechę Drop
. Jeśli typ wymaga
wykonania konkretnych operacji po tym, jak reprezentującej go zmiennej kończy
się zasięg, a dodamy dla tego typu cechę Copy
, uzyskamy błąd kompilacji. Aby
nauczyć się, jak implementować cechę Copy
dla danego typu, zajrzyj do
„Cechy wyprowadzalne” w Dodatku C.
Które więc typy mają cechę Copy
? Dla danego typu można dla pewności sprawdzić
w dokumentacji, ale jako regułę zapamiętaj, że każda grupa wartości skalarnych
może mieć cechę Copy
i nic, co wymaga alokacji lub jest pewnego rodzaju
zasobem jej nie ma. Oto przykłady typów z zaimplementowaną cechą Copy
:
- Wszystkie typy całkowite, takie jak
u32
. - Typ logiczny,
bool
, z wartościamitrue
orazfalse
. - Wszystkie typy zmiennoprzecinkowe, jak
f64
. - Typ znakowy,
char
. - Krotki, jeśli zawierają wyłącznie typy z cechą
Copy
. Na przykład,(i32, i32)
ma cechęCopy
, ale(i32, String)
już nie.
Własność i funkcje
Mechanika przekazywania wartości do funkcji jest podobna do przypisania wartości do zmiennej. Przekazanie zmiennej do funkcji przeniesie ją lub skopiuje, tak jak przy przypisywaniu. Listing 4-3 ukazuje przykład z kilkoma adnotacjami ilustrującymi, kiedy zaczynają się lub kończą zasięgi zmiennych:
Plik: src/main.rs
fn main() { let s = String::from("witaj"); // s pojawia się w zasięgu bierze_na_wlasnosc(s); // Wartość zmiennej s przenosi się do funkcji... // ...i w tym miejscu zmienna jest unieważniona. let x = 5; // Zaczyna się zasięg zmiennej x. robi_kopie(x); // Wartość x przeniosłaby się do funkcji, ale // typ i32 ma cechę Copy, więc w dalszym ciągu // można używać zmiennej x. } // Tu kończy sie zasięg zmiennych x, a potem s. Ale ponieważ wartość s // została przeniesiona, nie dzieje się nic szczególnego. fn bierze_na_wlasnosc(jakis_string: String) { // Zaczyna się zasięg some_string. println!("{}", jakis_string); } // Tu kończy się zasięg jakis_string i wywołana zostaje funkcja `drop`. // Zajmowana pamięć zostaje zwolniona. fn robi_kopie(jakas_calkowita: i32) { // Zaczyna się zasięg jakas_calkowita. println!("{}", jakas_calkowita); } // Tu kończy się zasięg jakas_calkowita. Nic szczególnego się nie dzieje.
Gdybyśmy spróbowali użyć s
po wywołaniu bierze_na_wlasnosc
, Rust
wygenerowałby błąd kompilacji. Te statyczne kontrole chronią nas przed
popełnianiem błędów. Spróbuj dodać do main
kod, który używa zmiennych s
oraz
x
, żeby zobaczyć, gdzie można ich używać, a gdzie zasady systemu własności nam
tego zabraniają.
Wartości zwracane i ich zasięg
Wartości zwracane mogą również przenosić własność. Listing 4-4 ilustruje przykład z podobnymi komentarzami do tych z listingu 4-3.
Plik: src/main.rs
fn main() { let s1 = daje_wlasnosc(); // daje_wlasnosc przenosi zwracaną // wartość do s1. let s2 = String::from("witaj"); // Rozpoczyna się zasięg s2. let s3 = bierze_i_oddaje(s2); // s2 zostaje przeniesiona do // bierze_i_oddaje, która jednocześnie // przenosi swoją wartość zwracaną do s3. } // Tutaj kończy się zasięg s3 i jej dane zostają zwolnione. Zasięg s2 też, ale // ponieważ jej dane przeniesiono, nic się nie dziejej. Zasięg s1 kończy się, // a jej dane zostają zwolnione. fn daje_wlasnosc() -> String { // daje_wlasnosc przenosi jej // wartość zwracaną do funkcji, // która ją wywołała. let jakis_string = String::from("wasze"); // Początek zasięgu jakis_string. jakis_string // jakis_string jest zwracany i // przeniesiony do funkcji // wywołującej. } // bierze_i_oddaje bierze dane w zmiennej String i zwraca je w innej. fn bierze_i_oddaje(a_string: String) -> String { // Rozpoczyna się zasięg // zmiennej a_string. a_string // a_string zostaje zwrócona i przeniesiona do funkcji wywołującej. }
Własność zmiennej zachowuje się zawsze w ten sam sposób: przypisanie wartości do
innej zmiennej przenosi tę wartość. Kiedy kończy się zasięg zmiennej
zawierającej dane ze sterty, dane te zostaną zwolnione przez drop
, chyba że
przekażemy je na własność innej zmiennej.
Przyjmowanie własności, a następnie oddawanie jej przy wywołaniu każdej funkcji jest trochę pracochłonne. A co jeśli chcemy zezwolić funkcji na użycie wartości, ale nie chcemy, by przejęła ją na własność? To dość denerwujące, kiedy wszystko, co przekazujemy, musi zostać powtórnie zabrane, jeśli chcemy tego ponownie użyć. Nie mówiąc już o danych generowanych przy okazji normalnego działania funkcji, które być może także chcielibyśmy zwrócić.
Z funkcji można zwrócić kilka wartości za pomocą krotki. Listing 4-5 ilustruje ten przypadek.
Plik: src/main.rs
fn main() { let s1 = String::from("witaj"); let (s2, len) = oblicz_dlugosc(s1); println!("Długość '{}' wynosi {}.", s2, len); } fn oblicz_dlugosc(s: String) -> (String, usize) { let dlugosc = s.len(); // len() zwraca długość łańcucha znaków. (s, dlugosc) }
Wymaga to dużo niepotrzebnej pracy, podczas gdy koncept ten spotykany jest powszechnie. Na szczęście dla nas, Rust wyposażony jest w referencje, które świetnie obsługują takie przypadki.