Rust – Ownership – Po co nam ta własność?

Wstęp

Ten artykuł jest częścią kilkuczęściowego cyklu poświęconego mechanizmowi Ownerhship będącego istotnym elementem języka Rust. Omówię w nim podstawowe zjawiska i pojęcia z tym związane. Jeśli chcesz poznać dlaczego warto nauczyć się Rusta zapraszam Cie do pierwszego mojego artykułu na ten temat.

Deklarowanie zmiennych, a co za tym idzie, alokacja pamięci to mechanizm, bez którego programowanie byłoby trudne, albo nawet niewykonalne. Rust jest językiem, który udostępnia całą gamę typów wbudowanych, zwanych prymitywami. Dodatkowo daje nam możliwość tworzenia własnych typów złożonych, jak np: struct. To, czym różni się w tym zakresie Rust od innych języków, jest sposób, w jaki zarządza zadeklarowaną przez nas wcześniej pamięcią i kiedy usuwa zaalokowany przez nas segment pamięci, gdy już go nie potrzebujemy. Dziś skupię się na omówieniu mechanizmu, nazywanego Ownership.

Co może stać się negatywnego, gdy tworzymy zmienne lub zmieniamy je?

Wyobraź sobie sytuację, że programując potrzebujesz 1000000 bitmap, które w trakcie działania aplikacji mogą się zmieniać, mogą być usuwane lub na ich miejsce tworzone nowe, a w międzyczasie, gdzieś w aplikacji istnieją powiązania do konkretnych elementów kolekcji. Mówimy tu o dwóch bardzo negatywnych w skutkach zjawiskach:

  • Wyciekach pamięci (memory leak) -> gdy nie zwalniamy zaalokowanej pamięci, pomimo że jej już nie potrzebujemy, podczas gdy alokujemy nową.
  • Wiszących referencjach (dangling reference or pointer) -> mamy zmienną, która wskazuje nam na fragment pamięci, który nie zawiera z jakichś powodów poprawnego obiektu.

Tworzenie i usuwanie w tym całym procesie może okazać się problematyczne. Może doprowadzić do naruszeń bezpieczeństwa pamięci.

Dlaczego?

  • W zależności od języka, w którym programujesz, może się okazać, że zapomnisz jawnie zwolnić zaalakowany fragment pamięci lub w wyniku Twoich działań nie zostanie on zwolniony.
  • Stworzyłeś alias wskazujący na któryś element kolekcji, która uległa zmianie bo np: dodałeś dodatkowy string.

Co może się stać?

  • “Pożyczona od systemu” będzie się zwiększać pomimo, że tak naprawdę jej nie będziemy potrzebować w takich ilościach. Nie będziemy zwalniać starej pamięci, a będziemy alokować nową.
  • Odwołując się do aliasu, może okazać się, że wskazywany fragment pamięci jest pusty, albo zawiera brudy, albo zawiera to, czego się spodziewasz, ale nie masz stuprocentowej pewności, jak zachowa się aplikacja w trakcie działania.

Czy tworzenie nowych zmiennych, modyfikowanie istniejących jest czymś, na co musimy zwracać szczególną uwagę?

Jak to wpływa na naszą produktywność?

Przyjrzyjmy się krótko, jak do zarządzania pamięci podchodzą różne języki.

Niektóre języki mają wbudowane jawne mechanizmy alokowania i zwalniania pamięci przy tworzeniu zmiennych. Takimi językami są np: C czy C++, gdzie za pomocą odpowiednich operatorów lub funkcji musimy najpierw powiedzieć w sposób jawny, że chcemy powołać do życia taką zmienną, z czym wiąże się odpowiednie zapotrzebowanie na pamięć. Wymaga to też od nas tego, byśmy pamiętali, by po zaprzestaniu użytkowania takiej zmiennej pamięć zwolnić w sposób jawny lub tworząc własne typy zastosować odpowiednią technikę (np: RAII). Gdy tego nie zrobimy, to może to doprowadzić do wspomnianego wycieku. Odpowiedzialność leży po stronie programisty, on też ma obowiązek sam o tym pamiętać.

W niektórych językach występuje mechanizm wspierający zwany Garbage Collectorem, którego zadaniem jest usunięcie tych zaalokowanych fragmentów pamięci, które są przez nas nieużywane, czyli np: nie istnieje na poziomie kodu do nich żadna referencja. W większości wypadków zwalnia to programistę z konieczności posprzątania po sobie. W niektórych wypadkach może to wymagać zastosowania innego podejścia lub wywołania mechanizmów GC ręcznie. W zależności od języka GC może zachowywać się inaczej i na podstawie innych informacji podejmować decyzje o tym, czy dana zmienna jest używana, czy nie. Jeśli nie, to ją po prostu zwolni bez udziału programisty. Może to trwać dłużej i jest to mechanizm działający w trakcie pracy programu. Czy mogą wystąpić wycieki pamięci? Tak mogą.

Co daje nam Rust?

Podstawowym założeniem języka Rust jest tworzenie oprogramowania systemowego przy zachowaniu wysokiej wydajności, ale także bezpieczeństwa pamięci, łatwej skalowalności w wielowątkowość.

Rust chce dać programiście poczucie bezpieczeństwa oraz pewność siebie przy tworzeniu aplikacji na poziomie systemu, jednocześnie usuwając przymus obawiania się o występowanie takich zjawisk, jak wiszące referencje czy też wycieki pamięci.

To, co czyni Rust w jakimś stopniu unikalnym jest właśnie jego podejście do zarządzania pamięcią. Sprawdzanie poprawności kodu pod kątem bezpieczeństwa odbywa się na etapie kompilacji, wykorzystując do tego statyczną analizę kodu, nie na poziomie uruchomienia aplikacji.

Proces alokowania i dealokowania pamięci jest ukryty z poziomu kodu do pewnego stopnia.

Deklarowanie wszystkich typów jest proste i nie wymaga od nas użycia dodatkowych słów kluczowych, jak np: new.

Jeśli chodzi o zwalnianie pamięci, to nie ma tu operatora zwalniania, którego musimy użyć i o którym musimy pamiętać. Nie ma też mechanizmu Garbage Collectora, podobnego do tego z języków C# czy Java, który w trakcie działania programu będzie za nas usuwać nieużytki. Jest za to inny bardzo pomocny mechanizm. (Na marginesie dodam, że jest także wbudowany mechanizm bazujący na liczeniu referencji, który działa na etapie wykonania aplikacji, ale to o nim innym razem.)

W jaki sposób Rust podchodzi do problemu tworzenia zmiennych i alokowania pamięci?

Wprowadza mechanizm Ownership, który można uprościć do stwierdzenia: jeśli zmienna, którą trzyma Ownership nad fragmentem pamięci, przestaje być używana, zostaje usunięta, a powiązana z nią pamięć zwolniona. Jest to realizowane na poziomie kompilacji, czyli już wtedy znany jest programiście cykl życia zmiennej, gdy sam o tym zadecydował. Jeśli spróbuje użyć danej zmiennej lub związanego z niej segmentu pamięci po jej wyjściu poza zakres, to nie dość, że nie będzie to możliwe, to jeszcze program się nie skompiluje.

Tak, to jest coś, co czyni Rust bardzo unikalnym. Już na etapie kompilacji rozwiążemy problemy związane z niegospodarnością pamięci. My rozwiążemy? Tak, my. To jest ta nieprzyjemna część. Od programisty w języku Rust wymagane jest zrozumienie i opanowanie mechanizmu Ownership, gdyż jakiekolwiek naruszenie jego zasad zostanie nam wypomniane przez kompilator.

Brzmi trudno? Z początku tak, ale idzie się do tego szybko przyzwyczaić.

Poznać Ownership

Pozwólcie, że zacznę od wysokopoziomowego przykładu. Wyobraźcie sobie, że zmienne są jak książki w biblioteczce. Nazwa zmiennej oznacza tytuł 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.

I właśnie po to istnieje ten mechanizm, by świadomie przekazywać prawo własności i by dzięki temu wiedzieć, kiedy niepotrzebną już książkę można usunąć, by zrobić miejsce dla kolejnej na półce.



Wróćmy na poziom kodu. Zacznę od prostego przykładu obrazującego, czym jest Ownership na poziomie kodu.

let id: u8 = 1;

W momencie deklaracji zmiennej przypisywany jest do niej ów Ownership. Zmienna ID staje się właścicielem pewnego segmentu pamięci z nią powiązanego.

Gdy wyjdziemy poza zakres użycia, np: wyjdziemy z funkcji, wyjdziemy z zakresu if, zostanie ona automatycznie zwolniona.

Tak, jak w poniższym przykładzie, gdy wyjdziemy poza zakres funkcji main, zmienna zostaje usunięta.

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

Podsumowanie

Ownership jest bardzo ciekawy, chociaż z początku może być trudny do opanowania.

Został stworzony po to, by na poziomie kompilacji wyeliminować negatywne zjawiska, związane z zarządzaniem pamięcią, takie jak: wycieki pamięci, wiszące referencje.

Głównym celem tego mechanizmu jest bezpieczeństwo, ale ma także ułatwić skalowalność i łatwiejsze tworzenie aplikacji wielowątkowych.

Co otrzymujemy w zamian:

  • Większe bezpieczeństwo przy zarządzaniu pamięcią.
  • Brak konieczności pamiętania o dodatkowych operatorach do usuwania.
  • Zero wiszących referencji.
  • Zero wycieków pamięci.
  • Brak wielokrotnego zwalniania tego samego segmentu pamięci

W następnych częściach cyklu opiszę mechanizm przenoszenia własności (Move), pożyczania (Borrowing) , jak radzi sobie Rust z Ownershipem i mutowalnoś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Ę