Typ Wycinak (Slice)
Wycinki (Slices) pozwalają odwoływać się do ciągłej sekwencji elementów w kolekcji. Wycinek jest rodzajem referencji, więc nie posiada własności.
Oto mały problem programistyczny: Napisz funkcję, która przyjmuje ciąg słów oddzielonych spacjami i zwraca pierwsze słowo, które znajdzie w tym ciągu. Jeśli funkcja nie znajdzie spacji w ciągu, cały ciąg musi być jednym słowem, więc powinien zostać zwrócony cały ciąg.
Uwaga: Dla celów wprowadzenia wycinków, w tej sekcji zakładamy tylko ASCII; bardziej szczegółowe omówienie obsługi UTF-8 znajduje się w sekcji „Przechowywanie tekstu kodowanego w UTF-8 za pomocą ciągów znaków” w Rozdziale 8.
Przyjrzyjmy się, jak napisalibyśmy sygnaturę tej funkcji bez użycia wycinków, aby zrozumieć problem, który wycinki rozwiążą:
fn first_word(s: &String) -> ?
Funkcja first_word ma parametr typu &String. Nie potrzebujemy własności, więc to jest w porządku. (W idiomatycznym Rust, funkcje nie przejmują własności swoich argumentów, chyba że jest to konieczne, a powody tego staną się jasne, gdy będziemy kontynuować.) Ale co powinniśmy zwrócić? Nie mamy tak naprawdę sposobu, aby mówić o części ciągu. Jednak moglibyśmy zwrócić indeks końca słowa, wskazany przez spację. Spróbujmy tego, jak pokazano w Listingu 4-7.
fn first_word(s: &String) -> usize {
let bytes = s.as_bytes();
for (i, &item) in bytes.iter().enumerate() {
if item == b' ' {
return i;
}
}
s.len()
}
fn main() {}
Ponieważ musimy przejść przez String element po elemencie i sprawdzić, czy wartość jest spacją, przekształcimy nasz String w tablicę bajtów za pomocą metody as_bytes.
fn first_word(s: &String) -> usize {
let bytes = s.as_bytes();
for (i, &item) in bytes.iter().enumerate() {
if item == b' ' {
return i;
}
}
s.len()
}
fn main() {}
Następnie, tworzymy iterator po tablicy bajtów używając metody iter:
fn first_word(s: &String) -> usize {
let bytes = s.as_bytes();
for (i, &item) in bytes.iter().enumerate() {
if item == b' ' {
return i;
}
}
s.len()
}
fn main() {}
Iteratory omówimy bardziej szczegółowo w Rozdziale 13. Na razie wystarczy wiedzieć, że iter to metoda, która zwraca każdy element w kolekcji, a enumerate opakowuje wynik iter i zwraca każdy element jako część krotki. Pierwszy element krotki zwróconej z enumerate to indeks, a drugi element to referencja do elementu. Jest to nieco wygodniejsze niż samodzielne obliczanie indeksu.
Ponieważ metoda enumerate zwraca krotkę, możemy użyć wzorców do dekonstrukcji tej krotki. Więcej o wzorcach omówimy w Rozdziale 6. W pętli for określamy wzorzec, który ma i dla indeksu w krotce i &item dla pojedynczego bajtu w krotce. Ponieważ otrzymujemy referencję do elementu z .iter().enumerate(), używamy & we wzorcu.
Wewnątrz pętli for szukamy bajtu reprezentującego spację, używając składni literału bajtowego. Jeśli znajdziemy spację, zwracamy pozycję. W przeciwnym razie zwracamy długość ciągu, używając s.len().
fn first_word(s: &String) -> usize {
let bytes = s.as_bytes();
for (i, &item) in bytes.iter().enumerate() {
if item == b' ' {
return i;
}
}
s.len()
}
fn main() {}
Mamy teraz sposób na znalezienie indeksu końca pierwszego słowa w ciągu, ale jest problem. Zwracamy samo usize, ale jest to znacząca liczba tylko w kontekście &String. Innymi słowy, ponieważ jest to oddzielna wartość od String, nie ma gwarancji, że będzie ona nadal ważna w przyszłości. Rozważ program z Listingu 4-8, który używa funkcji first_word z Listingu 4-7.
fn first_word(s: &String) -> usize {
let bytes = s.as_bytes();
for (i, &item) in bytes.iter().enumerate() {
if item == b' ' {
return i;
}
}
s.len()
}
fn main() {
let mut s = String::from("hello world");
let word = first_word(&s); // word otrzyma wartość 5
s.clear(); // to opróżnia String, sprawiając, że staje się równy ""
// word nadal ma wartość 5 tutaj, ale s nie ma już żadnej zawartości, której moglibyśmy
// sensownie użyć z wartością 5, więc word jest teraz całkowicie nieważne!
}
Ten program kompiluje się bez żadnych błędów i skompilowałby się również, gdybyśmy użyli word po wywołaniu s.clear(). Ponieważ word w żaden sposób nie jest powiązane ze stanem s, word nadal zawiera wartość 5. Moglibyśmy użyć tej wartości 5 ze zmienną s, aby spróbować wyodrębnić pierwsze słowo, ale byłby to błąd, ponieważ zawartość s zmieniła się, odkąd zapisaliśmy 5 w word.
Martwienie się o to, że indeks w word przestaje być zsynchronizowany z danymi w s, jest uciążliwe i podatne na błędy! Zarządzanie tymi indeksami jest jeszcze bardziej kruche, jeśli napiszemy funkcję second_word. Jej sygnatura musiałaby wyglądać tak:
fn second_word(s: &String) -> (usize, usize) {
Teraz śledzimy indeks początkowy i końcowy, i mamy jeszcze więcej wartości, które zostały obliczone na podstawie danych w określonym stanie, ale w ogóle nie są z tym stanem powiązane. Mamy trzy niepowiązane zmienne, które muszą być zsynchronizowane.
Na szczęście Rust ma rozwiązanie tego problemu: wycinki ciągów znaków (string slices).
Wycinki ciągów znaków (String Slices)
Wycinek ciągu znaków to referencja do ciągłej sekwencji elementów String i wygląda tak:
fn main() {
let s = String::from("hello world");
let hello = &s[0..5];
let world = &s[6..11];
}
Zamiast referencji do całego String, hello jest referencją do fragmentu String, określonego dodatkowym fragmentem [0..5]. Wycinki tworzymy za pomocą zakresu w nawiasach kwadratowych, określając [indeks_początkowy..indeks_końcowy], gdzie indeks_początkowy to pierwsza pozycja w wycinku, a indeks_końcowy to jeden więcej niż ostatnia pozycja w wycinku. Wewnętrznie struktura danych wycinka przechowuje pozycję początkową i długość wycinka, co odpowiada indeks_końcowy minus indeks_początkowy. Zatem w przypadku let world = &s[6..11];, world byłby wycinkiem, który zawiera wskaźnik do bajtu o indeksie 6 w s z wartością długości 5.
Rysunek 4-7 przedstawia to na diagramie.
Rysunek 4-7: Wycinek ciągu znaków odnoszący się do części String
Dzięki składni zakresu .. w Rust, jeśli chcesz zacząć od indeksu 0, możesz pominąć wartość przed dwoma kropkami. Innymi słowy, te są równoważne:
#![allow(unused)]
fn main() {
let s = String::from("hello");
let slice = &s[0..2];
let slice = &s[..2];
}
Analogicznie, jeśli twój wycinek zawiera ostatni bajt String, możesz pominąć końcową liczbę. Oznacza to, że te są równoważne:
#![allow(unused)]
fn main() {
let s = String::from("hello");
let len = s.len();
let slice = &s[3..len];
let slice = &s[3..];
}
Możesz również pominąć obie wartości, aby utworzyć wycinek całego ciągu. W ten sposób te są równoważne:
#![allow(unused)]
fn main() {
let s = String::from("hello");
let len = s.len();
let slice = &s[0..len];
let slice = &s[..];
}
Uwaga: Indeksy zakresu wycinka ciągu muszą występować na prawidłowych granicach znaków UTF-8. Jeśli spróbujesz utworzyć wycinek ciągu w środku znaku wielobajtowego, program zakończy się błędem.
Mając na uwadze wszystkie te informacje, przepiszmy first_word, aby zwracało wycinek. Typ, który oznacza „wycinek ciągu znaków”, jest zapisywany jako &str:
fn first_word(s: &String) -> &str {
let bytes = s.as_bytes();
for (i, &item) in bytes.iter().enumerate() {
if item == b' ' {
return &s[0..i];
}
}
&s[..]
}
fn main() {}
Indeks końca słowa uzyskujemy w ten sam sposób, jak w Listingu 4-7, szukając pierwszego wystąpienia spacji. Gdy znajdziemy spację, zwracamy wycinek ciągu, używając początku ciągu i indeksu spacji jako indeksów początkowego i końcowego.
Teraz, gdy wywołujemy first_word, otrzymujemy jedną wartość, która jest powiązana z danymi podstawowymi. Wartość ta składa się z referencji do punktu początkowego wycinka i liczby elementów w wycinku.
Zwracanie wycinka działałoby również dla funkcji second_word:
fn second_word(s: &String) -> &str {
Mamy teraz prosty interfejs API, który jest znacznie trudniejszy do popsucia, ponieważ kompilator zapewni, że referencje do String pozostaną ważne. Pamiętasz błąd w programie z Listingu 4-8, kiedy uzyskaliśmy indeks końca pierwszego słowa, ale potem wyczyściliśmy ciąg, więc nasz indeks był nieprawidłowy? Ten kod był logicznie niepoprawny, ale nie wykazywał żadnych natychmiastowych błędów. Problemy pojawiłyby się później, gdybyśmy nadal próbowali używać indeksu pierwszego słowa z opróżnionym ciągiem. Wycinki uniemożliwiają ten błąd i pozwalają nam znacznie wcześniej dowiedzieć się, że mamy problem z naszym kodem. Użycie wersji first_word z wycinkiem spowoduje błąd kompilacji:
fn first_word(s: &String) -> &str {
let bytes = s.as_bytes();
for (i, &item) in bytes.iter().enumerate() {
if item == b' ' {
return &s[0..i];
}
}
&s[..]
}
fn main() {
let mut s = String::from("hello world");
let word = first_word(&s);
s.clear(); // błąd!
println!("pierwsze słowo to: {word}");
}
Oto błąd kompilatora:
$ cargo run
Compiling ownership v0.1.0 (file:///projects/ownership)
error[E0502]: cannot borrow `s` as mutable because it is also borrowed as immutable
--> src/main.rs:18:5
|
16 | let word = first_word(&s);
| -- immutable borrow occurs here
17 |
18 | s.clear(); // error!
| ^^^^^^^^^ mutable borrow occurs here
19 |
20 | println!("the first word is: {word}");
| ---- immutable borrow later used here
For more information about this error, try `rustc --explain E0502`.
error: could not compile `ownership` (bin "ownership") due to 1 previous error
Przypomnij sobie z zasad pożyczania, że jeśli mamy niezmienną referencję do czegoś, nie możemy również wziąć mutowalnej referencji. Ponieważ clear musi skrócić String, potrzebuje mutowalnej referencji. println! po wywołaniu clear używa referencji w word, więc referencja niezmienna musi być nadal aktywna w tym punkcie. Rust zabrania istnienia mutowalnej referencji w clear i niezmiennej referencji w word w tym samym czasie, a kompilacja kończy się niepowodzeniem. Rust nie tylko ułatwił nam korzystanie z API, ale także wyeliminował całą klasę błędów w czasie kompilacji!
Literały ciągów znaków jako wycinki
Przypomnijmy, że mówiliśmy o literałach ciągów znaków przechowywanych wewnątrz pliku binarnego. Teraz, gdy wiemy o wycinkach, możemy właściwie zrozumieć literały ciągów znaków:
#![allow(unused)]
fn main() {
let s = "Hello, world!";
}
Typ s to &str: jest to wycinek wskazujący na ten konkretny punkt w pliku binarnym. Jest to również powód, dla którego literały ciągów znaków są niezmienne; &str to niezmienna referencja.
Wycinki ciągów znaków jako parametry
Wiedza o tym, że można tworzyć wycinki z literałów i wartości String, prowadzi nas do jeszcze jednego ulepszenia funkcji first_word, a mianowicie jej sygnatury:
fn first_word(s: &String) -> &str {
Bardziej doświadczony Rustacean napisałby sygnaturę pokazaną w Listingu 4-9 zamiast niej, ponieważ pozwala to nam używać tej samej funkcji zarówno dla wartości &String, jak i dla wartości &str.
fn first_word(s: &str) -> &str {
let bytes = s.as_bytes();
for (i, &item) in bytes.iter().enumerate() {
if item == b' ' {
return &s[0..i];
}
}
&s[..]
}
fn main() {
let my_string = String::from("hello world");
// `first_word` działa na wycinkach `String`ów, częściowych lub całych.
let word = first_word(&my_string[0..6]);
let word = first_word(&my_string[..]);
// `first_word` działa również na referencjach do `String`ów, które są równoważne
// z całymi wycinkami `String`ów.
let word = first_word(&my_string);
let my_string_literal = "hello world";
// `first_word` działa na wycinkach literałów ciągu znaków, częściowych lub
// całych.
let word = first_word(&my_string_literal[0..6]);
let word = first_word(&my_string_literal[..]);
// Ponieważ literały ciągu znaków *są* już wycinkami ciągu znaków,
// to również działa, bez składni wycinków!
let word = first_word(my_string_literal);
}
Jeśli mamy wycinek ciągu znaków, możemy go przekazać bezpośrednio. Jeśli mamy String, możemy przekazać wycinek String lub referencję do String. Ta elastyczność wykorzystuje konwersje dereferencji, cechę, którą omówimy w sekcji „Używanie konwersji dereferencji w funkcjach i metodach” w Rozdziale 15.
Definiowanie funkcji, która przyjmuje wycinek ciągu znaków zamiast referencji do String, sprawia, że nasze API jest bardziej ogólne i użyteczne bez utraty żadnej funkcjonalności:
fn first_word(s: &str) -> &str {
let bytes = s.as_bytes();
for (i, &item) in bytes.iter().enumerate() {
if item == b' ' {
return &s[0..i];
}
}
&s[..]
}
fn main() {
let my_string = String::from("hello world");
// `first_word` działa na wycinkach `String`ów, częściowych lub całych.
let word = first_word(&my_string[0..6]);
let word = first_word(&my_string[..]);
// `first_word` działa również na referencjach do `String`ów, które są równoważne
// z całymi wycinkami `String`ów.
let word = first_word(&my_string);
let my_string_literal = "hello world";
// `first_word` działa na wycinkach literałów ciągu znaków, częściowych lub
// całych.
let word = first_word(&my_string_literal[0..6]);
let word = first_word(&my_string_literal[..]);
// Ponieważ literały ciągu znaków *są* już wycinkami ciągu znaków,
// to również działa, bez składni wycinków!
let word = first_word(my_string_literal);
}
Inne wycinki
Wycinki ciągów znaków, jak można sobie wyobrazić, są specyficzne dla ciągów. Ale istnieje również bardziej ogólny typ wycinka. Rozważmy tę tablicę:
#![allow(unused)]
fn main() {
let a = [1, 2, 3, 4, 5];
}
Tak samo jak możemy chcieć odwołać się do części ciągu, możemy chcieć odwołać się do części tablicy. Zrobilibyśmy to w ten sposób:
#![allow(unused)]
fn main() {
let a = [1, 2, 3, 4, 5];
let slice = &a[1..3];
assert_eq!(slice, &[2, 3]);
}
Ten wycinek ma typ &[i32]. Działa tak samo jak wycinki ciągów, przechowując referencję do pierwszego elementu i długość. Będziesz używać tego rodzaju wycinków dla wszelkiego rodzaju innych kolekcji. Te kolekcje omówimy szczegółowo, gdy będziemy mówić o wektorach w Rozdziale 8.
Podsumowanie
Koncepcje własności, pożyczania i wycinków zapewniają bezpieczeństwo pamięci w programach Rust w czasie kompilacji. Język Rust daje kontrolę nad wykorzystaniem pamięci w taki sam sposób, jak inne języki programowania systemowego. Ale to, że właściciel danych automatycznie czyści te dane, gdy właściciel wychodzi poza zasięg, oznacza, że nie musisz pisać i debugować dodatkowego kodu, aby uzyskać tę kontrolę.
Własność wpływa na działanie wielu innych części Rust, więc będziemy rozmawiać o tych koncepcjach w dalszej części książki. Przejdźmy do Rozdziału 5 i przyjrzyjmy się grupowaniu fragmentów danych w struct.