Wpis z mikrobloga

Rust dla (t)opornych

Część 2. Ale, że jak bez Gówno Czujki?

W poprzedniej części dałem krótki opis czym Rust wyróżnia się na tle innych języków. Dzisiaj postaram się bardziej szczegółowo omówić system typów oraz powiązany z nim lifetime.

Rozdział 1. Typy podstawowe

Rust posiada parę typów, które są wbudowane i podstawowe (co chyba oczywiste):

+ typy całkowite ze znakiem: i8, i16, i32 i i64
+ typy całkowite bez znaku: u8, u16, u32 i u64
+ typy zmiennoprzecinkowe: f32, f64
+ ciąg znaków (jest on tzw. slice, o czym później): &str
Co się rzuca w oczy to brak typy przechowującego znaki (char). Jest to związane z tym, że Rust uznaje wszystkie ciągi znaków za kodowane w UTF-8 [1], więc "typ znakowy" nie miał by sensu, jako, że znaki w UTF-8 mogą mieć różną długość. Zamiast tego, jako "surowy ciąg znaków" traktowany jest typ u8.

Oprócz tego istnieje jeszcze "placeholder", który mówi kompilatorowi, że sami nie wiemy co tam ma się znaleźć i sam ma się domyślić, przykładowo:

let a: = 10;
\ to to samo co
let a = 10;

przydaje on się czasem gdy chcemy częściowo kreślić typ zwracanej wartości, przykładowo:

// to jest paskudne i niepolecane
let vec = vec![1,2,3].into
iter().map(|x| x * x).collect::>();

// zamiast można napisać
let vec: Vec<> = vec![1,2,3].intoiter().map(|x| x * x).collect();

Rozdział 2. Referencje

Zaczniemy od rozwiązania poprzedniej zagadki. Przypomnę tylko treść:

Czy dany kod w C++ jest poprawny:

#include
#include
#include
using namespace std;

int main() {
vector vec;

vec.pushback("Lol");

string& ref = vec[0];

vec.push
back("really?");

cout << ref;

return 0;
}

Jak można zobaczyć na Ideone dostajemy błąd wykonania. Co? Jak? Gdzie? Czemu? Ano z tego powodu, że std::vector to tak naprawdę otoczka wokół zwykłej dynamicznej tablicy. W momencie gdy dodajemy nowy element mogą się zdarzyć 2 rzeczy (zgodnie ze standardem):

+ biblioteka standardowa "przewidziała" dodatkowe miejsce na nowe elementy, więc jedyne co trzeba zrobić to wpisać element na kolejnej pozycji i zwiększyć wartość size
+ tablica jest za mała, co wymaga utworzenia nowej tablicy, przepisania wartości z poprzedniej tablicy i usunięcia starej tablicy z pamięci

W związku z 2. przypadkiem standard mówi, że wszystkie referencje do elementów wektora, w momencie zmiany jego wielkości, powinny zostać uznane za nieprawidłowe (mimo iż w teorii to nie musi być prawda, patrz pkt. 1).

Spójrzmy na identyczny przykład w Ruscie:

fn main() {
let mut vec = Vec::new();

vec.push("Lol");

let ref reference = vec[0];

vec.push("really?");

println!("{}", reference);
}

przy próbie kompilacji otrzymamy:

test.rs:8:5: 8:8 error: cannot borrow vec as mutable because it is also borrowed as immutable
test.rs:8 vec.push("really?");
^~~
test.rs:6:25: 6:28 note: previous borrow of vec occurs here; the immutable borrow prevents subsequent moves or mutable borrows of vec until the borrow ends
test.rs:6 let ref reference = vec[0];
^~~
test.rs:11:2: 11:2 note: previous borrow ends here
test.rs:1 fn main() {
...
test.rs:11 }
^
error: aborting due to previous error

Jak widać kompilator poinformuje nas, że próbujemy zrobić coś dziwnego. Wszystko dzięki systemowi borrowing, który określa, czy możemy modyfikować daną zmienną.

By uzmysłowić sobie jak to działa wyobraźmy sobie zmienną jako naszą książkę. Możemy z takową książką zrobić 3 rzeczy:

+ używać jej sami
+ pożyczyć komuś (przekazać referencję)
+ oddać komuś (przenieść własność)

Wszystko to ma na celu zapobiegnięciu powstawania "zwisających" wskaźników.

Kolejny przykład:

fn main() {
// początek lifetime 'a
let mut a = &10;

println!("a = {}", a);

{
// początek lifetime 'b
let b = 20;

println!("a = {}\nb = {}", a, b);

// a = &b // nie możemy "pożyczyć" książki, której właściciel będzie żył krócej niż my
// koniec lifetime 'b
}

println!("a = {}", a);
// koniec lifetime 'a
}

Tutaj mamy zobrazowane jak kompilator rozpoznaje czas życia poszczególnych zmiennych i jak decyduje, czy dana zmienna może odnosić się do do innej.

Część 3. Przenoszenie własności

Dodatkowym systemem jest zabezpieczenie przed podwójnym usunięciem elementów jest system przenoszenia własności. By zobrazować jak to może być uciążliwe spójrzmy na przykład (C++):

class Foo {
int* ptr;

public:
Foo() {
ptr = new int;
}

Foo(Foo&) = default;

~Foo() {
delete ptr;
}
}

Widać tutaj błąd? Jak nie, to zastanów się, co się stanie jak przekażemy tą klasę przez wartość.

Rust przeciwdziała takowemu działaniu poprzez system "własności" (ownership). Przykładowo:

let a = "lol".tostring(); // "owned" string
let b = a;

println!("{}", a); // błąd, a nie jest już właścicielem tego ciągu znaków

To samo w przypadku funkcji:

fn foo(string: String) {
println!("{}", string);
// wewnętrzna reprezentacja string jest tutaj niszczona
}

let a = "lol".to
string();

foo(a); // przenosimy własność

println!("{}", a); // nie możemy już użyć zmiennej a jako, że nie jest właścicielem wartości

By zachować własność, musimy "zwrócić" wartość, którą otrzymaliśmy (i tym samym przedłużyć czas jej życia):

fn foo(string: String) -> String { println!("{}", string); string }

let a = "lol".tostring();

let a = foo(a);

println!("{}", a);

Oczywiście nie wszystkie typy wymagają takiego bezpieczeństwa, specjalnie dla nich są zaimplementowane 2 traitsy:

+ Clone - implementuje metodę Clone::clone(&self), która pozwala na utworzenie głębokiej kopii obiektu
+ Copy - typ, który można skopiować poprzez użycie memcpy
Oprócz tego mamy jeszcze parę struktur, które ułatwiają pracę z pamięcią:

+ Box - owned pointer, odpowiednik "czystych wskaźników" z C/C++, użyteczne np. przy implementacji list
+ String - owned string
+ Rc - reference counted pointer (trochę kłamałem, że nie ma GC :P), prosty wskaźnik zarządzany przez GC zliczające referencje
+ Arc - j.w. z tym wyjątkiem, że zliczanie jest atomowe, co jest przydatne przy wskaźnikach przekazywanych między wątkami
+ Cell - wskaźnik na mutowalny obszar pamięci
+ i jeszcze parę innych

Trochę tego jest, ale większość ma swoje ścisłe zastosowania i nie używa się ich za często. W celu zapoznania się z różnicami zalecam przejrzeć dokumentację.

Część 4. Tablice, slices [2] i iteratory

Tablice w Ruscie są praktycznie idealnym odpowiednikiem tablic z C z tą różnicą, że są one zawsze inicjalizowane wartością domyślną (jak wszystkie zmienne w Ruscie) oraz w czasie wykonania jest sprawdzany zakres (nie da się odwołać do wartości spoza tablicy). Dodatkowo Rust sprawdza rozmiar tablic przekazywanych jako argument do funkcji, i. e.:

fn foo(arr: [u32; 8]) { … }

foo([1,2,3,4,5,6,7,8]);
// foo([1,2,3]); // zwróci błąd - niewłaściwy typ

Slices z kolei mogą być traktowane jako wskaźniki na pierwszy element tablicy. Slices pozwalają nam na przekazywanie tablic o nieznanej w czasie kompilacji długości lub fragmentów większych tablic.

Iteratory są raczej znanym konceptem z innych języków, więc nie będę się rozpisywał do czego służą a skupię się na ich stosowaniu.

Po pierwsze w Ruscie (tak jak, np. w Pythonie czy Ruby) jest tylko pętla for … in …, której zastosowanie wygląda tak:

let vec = vec![0, 1, 2, 3, 4];

for i in vec {
println!("{}", i);
}

i znaczy tyle samo co:

for i in vec.into
iter() { … }

Do iterowania po zakresie służy operator zakresu (zerżnięty z Rubiego):

for i in 1..10 { … } // IMHO dość czytelnie

W ogólnym założeniu wektor ma 2 różne iteratory:

- iter
- into_iter
Pierwszy iterator iteruje po elementach wektora i zwraca referencję do poszczególnych elementów, natomiast into_iter jest iteratorem "konsumującym", który przekazuje elementy wektora przez wartość i "niszczy" nasz wcześniejszy wektor. Jak zwykle, więcej info i przykłady w dokumentacji (link wyżej).

Oprócz oczywistej rzeczy - iteracji, iteratory implementują niektóre z high-order functions jak map czy fold (przez trait IteratorExt). Więcej o funkcyjności dam w innej części cyklu (jak ktoś chce przykład już teraz, to jest on na początku tej części).

Epilog

To by były podstawy systemu typów w Ruscie. Nie jest on prosty, ale po przyzwyczajeniu się i ogarnięciu podstawowych zasad nim rządzących staje się on w miarę prosty i zrozumiały. Dodatkowo kompilator stara nam się wytłumaczyć co i dla czego w dość przystępny sposób, więc to nie jest zbytni problem.

Happy hacking!

PS
Ktoś stworzył całkiem przystępną formę określania co jest czym. Dla zainteresowanych: The Periodic Table of Rust Types

[1] - Jak na razie. Jest RFC, którego celem jest ustandaryzowanie tego zachowania między platformami.
[2] - Ma ktoś pomysł jak to można przetłumaczyć?

  • 2