Znasz to uczucie, gdy zaczynasz naukę nowego języka i spodziewasz się sprintu, a zamiast tego potykasz się na pierwszym kroku? Tak właśnie było ze mną w przypadku Rusta. Miałem całkiem sprawne narzędzie w JavaScript — coś, co parsuło i modyfikowało kilka tablic — i pomyślałem, że przepiszę je w przerwie na kawę. Otworzyłem edytor, napisałem kod, który wydawał mi się naturalny, i uruchomiłem kompilator. Wybuchł. Nie błędami składni. Nie brakującymi średnikami. Poinformował mnie, że próbowałem użyć wartości, która została już przeniesiona (moved). Wpatrywałem się w ekran. Przeniesiona? Przecież niczego nie przenosiłem. Po prostu przypisałem ją do innej zmiennej.

Strefa komfortu JavaScript

JavaScript uczy cię traktować dane jak wspólną tablicę. Deklarujesz obiekt lub tablicę, przekazujesz go do funkcji, pozwalasz tej funkcji dodać właściwość, a potem przekazujesz go dalej. Garbage collector siedzi w tle niczym niewidzialny dozorca, czekając, by uprzątnąć wszystko, co porzucisz. Nigdy nie pytasz: „Kto jest właścicielem tego ciągu znaków?”. Pytasz: „Czy mogę go odczytać?”. Jeśli odpowiedź brzmi „tak”, dotykasz go. Jeśli potrzebujesz kolejnej referencji, po prostu piszesz let b = a i idziesz dalej. Obie zmienne wskazują na ten sam fragment pamięci i jeśli a go modyfikuje, b natychmiast widzi zmianę. To wygodne. To także chaotyczne, ale ten chaos rzadko cię dotyka, ponieważ środowisko uruchomieniowe zajmuje się sprzątaniem.

Pierwszy szok kompilatora

Rust ci nie ufa. Brzmi to surowo, ale to pierwsza rzecz, której się nauczysz. Język ten został zbudowany wokół systemu własności (ownership system) opartego na trzech sztywnych zasadach. Po pierwsze, każda wartość ma dokładnie jednego właściciela. Po drugie, w danym momencie może istnieć tylko jeden właściciel. Po trzecie, gdy właściciel wychodzi poza zakres (scope), Rust automatycznie zwalnia wartość. Bez garbage collectora. Bez zliczania referencji w tle, chyba że jawnie się na to zdecydujesz. Tylko te trzy zasady, egzekwowane przez kompilator, zanim twój kod w ogóle zostanie uruchomiony.

Kiedy pisałem moją wersję tego narzędzia w Ruście, robiłem to, co wydawało mi się naturalne. Utworzyłem wektor, który jest odpowiedzią Rusta na tablicę, i przypisałem go do drugiej zmiennej. Następnie spróbowałem użyć pierwszej. Kompilator odmówił. W JavaScript let b = a kopiuje referencję. W Ruście przenosi własność (moves ownership). Oryginalna zmienna staje się nieprawidłowa. Kompilator robi to, aby zagwarantować, że dwie ścieżki nigdy przypadkowo nie nadpiszą tej samej pamięci, co jest przyczyną wyścigów danych (data races) i błędów typu use-after-free w innych systemach.

Dlaczego Rust zabiera ci zabawki

Spójrz na ten wzorzec w JavaScript:

let a = [1, 2, 3];
let b = a;
a.push(4);
// Both a and b see 4.

To wpisane w naturę. Nie zastanawiałbyś się nad tym ani chwili. W Ruście ta sama intuicja sprawi, że zostaniesz odrzucony przez kompilator, zanim skończysz pisać. Gdy b przejmuje własność wektora, a staje się jedynie pustą nazwą. Nie możesz do niej nic dodać. Nie możesz nawet na nią spojrzeć. Pamięć należy teraz do b.

Może to wydawać się karą, dopóki nie zrozumiesz, co to zapobiega. Gdyby dwie zmienne mogły modyfikować te same dane na stercie bez koordynacji, ryzykowałbyś uszkodzenie pamięci. Rust eliminuje całą tę kategorię błędów, czyniąc transfer własności jawnym. Kompilator nie jest pedantyczny. Pełni rolę strażnika. Zmusza cię do zdecydowania na każdym kroku, która część twojego kodu odpowiada za jakie dane.

Pożyczanie: obejście, które w rzeczywistości jest funkcją

Oczywiście, gdyby każde przypisanie na zawsze przenosiło własność, pisanie programów byłoby wyczerpujące. Musiałbyś klonować wszystko, marnując pamięć i czas procesora. Rust rozwiązuje to za pomocą pożyczania (borrowing).

Pożyczanie pozwala korzystać z wartości bez przejmowania jej na własność. Istnieją dwa typy i ta różnica ma znaczenie.

  • Niezmienne pożyczki, zapisywane jako &T: Pozwalają one na odczyt danych. Możesz mieć ich tyle, ile chcesz w tym samym