Program w C, który zwalnia bufor i dalej z niego czyta, mimo to się kompiluje. Potem psuje pamięć, czasem natychmiast, czasem trzy requesty później, w fragmencie kodu, który nigdy tego bufora nie dotknął. Rust odrzuca taki program już na etapie cargo build, i nie ma w tym żadnego garbage collectora. Kompilator śledzi, kto jest właścicielem każdej wartości i kto może na nią patrzeć. To śledzenie to właśnie ownership i borrowing.

Jakie są zasady ownership w Rust?

Model ownership w Rust sprowadza się do trzech zasad:

  1. Każda wartość ma dokładnie jednego ownera.
  2. W danym momencie może istnieć tylko jeden owner.
  3. Gdy owner wychodzi ze swojego scope, wartość zostaje zdropowana.

“Zdropowana” znaczy, że jej destruktor się wykonuje, a pamięć jest zwalniana od razu, a nie wtedy, kiedy garbage collector znajdzie na to czas. To jest umowa, którą proponuje Rust: żaden runtime nie decyduje, kiedy odzyskać pamięć, w zamian za to kompilator wymusza te zasady w każdej linijce, którą piszesz.

Zasada 2 to ta, która najbardziej zaskakuje na początku. Przypisz String do drugiej zmiennej, a pierwsza przestaje działać:

1
2
3
4
5
6
fn main() {
    let s1 = String::from("hello");
    let s2 = s1;

    println!("{s1}, world!");
}
1
2
3
4
5
6
7
8
9
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
5 |     println!("{s1}, world!");
  |                ^^ value borrowed here after move

let s2 = s1 nie kopiuje bajtów stringa. String przechowuje wskaźnik na bufor na heapie, długość i pojemność. Skopiowanie tej struct bit po bicie zostawiłoby dwóch ownerów wskazujących na ten sam bufor, a kiedy oba wyszłyby ze swojego scope, bufor zostałby zwolniony dwa razy. Rust unika tego, zamieniając przypisanie w move: s2 przejmuje ownership wartości, a s1 przestaje być poprawny. Przekazanie String do funkcji robi to samo: funkcja przejmuje ownership, a zmienna, którą przekazałeś, staje się nieprawidłowa u wywołującego.

Czemu liczby całkowite nie są przenoszone w Rust?

Liczby całkowite nie mają tego problemu:

1
2
3
4
5
6
fn main() {
    let x = 5;
    let y = x;

    println!("{x} and {y}");
}

To się kompiluje. i32 implementuje trait Copy, więc przypisanie duplikuje wartość, a nie przenosi ją. Nie ma nic na heapie, co można by zwolnić dwa razy, tylko cztery bajty na stacku. Każdy typ całkowity, bool, każdy typ zmiennoprzecinkowy, char, i tuple złożone wyłącznie z typów Copy ((i32, bool), a nie (i32, String)) zachowują się tak samo. Typ może implementować Copy tylko wtedy, gdy żadna jego część nie implementuje Drop — to jest sposób, w jaki Rust mówi: jeśli czyszczenie się liczy, duplikacja musi być jawna.

Kiedy chcesz drugiej, niezależnej kopii String albo Vec, mówisz to przez .clone(). To dodatkowa alokacja na heapie, i właśnie dlatego Rust nie robi tego bez pytania przy każdym przypisaniu.

Jak działa borrowing w Rust?

Jeśli każda funkcja, która musi odczytać wartość, musiałaby przejąć jej ownership, kończyłbyś zwracaniem wartości z każdej funkcji w tuple tylko po to, żeby dalej ich używać. Borrowing eliminuje ten problem: weź referencję przez &, użyj wartości, a ownership nigdy nie zmienia właściciela.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
fn main() {
    let s1 = String::from("hello");
    let len = calculate_length(&s1);

    println!("The length of '{s1}' is {len}.");
}

fn calculate_length(s: &String) -> usize {
    s.len()
}

s1 pozostaje poprawny po wywołaniu, bo calculate_length tylko go pożyczył. Typ parametru &String to referencja: wskazuje na dane s1, nie będąc ich ownerem, więc nie ma nic do zdropowania, kiedy borrow się kończy.

Dwie zasady utrzymują to bezpiecznie:

  • W danym momencie możesz mieć albo jedną referencję mutowalną do wartości, albo dowolną liczbę referencji niemutowalnych, nigdy obu naraz.
  • Referencja nigdy nie może żyć dłużej niż dane, na które wskazuje.

Złam pierwszą i kompilator cię zatrzymuje:

1
2
3
4
5
6
7
8
9
fn main() {
    let mut s = String::from("hello");

    let r1 = &s;
    let r2 = &s;
    let r3 = &mut s;

    println!("{r1}, {r2}, and {r3}");
}
1
2
3
4
5
6
7
8
9
error[E0502]: cannot borrow `s` as mutable because it is also borrowed as immutable
 --> src/main.rs:6:14
  |
4 |     let r1 = &s;
  |              -- immutable borrow occurs here
6 |     let r3 = &mut s;
  |              ^^^^^^ mutable borrow occurs here
8 |     println!("{r1}, {r2}, and {r3}");
  |                -- immutable borrow later used here

Dwie referencje mutowalne do tej samej wartości naraz też są odrzucane (error[E0499]: cannot borrow `s` as mutable more than once at a time). Żaden z tych dwóch błędów nie jest przypadkowy. Referencja niemutowalna to obietnica dana resztą programu, że wartość się nie zmieni pod nią, a referencja mutowalna potrzebuje wyłączności, żeby tę obietnicę utrzymać. Klasyczny bug thread-safety w C albo Go, gdzie dwa fragmenty kodu piszą do tej samej pamięci, podczas gdy trzeci ją czyta, to właśnie to, czego te zasady nie pozwalają skompilować.

To nie znaczy, że Rust wychwytuje każdy race condition, jaki mógłbyś napisać. Bezpieczny Rust wciąż może się zablokować na mutexie albo pomylić kolejność operacji, a bloki unsafe mogą całkowicie obejść te sprawdzenia. To, co eliminuje borrow checker, to konkretna, bardzo częsta postać tego buga: aliasowana pamięć mutowalna, wychwycona, zanim program zacznie działać.

Co znaczą adnotacje lifetime takie jak ‘a?

Każda referencja ma lifetime: scope, w którym jest poprawna. Zwykle kompilator wylicza to sam. Potrzebuje twojej pomocy, kiedy funkcja zwraca referencję i w grze jest więcej niż jedna referencja wejściowa, bo nie ma sposobu, żeby wiedzieć, do którego wejścia jest przypisane wyjście.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() { x } else { y }
}

fn main() {
    let string1 = String::from("abcd");
    let string2 = "xyz";

    let result = longest(string1.as_str(), string2);
    println!("The longest string is {result}");
}

'a to parametr lifetime, czytany jako “tick-a”. To nazwa, nie czas trwania. Napisanie &'a str w obu parametrach i w typie zwracanym mówi kompilatorowi: referencja, którą zwracam, żyje maksymalnie tak długo, jak krótsza z x i y. Adnotacja nie zmienia tego, jak długo cokolwiek żyje. Opisuje relację, którą kompilator musi i tak zweryfikować, dzięki czemu może odrzucić wywołanie, w którym zwracana referencja żyłaby dłużej niż jedno z jej wejść.

Nie piszesz 'a wszędzie, bo Rust eliduje to w typowych przypadkach. Funkcja z dokładnie jednym parametrem referencyjnym automatycznie przypisuje ten lifetime swojemu typowi zwracanemu, a metoda przyjmująca &self przypisuje lifetime self typowi zwracanemu. Dlatego fn first_word(s: &str) -> &str kompiluje się bez żadnej adnotacji: jedno wejście, jedno wyjście, nic niejednoznacznego. longest przyjmuje dwa niezależne parametry referencyjne i żadnego self, więc elizja nie ma się o co zaczepić, i lifetime piszesz ręcznie.

Co robić, kiedy borrow checker walczy z twoim designem

Przez pierwsze tygodnie borrow checker wydaje się wrogi. A czasem bezpośrednia wersja struktury danych w ogóle nie pasuje do modelu jednego-ownera-naraz: drzewo ze wskaźnikami do rodzica, albo cache, który dwa subsystemy muszą modyfikować. Pragmatyczne wyjścia:

  • .clone() wartość, zamiast walczyć z borrow. Kosztuje alokację, i jest to poprawka w jednej linii, którą możesz zoptymalizować później, kiedy reszta kodu już działa.
  • Rc<T> (albo Arc<T> między wątkami) dla współdzielonego ownership, kiedy więcej niż jedna część programu naprawdę musi utrzymać wartość przy życiu.
  • RefCell<T> (albo Mutex<T> między wątkami), kiedy musisz zmodyfikować coś przez współdzieloną referencję. To przenosi sprawdzanie borrow z czasu kompilacji na czas wykonania, zamieniając błąd kompilatora na panic, jeśli się pomylisz.

Żadne z tych rozwiązań nie jest czymś, czego powinieneś się wstydzić. Pokazują też, czemu ten model nie jest właściwy do każdego projektu. Jednorazowy skrypt, który czyta plik i wypisuje raport, nie zyskuje nic na śledzeniu ownership bajt po bajcie, a język z garbage collectorem i goroutinami szybciej doprowadzi cię do działającego programu współbieżnego, jeśli bezpieczeństwo pamięci na etapie kompilacji nie jest wymagane. Rust się opłaca, kiedy use-after-free albo data race w produkcji to porażka, na którą nie możesz sobie pozwolić — wtedy lepiej zapłacić za to z góry, błędami kompilatora.

Następnym razem, kiedy rustc odrzuci twój kod z E0382 albo E0502, przeczytaj błąd, zamiast odruchowo sięgać po .clone(). Wskazuje dwie referencje, które sobie kolidują, i miejsce, w którym każda z nich zaczyna żyć, a naprawienie problemu ze scope, na który wskazuje, zwykle wymaga mniej kodu, niż jego zamaskowanie. Te komunikaty to najszybszy sposób, żeby nauczyć się tego modelu; są dokładniejsze niż większość wyjaśnień na jego temat, ten tekst włącznie.

Powiązane artykuły