Przechowywanie tekstu zakodowanego w UTF-8 za pomocą ciągów znaków
O ciągach znaków rozmawialiśmy w Rozdziale 4, ale teraz przyjrzymy się im bardziej szczegółowo. Nowi użytkownicy Rusta często mają problemy z ciągami znaków z powodu połączenia trzech czynników: skłonności Rusta do ujawniania możliwych błędów, ciągów znaków będących bardziej skomplikowaną strukturą danych, niż wielu programistów im przypisuje, oraz UTF-8. Czynniki te łączą się w sposób, który może wydawać się trudny, gdy pochodzisz z innych języków programowania.
Ciągi znaków omawiamy w kontekście kolekcji, ponieważ są one implementowane jako kolekcja bajtów, plus kilka metod zapewniających użyteczną funkcjonalność, gdy te bajty są interpretowane jako tekst. W tej sekcji omówimy operacje na String, które posiada każdy typ kolekcji, takie jak tworzenie, aktualizowanie i odczytywanie. Omówimy również różnice między String a innymi kolekcjami, a mianowicie, jak indeksowanie String jest skomplikowane przez różnice między tym, jak ludzie i komputery interpretują dane String.
Definiowanie ciągów znaków
Najpierw zdefiniujemy, co rozumiemy przez termin ciąg znaków. Rust ma tylko jeden typ ciągu znaków w podstawowym języku, który jest wycinkiem ciągu str, zazwyczaj występującym w formie pożyczonej, &str. W Rozdziale 4 rozmawialiśmy o wycinkach ciągów znaków, które są referencjami do danych ciągu znaków zakodowanych w UTF-8, przechowywanych gdzie indziej. Literały ciągów znaków, na przykład, są przechowywane w binarnym pliku programu i dlatego są wycinkami ciągów znaków.
Typ String, który jest dostarczany przez standardową bibliotekę Rusta, a nie zakodowany w podstawowym języku, jest rosnącym, mutowalnym, posiadającym własność, zakodowanym w UTF-8 typem ciągu znaków. Kiedy użytkownicy Rusta odwołują się do „ciągów znaków” w Rust, mogą odnosić się zarówno do typów String, jak i wycinków ciągu &str, a nie tylko do jednego z tych typów. Chociaż ta sekcja dotyczy głównie String, oba typy są intensywnie używane w standardowej bibliotece Rusta, a zarówno String, jak i wycinki ciągów znaków są zakodowane w UTF-8.
Tworzenie nowego ciągu znaków
Wiele z tych samych operacji dostępnych dla Vec<T> jest również dostępnych dla String, ponieważ String jest faktycznie implementowane jako opakowanie wokół wektora bajtów z dodatkowymi gwarancjami, ograniczeniami i możliwościami. Przykładem funkcji, która działa w ten sam sposób z Vec<T> i String, jest funkcja new do tworzenia instancji, pokazana w Listing 8-11.
fn main() {
let mut s = String::new();
}
Ta linia tworzy nowy, pusty ciąg znaków o nazwie s, do którego możemy następnie załadować dane. Często będziemy mieli pewne początkowe dane, którymi chcemy rozpocząć ciąg znaków. W tym celu używamy metody to_string, która jest dostępna dla każdego typu implementującego cechę Display, tak jak literały ciągów znaków. Listing 8-12 pokazuje dwa przykłady.
fn main() {
let data = "początkowa zawartość";
let s = data.to_string();
// Metoda działa również bezpośrednio na literale:
let s = "początkowa zawartość".to_string();
}
Ten kod tworzy ciąg znaków zawierający początkową zawartość.
Możemy również użyć funkcji String::from do utworzenia String z literału ciągu znaków. Kod w Listing 8-13 jest równoważny kodowi w Listing 8-12, który używa to_string.
fn main() {
let s = String::from("początkowa zawartość");
}
Ponieważ ciągi znaków są używane do wielu rzeczy, możemy używać wielu różnych generycznych API dla ciągów znaków, co daje nam wiele opcji. Niektóre z nich mogą wydawać się nadmiarowe, ale wszystkie mają swoje miejsce! W tym przypadku String::from i to_string robią to samo, więc wybór zależy od stylu i czytelności.
Pamiętaj, że ciągi znaków są zakodowane w UTF-8, więc możemy w nich zawrzeć dowolne poprawnie zakodowane dane, jak pokazano w Listing 8-14.
fn main() {
let hello = String::from("السلام عليكم");
let hello = String::from("Dobrý den");
let hello = String::from("Hello");
let hello = String::from("שלום");
let hello = String::from("नमस्ते");
let hello = String::from("こんにちは");
let hello = String::from("안녕하세요");
let hello = String::from("你好");
let hello = String::from("Olá");
let hello = String::from("Здравствуйте");
let hello = String::from("Hola");
}
Wszystkie te wartości String są prawidłowe.
Aktualizowanie ciągu znaków
String może rosnąć i zmieniać swoją zawartość, podobnie jak zawartość Vec<T>, jeśli dodasz do niego więcej danych. Ponadto możesz wygodnie użyć operatora + lub makra format! do łączenia wartości String.
Dołączanie za pomocą push_str lub push
Możemy powiększyć String, używając metody push_str do dołączenia wycinka ciągu znaków, jak pokazano w Listing 8-15.
fn main() {
let mut s = String::from("foo");
s.push_str("bar");
}
Po tych dwóch liniach s będzie zawierać foobar. Metoda push_str przyjmuje wycinek ciągu znaków, ponieważ niekoniecznie chcemy przejąć własność parametru. Na przykład w kodzie w Listing 8-16 chcemy móc użyć s2 po dołączeniu jego zawartości do s1.
fn main() {
let mut s1 = String::from("foo");
let s2 = "bar";
s1.push_str(s2);
println!("s2 to {s2}");
}
Gdyby metoda push_str przejęła własność s2, nie moglibyśmy wydrukować jego wartości w ostatniej linii. Jednak ten kod działa zgodnie z oczekiwaniami!
Metoda push przyjmuje pojedynczy znak jako parametr i dodaje go do String. Listing 8-17 dodaje literę l do String za pomocą metody push.
fn main() {
let mut s = String::from("lo");
s.push('l');
}
W rezultacie s będzie zawierać lol.
Łączenie za pomocą + lub format!
Często będziesz chciał połączyć dwa istniejące ciągi znaków. Jednym ze sposobów na to jest użycie operatora +, jak pokazano w Listing 8-18.
fn main() {
let s1 = String::from("Witaj, ");
let s2 = String::from("świecie!");
let s3 = s1 + &s2; // uwaga, s1 zostało przeniesione i nie może być już użyte
}
Ciąg s3 będzie zawierał Witaj, świecie!. Powód, dla którego s1 jest już nieważne po dodaniu, oraz powód, dla którego użyliśmy referencji do s2, ma związek z sygnaturą metody, która jest wywoływana, gdy używamy operatora +. Operator + używa metody add, której sygnatura wygląda mniej więcej tak:
fn add(self, s: &str) -> String {
W standardowej bibliotece zobaczysz add zdefiniowane przy użyciu generyków i typów skojarzonych. Tutaj podstawiliśmy konkretne typy, co dzieje się, gdy wywołujemy tę metodę z wartościami String. Generyki omówimy w Rozdziale 10. Ta sygnatura daje nam wskazówki potrzebne do zrozumienia trudnych aspektów operatora +.
Po pierwsze, s2 ma &, co oznacza, że dodajemy referencję drugiego ciągu znaków do pierwszego ciągu znaków. Dzieje się tak z powodu parametru s w funkcji add: możemy dodać tylko wycinek ciągu znaków do String; nie możemy dodać dwóch wartości String razem. Ale czekaj – typ &s2 to &String, a nie &str, jak określono w drugim parametrze add. Dlaczego więc Listing 8-18 kompiluje się?
Powodem, dla którego możemy użyć &s2 w wywołaniu add, jest to, że kompilator może wymusić konwersję argumentu &String na &str. Kiedy wywołujemy metodę add, Rust używa deref coercion, która tutaj zmienia &s2 na &s2[..]. Omówimy deref coercion szczegółowo w Rozdziale 15. Ponieważ add nie przejmuje własności parametru s, s2 nadal będzie prawidłowym String po tej operacji.
Po drugie, w sygnaturze widzimy, że add przejmuje własność self, ponieważ self nie ma &. Oznacza to, że s1 w Listing 8-18 zostanie przeniesione do wywołania add i po tym nie będzie już ważne. Tak więc, chociaż let s3 = s1 + &s2; wygląda na to, że skopiuje oba ciągi i stworzy nowy, to faktycznie przejmuje własność s1, dołącza kopię zawartości s2, a następnie zwraca własność wyniku. Innymi słowy, wygląda na to, że wykonuje wiele kopii, ale tak nie jest; implementacja jest bardziej wydajna niż kopiowanie.
Jeśli potrzebujemy połączyć wiele ciągów znaków, zachowanie operatora + staje się nieporęczne:
fn main() {
let s1 = String::from("tic");
let s2 = String::from("tac");
let s3 = String::from("toe");
let s = s1 + "-" + &s2 + "-" + &s3;
}
W tym momencie s będzie tic-tac-toe. Z tymi wszystkimi znakami + i " trudno jest zrozumieć, co się dzieje. Do łączenia ciągów znaków w bardziej skomplikowany sposób możemy zamiast tego użyć makra format!:
fn main() {
let s1 = String::from("tic");
let s2 = String::from("tac");
let s3 = String::from("toe");
let s = format!("{s1}-{s2}-{s3}");
}
Ten kod również ustawia s na tic-tac-toe. Makro format! działa jak println!, ale zamiast drukować wynik na ekranie, zwraca String z zawartością. Wersja kodu używająca format! jest znacznie łatwiejsza do odczytania, a kod generowany przez makro format! używa referencji, dzięki czemu to wywołanie nie przejmuje własności żadnego z jego parametrów.
Indeksowanie w ciągach znaków
W wielu innych językach programowania, dostęp do pojedynczych znaków w ciągu znaków poprzez odwoływanie się do nich za pomocą indeksu jest prawidłową i powszechną operacją. Jednakże, jeśli spróbujesz uzyskać dostęp do części String za pomocą składni indeksowania w Rust, otrzymasz błąd. Rozważ nieprawidłowy kod w Listing 8-19.
fn main() {
let s1 = String::from("hi");
let h = s1[0];
}
Ten kod spowoduje następujący błąd:
$ cargo run
Compiling collections v0.1.0 (file:///projects/collections)
error[E0277]: the type `str` cannot be indexed by `{integer}`
--> src/main.rs:3:16
|
3 | let h = s1[0];
| ^ string indices are ranges of `usize`
|
= help: the trait `SliceIndex<str>` is not implemented for `{integer}`
= note: you can use `.chars().nth()` or `.bytes().nth()`
for more information, see chapter 8 in The Book: <https://doc.rust-lang.org/book/ch08-02-strings.html#indexing-into-strings>
= help: the following other types implement trait `SliceIndex<T>`:
`usize` implements `SliceIndex<ByteStr>`
`usize` implements `SliceIndex<[T]>`
= note: required for `String` to implement `Index<{integer}>`
For more information about this error, try `rustc --explain E0277`.
error: could not compile `collections` (bin "collections") due to 1 previous error
Błąd opowiada historię: ciągi znaków w Rust nie obsługują indeksowania. Ale dlaczego? Aby odpowiedzieć na to pytanie, musimy omówić, jak Rust przechowuje ciągi znaków w pamięci.
Reprezentacja wewnętrzna
String jest opakowaniem na Vec<u8>. Spójrzmy na niektóre z naszych poprawnie zakodowanych przykładów ciągów znaków UTF-8 z Listing 8-14. Najpierw ten:
fn main() {
let hello = String::from("السلام عليكم");
let hello = String::from("Dobrý den");
let hello = String::from("Hello");
let hello = String::from("שלום");
let hello = String::from("नमस्ते");
let hello = String::from("こんにちは");
let hello = String::from("안녕하세요");
let hello = String::from("你好");
let hello = String::from("Olá");
let hello = String::from("Здравствуйте");
let hello = String::from("Hola");
}
W tym przypadku len będzie równe 4, co oznacza, że wektor przechowujący ciąg znaków "Hola" ma 4 bajty długości. Każda z tych liter zajmuje 1 bajt po zakodowaniu w UTF-8. Następna linia może Cię jednak zaskoczyć (zauważ, że ten ciąg zaczyna się od wielkiej cyrylicznej litery Ze, a nie liczby 3):
fn main() {
let hello = String::from("السلام عليكم");
let hello = String::from("Dobrý den");
let hello = String::from("Hello");
let hello = String::from("שלום");
let hello = String::from("नमस्ते");
let hello = String::from("こんにちは");
let hello = String::from("안녕하세요");
let hello = String::from("你好");
let hello = String::from("Olá");
let hello = String::from("Здравствуйте");
let hello = String::from("Hola");
}
Gdybyś został zapytany, jak długi jest ten ciąg znaków, mógłbyś powiedzieć 12. W rzeczywistości odpowiedź Rusta to 24: to liczba bajtów potrzebna do zakodowania „Здравствуйте” w UTF-8, ponieważ każda skalarna wartość Unicode w tym ciągu zajmuje 2 bajty pamięci. Dlatego indeksowanie bajtów ciągu nie zawsze będzie korelować z prawidłową skalarną wartością Unicode. Aby to zademonstrować, rozważ ten nieprawidłowy kod w Rust:
let hello = "Здравствуйте";
let answer = &hello[0];
Wiesz już, że answer nie będzie З, pierwszą literą. Po zakodowaniu w UTF-8 pierwszy bajt З to 208, a drugi to 151, więc wydawałoby się, że answer powinien faktycznie wynosić 208, ale 208 sam w sobie nie jest prawidłowym znakiem. Zwracanie 208 prawdopodobnie nie jest tym, czego użytkownik chciałby, gdyby poprosił o pierwszą literę tego ciągu; jednak to jedyne dane, które Rust ma pod indeksem bajtowym 0. Użytkownicy generalnie nie chcą, aby zwracana była wartość bajtowa, nawet jeśli ciąg zawiera tylko litery łacińskie: Gdyby &"hi"[0] był prawidłowym kodem zwracającym wartość bajtową, zwróciłby 104, a nie h.
Odpowiedź brzmi zatem, że aby uniknąć zwracania nieoczekiwanej wartości i powodowania błędów, które mogą nie zostać natychmiast wykryte, Rust w ogóle nie kompiluje tego kodu i zapobiega nieporozumieniom na wczesnym etapie procesu rozwoju.
Bajty, wartości skalarne i klastry grafemów
Inną kwestią dotyczącą UTF-8 jest to, że istnieją faktycznie trzy istotne sposoby patrzenia na ciągi znaków z perspektywy Rusta: jako bajty, wartości skalarne i klastry grafemów (najbliższe temu, co nazwalibyśmy literami).
Jeśli spojrzymy na hinduskie słowo „नमस्ते” napisane pismem dewanagari, jest ono przechowywane jako wektor wartości u8, który wygląda tak:
[224, 164, 168, 224, 164, 174, 224, 164, 184, 224, 165, 141, 224, 164, 164,
224, 165, 135]
To 18 bajtów i tak ostatecznie komputery przechowują te dane. Jeśli spojrzymy na nie jako skalarne wartości Unicode, czyli to, czym jest typ char w Rust, te bajty wyglądają tak:
['न', 'म', 'स', '्', 'त', 'े']
Jest tu sześć wartości char, ale czwarta i szósta nie są literami: to znaki diakrytyczne, które same w sobie nie mają sensu. W końcu, jeśli spojrzymy na nie jako klastry grafemów, otrzymamy to, co człowiek nazwałby czterema literami tworzącymi hinduskie słowo:
["न", "म", "स्", "ते"]
Rust udostępnia różne sposoby interpretacji surowych danych ciągu znaków, które przechowują komputery, tak aby każdy program mógł wybrać interpretację, której potrzebuje, niezależnie od tego, w jakim języku ludzkim są dane.
Ostatnim powodem, dla którego Rust nie pozwala nam indeksować String w celu uzyskania znaku, jest to, że operacje indeksowania mają zawsze zajmować stały czas (O(1)). Nie jest jednak możliwe zagwarantowanie takiej wydajności w przypadku String, ponieważ Rust musiałby przechodzić przez zawartość od początku do indeksu, aby określić, ile jest prawidłowych znaków.
Krojenie ciągów znaków
Indeksowanie ciągu znaków jest często złym pomysłem, ponieważ nie jest jasne, jaki powinien być typ zwracany przez operację indeksowania ciągu znaków: wartość bajtowa, znak, klaster grafemów czy wycinek ciągu znaków. Jeśli naprawdę potrzebujesz używać indeksów do tworzenia wycinków ciągu znaków, Rust prosi o większą precyzję.
Zamiast indeksowania za pomocą [] z pojedynczą liczbą, możesz użyć [] z zakresem, aby utworzyć wycinek ciągu znaków zawierający określone bajty:
#![allow(unused)]
fn main() {
let hello = "Здравствуйте";
let s = &hello[0..4];
}
W tym przypadku s będzie &str, który zawiera pierwsze 4 bajty ciągu. Wcześniej wspomnieliśmy, że każdy z tych znaków miał 2 bajty, co oznacza, że s będzie Зд.
Gdybyśmy spróbowali podzielić tylko część bajtów znaku za pomocą czegoś takiego jak &hello[0..1], Rust panikowałby w czasie wykonywania w taki sam sposób, jakbyśmy uzyskali dostęp do nieprawidłowego indeksu w wektorze:
$ cargo run
Compiling collections v0.1.0 (file:///projects/collections)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.43s
Running `target/debug/collections`
thread 'main' panicked at src/main.rs:4:19:
byte index 1 is not a char boundary; it is inside 'З' (bytes 0..2) of `Здравствуйте`
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
Powinieneś zachować ostrożność podczas tworzenia wycinków ciągów znaków za pomocą zakresów, ponieważ może to spowodować awarię programu.
Iterowanie po ciągach znaków
Najlepszym sposobem na operowanie na fragmentach ciągów znaków jest wyraźne określenie, czy chcemy znaków, czy bajtów. W przypadku pojedynczych skalarnych wartości Unicode, użyj metody chars. Wywołanie chars na „Зд” rozdziela i zwraca dwie wartości typu char, i możesz iterować po wyniku, aby uzyskać dostęp do każdego elementu:
#![allow(unused)]
fn main() {
for c in "Зд".chars() {
println!("{c}");
}
}
Ten kod wydrukuje następujące:
З
д
Alternatywnie, metoda bytes zwraca każdy surowy bajt, co może być odpowiednie dla twojej domeny:
#![allow(unused)]
fn main() {
for b in "Зд".bytes() {
println!("{b}");
}
}
Ten kod wydrukuje 4 bajty, które składają się na ten ciąg znaków:
208
151
208
180
Ale pamiętaj, że prawidłowe skalarne wartości Unicode mogą składać się z więcej niż 1 bajta.
Uzyskiwanie klastrów grafemów z ciągów znaków, tak jak w przypadku pisma dewanagari, jest złożone, dlatego ta funkcjonalność nie jest dostarczana przez standardową bibliotekę. Skrzynki są dostępne na crates.io, jeśli to jest funkcjonalność, której potrzebujesz.
Obsługa złożoności ciągów znaków
Podsumowując, ciągi znaków są skomplikowane. Różne języki programowania podejmują różne decyzje dotyczące tego, jak przedstawić tę złożoność programistom. Rust wybrał, aby prawidłowe obchodzenie się z danymi String było domyślnym zachowaniem dla wszystkich programów Rust, co oznacza, że programiści muszą z wyprzedzeniem poświęcić więcej uwagi obsłudze danych UTF-8. Ten kompromis ujawnia więcej złożoności ciągów znaków, niż jest to widoczne w innych językach programowania, ale zapobiega konieczności obsługi błędów związanych ze znakami spoza ASCII w późniejszym cyklu rozwoju.
Dobrą wiadomością jest to, że standardowa biblioteka oferuje wiele funkcjonalności zbudowanych na typach String i &str, aby pomóc w prawidłowym radzeniu sobie z tymi złożonymi sytuacjami. Koniecznie sprawdź dokumentację pod kątem przydatnych metod, takich jak contains do wyszukiwania w ciągu znaków i replace do zastępowania części ciągu znaków innym ciągiem znaków.
Przejdźmy do czegoś nieco mniej skomplikowanego: mapy haszujące!