Dlaczego?

Ownership w Rust jest konceptem, który determinuje w jaki sposób kontrolowana jest pamięć.

Wyobraźmy sobie że alokujemy obiekty w pamięci — w językach z garbage collectorem sprawa jest prosta: GC skanuje które obiekty są nieosiągalne i zwalnia zasoby.

…ale Rust nie posiada garbage collectora. Bez żadnych zasad moglibyśmy alokować i alokować aż skończyłaby nam się nieoczekiwanie pamięć i program by się nieoczekiwanie zakończył — i tutaj zaczyna się Ownership.

fn main() {
    let first_owner = String::from("Book");
    let second_owner = first_owner;
    println!("{}", first_owner);
    println!("{}", second_owner);
}
Ctrl+Enter to run

Jak widać - ledwo zdążyliśmy cokolwiek napisać i już Rust marudzi - ale to zamierzone!

Po usunięciu pierwszego println!() (możesz sam to zrobić tutaj :)) - kod uruchamia nam się bez problemu.

Dlaczego?

Ponieważ każda wartość ma swojego właściciela, i przez przypisanie jej do następnej zmiennej również unieważniliśmy pierwszą.

Problem czy feature?

Aby dokładniej zobrazować problem napiszmy coś podobnego ale w języku C

  #include <stdio.h>
  #include <stdlib.h>
  #include <string.h>

  int main() {
      char *s = malloc(strlen("Rust") + 1);
      strcpy(s, "Rust");
      free(s);
      printf("%s\n", s);
      return 0;
  }
Ctrl+Enter to run

Jak to ma się do Rusta?

Na załączonym przykładzie w języku C widzimy że byliśmy w stanie zwolnić pamięć przed wywołaniem tworząc potencjalny exploit lub prowadząc do undefined behavior.

W C nie ma ownership więc kto jest właścicielem s ?

Nikt, to tylko wskaźnik.

Na programiście spoczywa cała odpowiedzialność aby być pewnym że w odpowiednim momencie zwalnia zasoby nie tworząc okazji do remote code execution!

To jest kategoria z CVE z wysokim CVSS score i byliśmy świadkami setek takich bugów. Rust eliminuje to statycznie - program się nie skompiluje zamiast działać przez przypadek.

Transferowanie ownership przez funkcje

Spróbujmy teraz przekazać ownership przez funkcje

fn main() {
    let first_owner = String::from("Book");
    takes_ownership(first_owner);
}

fn takes_ownership(str: String)  {
    println!("takes_ownership: {}", str); 
}
Ctrl+Enter to run

Jak widać, nasz program bez problemu się wywołuje printując zawartość, dodajmy zatem println! aby wyprintować first_owner

fn main() {
    let first_owner = String::from("Book");
    takes_ownership(first_owner);
    println!("first owner: {}", first_owner);
}

fn takes_ownership(str: String)  {
    println!("takes_ownership: {}", str); 
}
Ctrl+Enter to run

Rust znowu nie pozwala nam się skompilować! - Ownership został przekazany do funkcji takes_ownership.

Niewidzialny Drop

Już wiemy że w Rust istnieje pojedynczy ownership — skompilujmy nasz program i zajrzyjmy na wynikowy assembler przez objdump:

objdump -d -M intel target/debug/ownership | grep -A 20 "takes_ownership"
  ===============================================================
  2318b: mov rsi, [rsp+0x8]       drugi argument dla _print (len)
  ========= println implementation ============
  2319e: jmp 231a0                skocz do dropu

  231a0: mov rdi, [rsp+0x18]      wskaźnik do String (argument dla drop)
  231a5: call drop_in_place        drop za }
  231aa: add rsp, 0x58            przywróć stos
  231ae: ret                      powrót z funkcji
  

W świecie języków z garbage collectorem GC sprawdza czy obiekty są poza zasięgiem i zwalnia zasoby. W przypadku Rust kompilator wstrzykuje zwalnianie zasobów w momencie zakończenia czasu życia funkcji — w tym przypadku }.

fn main() {
    let first_owner = String::from("Book"); //_move - przenieś właściciela na takes_ownership
    takes_ownership(first_owner);
    println!("first owner: {}", first_owner);
} 

fn takes_ownership(str: String)  {
    println!("takes_ownership: {}", str); 
} // wstrzyknięcie _drop

Jest to analogiczny przykład wywołania destruktora (~MyClass()) jak w przypadku C++ — z tą różnicą, że Rust gwarantuje wywołanie dokładnie raz przez system ownership, podczas gdy w C++ zależy to od dyscypliny programisty.

Dodatkowo Rust umożliwia implementację trait Drop dzięki któremu jeśli wartość wychodzi poza scope wywoływany jest drop.

Użyjmy mechanizmu otwierania plików z std

  use std::fs::File;

  fn main() {
      {
          let f = File::open("/etc/hostname").unwrap();
          println!("plik otwarty");
      }  // close(fd) tutaj

      println!("plik już zamknięty");
  }
  impl Drop for OwnedFd {
      fn drop(&mut self) {
          unsafe {
              let _ = libc::close(self.fd.as_inner());  // ← syscall close(fd)
          }
      }
  }

libc::close jest wywoływany automatycznie - dzieje się to under the hood!

Aby oddać ownership wystarczy je z powrotem zwrócić

fn takes_ownership(str: String)  {
    println!("takes_ownership: {}", str);
    str // oddaje ownership z powrotem
}

Copy: kiedy transfer ownership staje się kopią

fn main() {
    let first_owner:i32 = 10;
    takes_ownership(first_owner);
    println!("first_owner: {}", first_owner);
}

fn takes_ownership(num: i32)  {
    println!("takes_ownership: {}", num); 
}
Ctrl+Enter to run

Porównując to z naszym pierwszym przykładem tutaj Rust powinien krzyczeć a zamiast tego ładnie się skompilował.

  use std::fs::File;

  fn main() {
      println!("i32:    {}", std::mem::needs_drop::<i32>());
      println!("f64:    {}", std::mem::needs_drop::<f64>());
      println!("bool:   {}", std::mem::needs_drop::<bool>());
      println!("char:   {}", std::mem::needs_drop::<char>());

      println!("String: {}", std::mem::needs_drop::<String>());
      println!("Vec:    {}", std::mem::needs_drop::<Vec<i32>>());
      println!("File:   {}", std::mem::needs_drop::<File>());
      println!("Box:    {}", std::mem::needs_drop::<Box<i32>>());

      println!("(i32, i32):    {}", std::mem::needs_drop::<(i32, i32)>());
      println!("(i32, String): {}", std::mem::needs_drop::<(i32, String)>());
      println!("[i32; 3]:      {}", std::mem::needs_drop::<[i32; 3]>());
  }
Ctrl+Enter to run

Jak widać - dla typów prymitywnych Rust zamiast przenosić ownership tworzy kopię. Są to typy w całości przechowywane na stosie — kopia to dosłownie kilka bajtów, bez żadnych skutków ubocznych. Nie ma alokacji, wskaźników.

Dwie kopie są całkowicie niezależne.

Rust implementuje trait który mówi kompilatorowi że ten typ można kopiować bitowo np:

#[derive(Copy, Clone)]
  struct Point {
      x: f64,
      y: f64,
  }

Ma to sens tylko gdy typ jest w całości na stosie i nie zarządza żadnymi zasobami (np. pamięcią heap, deskryptorami plików).

Dlatego String nie może implementować Copy — posiada wewnętrzny bufor na stercie.

Ale kod czasami wymaga nie tylko przenoszenia ownership, ale też wielokrotnego dostępu do tej samej wartości z różnych miejsc.

Do tego służy Borrowing, który będzie tematem następnego posta - Stay Tuned!