Rust – Moving Ownership – Przenoszenie własności

W pierwszy odcinku tego cyklu przedstawiłem, czym jest Ownership i dlaczego jest użytecznym mechanizmem. W tym odcinku przedstawię bliżej podstawowy mechanizm związany z zarządzaniem własnością.

Żeby pokazać, o co mi chodzi, spójrzmy przez moment na świat rzeczywisty. Wyobraźcie sobie, że zmienne są jak książki. Fizyczny egzemplarz książki jest powiązany z jednym właścicielem i tylko jednym. Ów właściciel może zrobić, co zechce z ową książką: może ją komuś oddać, może ją komuś przekazać, ale nie może być więcej, niż jeden właściciel danego egzemplarza.

Reguła numer jeden jest dość prosta: Może istnieć tylko jeden właściciel. Gdy chcemy, by ktoś miał dostęp do naszego egzemplarza książki, musimy mu ją dać lub pożyczyć.

Dokładnie ten model pomaga w zrozumieniu działania języka Rust – jeśli chcemy, by ktoś inny (zakres, funkcja, inna zmienna) miał dostęp do fragmentu pamięci, musimy mu go przekazać.

Gdy deklarujemy zmienną (tak jak w poniższym kodzie), sprawiamy, że zmienna staje się właścicielem. Zmienna ma swój scope życia. W poniższym przypadku jej zakres życia to odpowiednio:

  • Linia deklaracji oznaczająca początek istnienia zmiennej.
  • Linia końca zakresu { oznaczająca koniec życia zmiennej.
{
   let id: u8 = 1;
}

Gdy zmienna będąca właścicielem fragmentu pamięci wychodzi poza zakres związany z nią, to zajęty przez nią fragment pamięci zostaje zwolniony. Po tym nikt nie ma prawa uzyskać dostępu do zmiennej. Jeśli spróbujemy, zostaniemy o tym poinformowani na etapie kompilacji.

To są podstawowe pojęcia związane z samą własnością.

Dlaczego to jest tak bardzo ważne?

Dzięki temu nie będzie możliwe np:

  • Odwołanie się do wcześniej zwolnionej zmiennej.
  • Zwolnienie danego segmentu pamięci dwukrotnie.

Gdyby więcej zmiennych miało przypisaną własność, biorąc pod uwagę także argumenty funkcji, bardzo trudne mogłoby być określenie, gdzie kończy się życie danej zmiennej. Wcześniej opisane zjawiska byłyby na porządku dziennym. Mogłyby także pojawić się problemy związane z współbieżnością np: wyścigi.

A co, gdy jednak będziemy potrzebować, by dana zmienna była dostępna w innych miejscach, np. w wewnętrznym scopie lub przekazać zmienną do funkcji jako parametr?

Moving ownership – Przeniesienie własności

Wracam do analogii z książką – by ktoś mógł przeczytać moją książkę, muszę tej osobie udostępnić ową książkę.

By móc użyć zmienną w innym zakresie, musimy zatem dać temu zakresowi dostęp do tej zmiennej. Mechanizmów umożliwiających swobodne korzystanie ze zmiennej jest kilka. Dziś przyjrzyjmy się mechanizmowi Moving Ownership czyli przekazania lub przeniesienia własności.

Przeniesienie własności a typy złożone

Wprowadzamy własny typ Book. Skoro już korzystamy z metafory książki 🙂

struct Book{
   title: String,
}

Jeśli najpierw tworzymy zmienną book (2 linijka), która jest typu złożonego, a następnie przypiszemy za pomocą operatora przypisania (=) do innej zmiennej, to własność, która należała do zmiennej book, zostanie przeniesiona na zmienną book2.

Jakie są tego implikacje:

  • Od tej pory zmienna `book2` jest właścicielem.
  • Od tej pory jakiekolwiek wykorzystanie zmiennej `book` zakończy się błędem na etapie kompilacji.
  • Gdy zmienna `book2` wyjdzie z zakresu użycia to powiązany fragment pamięci zostanie zwiolniony.

Jest to bardzo istotne, że fragment pamięci zostanie usunięty tylko wtedy, gdy aktualny (a zarazem jedyny) właściciel wyjdzie poza zakres użycia. Wtedy skończy się zakres życia zmiennej.

Dzięki czemu też unikniemy potencjalnego problemu zwalniania już zwolnionego wcześniej fragmentu pamięci.

Dzięki takiej sytuacji nie będzie także możliwe odwołanie do fragmentu pamięci, który mógł zostać zwolniony.

Przyjrzyjmy się przykładowi.

fn main() {
   let book: Book = Book {title: String::from("The Title")};
   let book2 = book;
   println!("Book title:{}", book.title);
   println!("Book2 title:{}", book2.title);
}

Pojawi się błąd na etapie kompilacji:

error[E0382]: borrow of moved value: `book`
 --> src/main.rs:5:30
  |
4 |    let book2 = book;
  |                ---- value moved here
5 |    println!("Book title:{}", book.title);
  |                              ^^^^^^^^^^ value borrowed here after move

Uruchom przykład

Gdy spróbujemy odwołać się do zmiennej book (linia 5) kompilator poinformuje nas, że własność została przeniesiona („value moved here”).

W innych językach, np. w C# taka operacja wiązałaby się z tworzeniem aliasu. Obie zmienne book oraz book2 wskazywałyby na ten sam fragment pamięci. Gdyby któryś z nich został usunięty, to mogłoby doprowadzić do nieuprawnionego odwołania do pamięci, a dla ludzi o mocnych nerwach do NullReferenceExceptiona 😉

W przypadku Rust uruchomienie kompilacji zakończy się błędem.

Co tu się zadziało?

W przypadku typów złożonych nastąpiło przeniesienie własności (Moving Ownership).

Tak, jak w przypadku książek, jeśli chcemy, by ktoś inny stał się właścicielem, to musimy mu jawnie oddać książkę. Od tego momentu to nowy właściciel może ją w pełni użytkować, a stary już nie. Bo nie ma dostępu do fizycznego egzemplarza. Jeśli oddałbym komuś książkę, to już dłużej nie mogę jej czytać.

Przeniesienie własności a typy proste

Gdy napiszemy podobny fragment kodu, ale operujący na typach prostych:

fn main() {
   let id: u8 = 1;
   let id2: u8 = id;
   println!("{}", id);
   println!("{}", id2);
}

Uruchom przykład

Uruchomienie kompilacji zakończy się sukcesem.

Co tu się stało?

Zmienna id jest właścicielem wartości 1 (dla uproszczenia, bo już pewnie wiecie, że chodzi o fragment pamięci na stosie, który przechowuje wartość 1.). W momencie przypisania została wykonana twarda kopia. Nie dochodzi tu do przeniesienia własności, ale do utworzenia pełnoprawnej kopii. W tym samym czasie istnieją dwa identyczne co do wartości fragmenty pamięci, ale znajdujące się w innym miejscu. Dzięki czemu możemy bezproblemowo odwoływać się do obu zmiennych.

Nie doszło tu do przeniesienie własności, a do zrobienia pełnoprawnej kopii.

Różne traktowanie zmiennych

Rust wymaga rozróżnienia dwóch rodzajów typów.

Inne zachowanie w zakresie Ownership możemy zaobserwować dla typów prostych, a inne dla typów złożonych. * Typy proste, których rozmiar, a co za tym idzie zapotrzebowanie na pamięć, znamy na etapie kompilacji. Mogą być przechowywane na stosie, dzięki czemu dostęp do nich jest stosunkowo szybki. Łatwiej jest też wykonać ich pełną kopie, gdy zajdzie potrzeba. * Typy złożone, których rozmiar może nie być znany na etapie kompilacji, np: struktury, kolekcje. Każda struktura w Rust ma z góry określony, znany w trakcie kompilacji typ. Ich rozmiar może się zmieniać w trakcie działania aplikacji. Z racji ich charakterystyki zachowania przechowywane są na stercie, dostęp do nich jest wolniejszy, co też wynika z charakterystyki działania i zarządzania stertą. Stworzenie kopii wymaga od kompilatora lub programisty stworzenia fizycznej kopii tego, co się kryje za zmienną. Co może być procesem dłuższym.

Jest to bardzo ważne, by dostrzec różnice pomiędzy typami prostymi, a złożonymi.

W przypadku typów prostych przypisanie zmiennej (=) nastąpi wykonanie głębokiej kopii zmiennej.

W przypadku typów złożonych użycie operatora przypisania spowoduje przeniesienie własności (wcześniej wspomniany Moving Ownership) o ile nie implementuje on traita Copy (o tym można przczytać poniżej).

Mówimy tu o operacji przypisania tylko przy wykorzystaniu operatora =, bez dodatkowych modyfikacji.

Parametry funkcji a ownership

Podobne zachowanie będziemy mogli zauważyć, gdy spróbujemy przekazać zmienną jako parametr funkcji .

fn take_ownership(book: Book) {           
   println!("Book {}", book.title)
}
fn main() {
    let book: Book = Book {title: String::from("The Title")};
    take_ownership(book);
    //println!("Book {}", book.title);
}

Specjalnie zakomentowałem linijkę z próbą wyświetlenie zmiennej book.

Dlaczego?

Domyślnie wywołując funkcję przekazujemy jej własność. Parametr funkcji (którego zakres życia to zakres funkcji) staje się właścicielem segmentu pamięci z naszą książką. Po zakończeniu funkcji parametr wyjdzie poza zakres, a co za tym idzie, skojarzony z nim fragment pamięci zostanie zwolniony.

Gdy wrócimy do funkcji main, pomimo że jest to jej macierzysty zakres, tu została stworzona zmienna book, to nie ma już jakichkolwiek technicznych możliwości, by jej użyć, a próba odwołania się do niej zakończy się błędem kompilacji.

Uruchom przykład

Wywołanie funkcji i przekazanie zmiennej jako parametru jest jednoznaczne z przeniesieniem własności.

Zwracanie własności

Funkcje mogą także przenieść własność do zewnętrznego zakresu. Jeśli zwracamy coś do czego posiadamy własność, to możemy przekazać ją na zewnątrz.

Służy to tego albo operator return, albo użycie nazwy zmiennej w ostatniej linijce bez średnika.

fn create_book_and_return_it_with_ownership() -> Book{           
    let book: Book = Book {title: String::from("The Title")};
    Book //return book;
}
fn main() {
   let book = create_book_and_return_it_with_ownership();
   println!("Book {}", book.title);
}

Uruchom przykład

Może też to się okazać bardzo użyteczne. Funkcje, których celem jest tworzenie struktur, których tajniki implementacyjne można hermetyzować wewnątrz funkcji. Funkcje, których parametry będą wykorzystywane tylko na czas wykonania czynności, a potem można się ich pozbyć.

Jak mieć własność w dwóch miejscach naraz?

Czy jest możliwe, bym kupił książkę (fizyczną), a potem by ta książka występowała w dwóch miejscach jednocześnie? Nie jest to możliwe – mogę zrobić kopię książki, ale nie mogę mieć tej samej książki w dwóch różnych miejscach.

Tak samo nie może być dwóch zmiennych, które mają prawo własności do tej samej zmiennej.

Możemy natomiast sprawić, że będą dwa egzemplarze. Tak jak w przypadku książki mogę Ci dać książkę i mieć książkę, jeśli dysponuję dwoma egzemplarzami lub jeśli jeden w jakiś sposób sklonuję.

Pamiętasz, jak to odbywało się w przypadku typów prostych. Wytworzona została kopia. “Robiło się” to automatycznie.

W przypadku typów złożonych, takich jak np: String lub nasz Book, też mamy dodatkowe możliwości. Musimy po prostu zaimplementować trait Copy. Warto wspomnieć, że String ma tę funkcję już zaimplementowane i próba użycia operatora wywołuje inne zachowanie.

O czym informuje nas oczywiście kompilator:

= note: move occurs because `book` has type `Book`, which does not implement the `Copy` trait

Należy też pamiętać, że to nie przenosi własności, a tworzy nową zmienną z własnością do innego segmentu pamięci, a proces kopiowania może mieć swoje narzuty czasowe.

fn take_ownership(title: String){        
    println!("Book {}", title);
}
fn main() {
   let title:String = String::from("The Title");
   take_ownership(title.clone());
   println!("Book {}", title);
}

Uruchom przykład Operator = spowoduje wykonanie kopii wtedy, gdy dany typ implementuje trait Copy.

Podsumowanie

Analogia przedmiotu fizycznego jest dobrą analogią w wypadku Rusta. Tak, jak jedną książkę może jednocześnie posiadać tylko jedna osoba, tak samo w Rust zmienna może mieć maksymalnie jednego właściciela.

Mamy za to możliwość przenoszenia (przekazywania) własności (Moving Ownership) do innej zmiennej w tym samym zakresie użycia, do innego zakresu lub do funkcji – dokładnie tak samo, jak mogę pożyczyć komuś innemu posiadaną przeze mnie książkę.

Przeniesienie własności jest podstawowym mechanizmem. Kiedy może dojść do zmiany właściciela? Przy użyciu operatora przypisania lub przy przekazaniu parametrów do funkcji.

Własność można przenieść tylko wtedy, gdy ją posiadamy. Zmienna, która nie posiada własności, nie może przenieść jej dalej. Zmienna może przenieść własność maksymalnie raz (do momentu jej odzyskania).

Funkcja, zwracając zmienną, która ma własność, może przenieść własność do zewnętrznego zakresu – na przykład do funkcji main.

Co dzięki temu zyskujemy:

  • Niemożliwe jest odwołanie do zwolnionego fragmentu pamięci. W innych językach NullReferenceException albo NullPointerException.
  • Niemożliwe jest dwukrotnie zwolnienie tego samego segmentu pamięci. Jeśli zwalnianie pamięci byłoby kosztownym procesem, to koszt ten ponosilibyśmy tylko raz.
  • Widać, gdzie zmienna kończy swój żywot i gdzie zostanie zwolniona pamięć z nią związana.

W następnym artykule pokaże mechanizm pożyczania (Borrowing Ownership), dzięki któremu zobaczycie, że nie każde przypisanie i wywołoanie funkcji przy jednoczesnym przekazaniu parametrów kończy się przekazanie własności.

W dzień Senior Big Data Architect | Lead Developer | Software Developer w firmie Future Processing, w nocy śpi. Ponad 10 lat doświadczenia w zakresie wytwarzania oprogramowania w różnych technologiach oraz domenach, również w takich, w których nikt nie chciał pracować. Jak trzeba usunąć problem w dowolnej dziedzinie to wiesz do kogo dzwonić :) Zafascynowany rozwojem technologii związanej z przetwarzaniem danych a w szczególności tworzeniem rozwiązań z rodziny Big Data. Prelegent oraz organizator licznych wydarzeń, których głównym celem jest dzielenie się wiedzą oraz krzewienie potrzeby stosowania dobrych praktyk, w celu maksymalizacji jakości wytwarzanego produktu. Współorganizator Wakacyjnych Praktyk w Future Processing oraz prowadzący przedmiot na Politechnice Śląskiej „Tworzenie Oprogramowania w Zmiennym Środowisku Biznesowym”.
PODZIEL SIĘ