Rust – Slice’y

Do tej pory operowaliśmy na typach prostych i złożonych reprezentujących pojedyncze byty. Były to rzeczy takie jak książka, rekord czy osoba. Każdy z tych bytów powiązany był ze zmienną do której przypisana była własność. Dzięki odpowiednim mechanizmom języka Rust opisanych w poprzednich artykułach mogliśmy zadbać o bezpieczeństwo pamięci świadomie przenosząc lub pożyczając własność.

W pewnym momencie w trakcie pracy z językiem Rust przyjdzie nam pracować z typami reprezentującymi kolekcje. W tym artykule dowiesz o tym czym są tablice, string oraz slice i jak to się ma do wcześniej wspomnianego ownershipu.

Kolekcje

Do reprezentacji kolekcji mamy dostępne kilka typów wbudowanych takich jak: tablica ([]), string (String oraz &str, o różnicy wspomnę za chwilę) oraz typy bardziej rozbudowane takie jak np: vector.

  • String – Służy do reprezentowania kolekcji znaków, łańcucha znaków. W przypadku Rust możemy spokojnie operować na UTF-8.
  • Tablica – Służy do reprezentowania kolekcji typów. Np: i32, struktur.

W przypadku tablicy musimy znać rozmiar tablicy już na etapie kompilacji. W przypadku stringa mamy do dyspozycji więcej niż jeden typ. Jeśli deklarujemy string w następujący sposób:

let s = String::from(“Hello. is it me you're looking for”)

To stworzymy typ złożony, przechowywany na stercie, do którego własność będzie przypisana jako zmienna s. W kontekście ownershipu zachowanie będzie podobne do typów złożonych prezentowanych w poprzednich artykułach.

Gdy zadeklarujemy string w poniższy sposób:

let s = “Hello. is it me you're looking for”

To zostanie stworzony statyczny literał, typu &'static str, który nie będzie przechowywany na stercie, ale znajdzie się w skompilowanym kodzie programu. Dzięki temu ten literał stanie się niezależnie od naszych poczynań niezmienny, aż do czasu kolejnej kompilacji.

Powtórzę istotną rzecz: niezależnie od rodzaju stringa przy przekazaniu go jako parametru lub przypisaniu zostanie on sklonowany i zostanie wykonana twarda kopia. Dzięki temu nie ma tu mowy o przenoszeniu własności jak w przypadku innych typów złożonych.

Tablice będa zachowywać się różnie – w zależności od tego jaki typ przechowują. Jeśli tablica przechowuje typy proste, lub takie które implementują trait Clone, to wtedy przy próbie przypisania do innej zmiennej nastąpi wytworzenie jej kopii:

fn main() {
  let numbers = [1,2,3];
  let cloned_numbers = numbers;
  let borrowed_numbers = &numbers;
  println!("{}", cloned_numbers[0]);
  println!("{}", borrowed_numbers[0]);
}

Uruchom przykład

Jeśli tablica będzie przechowywać typy złożone np strukturę Book:

#[derive(Debug)]
struct Book {
   title: String
}

fn main() {
  let books = [Book{title: String::from("1")} , Book{title:String::from("2")}];
  let moved_books = books;
//  let borrowed_books = &books;
  println!("{:?}", moved_books[0]);
//    println!("{:?}", borrowed_books[0]);
}

Uruchom przykład

To będą obowiązywać te same prawa co przy przenoszeniu i pożyczaniu własności co w wypadku struktury Book.

Jest to dość prostolinijne i wygląda intuicyjnie. A co, gdy będziemy zainteresowani wykonywaniem operacji na części kolekcji albo na podzbiorze, którego wielkości nie znamy?

Wcześniej wspomniałem, że w trakcie kompilacji musi być znany typ zmiennej, aby móc określić rozmiar – ale są od tego wyjątki. I w tym przypadku w kontekście przed chwilą zdefiniowanych problemów jeden z tych wyjątków okaże się przydatny. Mowa o slice’ach – wycinkach.

Slice – kiedy okażą się przydatne?

Gdy chcemy tylko pożyczyć kawałek kolekcji, niezależnie od jego wielkości by wykonać jakąś operację. Gdy nie znamy jego wielkości na etapie kompilacji, a znajomość tego rozmiaru nie powinna nas ograniczać.

Przykład:

  • Piszemy funkcję, która ma operować na imieniu kandydata.
  • Piszemy funkcję, która ma operować na wszystkich wypożyczonych książkach i zwrócić pierwszą z tych książek.

Zarówno w pierwszym jak i w drugim przypadku na etapie kompilacji nie możemy stwierdzić jakiego rozmiaru są te kolekcje. I w tym wypadku z pomocą przychodzi nam slice – czyli wycinek.

Wyobraźcie sobie książkę, która składa się z 1000 stron. Chcemy sprawdzić tylko występowanie frazy “what” w jednym konkretnym rozdziale. Wystarczy, że wydzielimy tylko jeden rozdział z całej książki składający się z kolekcji stron.

Ważne jest to, że używając Slice dochodzi do pożyczenia własności do stron, a nie do ich przejęcia czyli przeniesienia własności.

Dlaczego tak? Dzięki temu to obiekt z którego wycinamy te fragmenty nadal jest właścicielem, czyli to dopiero po zakończeniu jego życia segment pamięci zostanie zwolniony. Daje nam to możliwość precyzyjnego określenia gdzie pamięć związana ze zmienną powinna zostać zwolniona tylko raz.

Reasumując: Wycinek (slice) to typ danych, który nie ma prawa własności. Wycinek pozwala na dostęp, odwołanie się do ciągłej sekwencji elementów w kolekcji, w tym do całej kolekcji, ale bez uzyskania prawa własności.

Slice – Wycinki

W przypadku typów tabelarycznych deklaracja slice będzie wyglądać tak: nazwa_zmiennej: &[TYP] np books: &[Book] W przypadku stringa: &str. Wcześniej o tym nie wspominałem, ale literał stringowy jest tak naprawdę slicem. Można w tamtym wypadku rzec, że pożyczamy dostęp do zmiennych zadeklarowanych w kodzie aplikacji, które zostaną usunięte po zakończeniu działania całej aplikacji.

Jak przekazać slice do funkcji albo przypisać go do zmiennej?

Służy do tego operator [od..do] gdzie od i do oznaczają zasięgu wycinku. Z perspektywy matematycznej ten zapis byśmy oznaczyli jako <od, do) Czyli w poniższym wypadku będą to dwie pierwsze litery: he

let s = String::from("hello world");
let slice = &s[0..2]; 
println!("{}", slice) 

Uruchom przykład

Jak już pewnie możecie zauważyć Slice to nic innego jak para informacji: wskaźnik do danych oraz ilość elementów, które się w nim znajdą.

Indeksowanie w Rust zaczyna się od 0, jak w większości języków. Jeśli nie podamy wartości ‘od’ lub ‘do’, to uzyskamy początek lub koniec stringa.

&s[0..] da nam pełny string &s[..2] da nam podstring He.

Możemy także użyć zmiennych do reprezentowania zakresów. Zmienne takie niezależnie od typu w tablicy muszą być typu usize.

Jaką daje nam to korzyść?

Możemy operować tylko na segmencie tablicy. Możemy np zwrócić tylko pierwszych n elementów tablicy nie znając jej wcześniejszego rozmiaru.

Tak jak w poniższym przykładzie gdzie funkcja first_5_chars przyjmuje string a zwraca pierwszych pięć elementów.

fn first_5_chars(s: &String) -> &str {
   &s[..5]
}

fn main(){
   let s = String::from("hello world");
   let k = first_5_chars(&s);
   println!("{}",k)
}

Uruchom przykład

Warto wiedzieć, że używając zakresów [od ; do], poruszamy się po offsetach bajtowych. 0..2 nie oznacza od 0 do 2 znaku. Oznacza od 0 do 2 bajtu.

Jak będziemy przechowywać w stringu UTF-8 np słowo: “Wątek” i spróbujemy uzyskać slice dwóch pierwszych bajtów, to pojawi się odpowiednia informacja o błędzie już na etapie kompilacji:

fn main(){
   let s = String::from("Wątek");
   let k = &s[0..2];
   println!("{}",k)
}

Uruchom przykład

thread 'main' panicked at 'byte index 2 is not a char boundary; it is inside 'ą' (bytes 1..3) of `Wątek`', src\libcore\str\mod.rs:2109:5

Jeśli będziemy chcieli zwrócić wycinek z funkcji należy zwrócić type odpowiedni typ slice. Dla tablic będzie to &[TYP], a dla stringa &str. Próba użycia &String czyli typu pożyczonego zakończy się niepowodzeniem na etapie kompilacji. Dobrą praktyką w przypadku operacji na stringach, których nie zamierzamy zmieniać jest używanie &str dla parametru. &String jest kompatybilny z &str dzięki mechanizmowi dziedziczenia metod.

Indeksowanie slice’ów

Slice, które pochodzą od tablic można indeksować.

fn first_one(list: &[i32]) -> &[i32]{
   &list[0..1]
}


fn main(){
   let a = [1, 2, 3, 4, 5];
   let slice = first_one(&a[1..3]);
   println!("{}", slice[0])    
}

Uruchom przyład

W powyższym kodzie dla 5 elementowej tablicy najpierw tworzymy slice zawierający elementy 1..3 czyli odpowiednio 1 i 2 (2,3) a potem w funkcji first_one zwracamy slice z pierwszym elementem.

Jeśli spróbujemy zrobić coś podobnego ze stringiem otrzymamy błąd.

fn main(){
   let s = String::from("Hello World");
   let k = &s[0..2];
   println!("{}",k[0])
}

Próba odwołania do pierwszego elementu zakończy się błędem.

error[E0277]: the type `str` cannot be indexed by `{integer}`
 --> src\main.rs:5:19
  |
5 |     println!("{}",k[0])
  |                   ^^^^ `str` cannot be indexed by `{integer}`
  |
  = help: the trait `std::ops::Index< {integer}>` is not implemented for `str`

Uruchom przykład

Podsumowanie

Slice – wycinki to mechanizm pożyczania fragmentu kolekcji, listy, stringa. Nie wiąże się to ze zmianą właściciela. Dodatkowo nadal mamy bezpieczny kod i możemy operować na wycinkach kolekcji, które żyją tak długo jak ich właściciel, nie jak zmienna wypożyczająca do nich dostęp. Jeśli spróbowalibyśmy operować na wycinku nieistniejącej kolekcji taka sytuacja wywołałaby błąd kompilacji.

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Ę