Przechowywanie Tekstów UTF-8 za Pomocą Łańcuchów

W rozdziale 4 poświęciliśmy trochę czasu łańcuchom (ang. strings), ale teraz zagłębimy się w ten temat. Świeżo upieczeni Rustowcy bardzo często zatrzymują się na łańcuchach i nie mogą ruszyć dalej z trzech powodów: Rust ma skłonność do ujawniania możliwych błędów, łańcuchy są o wiele bardziej złożoną strukturą danych niż wielu programistów przyznaje, a także system kodowania UTF-8. Jeśli uczyliście się wcześniej innych języków programowania, taka mieszanka może wydawać się trudna.

Łańcuchy znaków omówimy w kontekście kolekcji, ponieważ są one zaimplementowane jako kolekcja bajtów i paru metod zapewniających przydatną funkcjonalność, kiedy bajty są interpretowane jako tekst. W tej sekcji, poruszymy też kwestię operacji wykonywanych na String, takich jak tworzenie, modyfikowanie i czytanie i które są dostępne w każdej kolekcji. Określimy też różnice między String a innymi kolekcjami, a dokładniej jak rozbieżność między interpretacją danych w String przez człowieka i przez komputer komplikuje indeksowanie w String.

Czym Jest Łańcuch Znaków?

Zacznijmy od wyjaśnienia czym jest łańcuch znaków. W rdzeniu językowym Rusta znajdziemy tylko jeden rodzaj łańcucha znaków i jest nim wycinek łańcucha str , który zazwyczaj znaleźć można w formie zapożyczonej &str. W rozdziale 4 wspominaliśmy o wycinkach łańcuchów, które są referencją do pewnego łańcucha tekstu UTF-8 i zapisanego w innym miejscu. Na przykład, literały łańcuchów są zapisane w pliku binarnym programu, a więc są wycinkami łańcuchów.

Rodzaj łańcucha znaków String jest zapewniany przez bibliotekę standardową Rusta a nie wkodowany w rdzeń języka. Przechowuje tekst UTF-8, może się powiększać, mutować, a także być własnością. Rozmawiając o łańcuchach Rustowcy nie odwołują się do jednego konkretnego jego typu, mogą mieć na myśli alboString albo wycinek łańcucha &str. Chociaż ta sekcja poświęcona jest w dużej mierze String, oba typy są często wykorzystywane w bibliotece standardowej Rusta i oba przechowują tekst UTF-8

Tworzenie Nowego Łańcucha Znaków

Jeśli przyjrzymy się operacjom dostępnym w Vec<T> i w String, zauważymy, że wiele z nich się powtarza. Dzieje się tak, ponieważ String opakowuje wektor bajtów i posiada dodatkowo pewne zabezpieczenia, ograniczenia, a także możliwości. Przykładem funkcji działającej tak samo na Vec<T> i String jest funkcja new za pomocą której możemy stworzyć nowe instancje i która jest pokazana na listingu 8-11.

fn main() {
    let mut s = String::new();
}

Listing 8-11: Tworzenie nowego, pustego String

Powyższa linijka kodu tworzy nowy, pusty łańcuch s, do którego możemy wprowadzić dane. Częściej jednak mamy już jakieś wstępne dane, z którymi chcielibyśmy stworzyć łańcuch. By to osiągnąć, możemy posłużyć się metodą to_string, dostępną w każdym typie, który implementuje cechę Display tak jak robią to literały łańcuchów. Na listingu 8-12 znajdziemy dwa przykłady zastosowania tej metody.

fn main() {
    let data = "wstępna zawartość";

    let s = data.to_string();

    // ta metoda działa również bezpośrednio na literał:
    let s = "wstępna zawartość".to_string();
}

Listing 8-12: Użycie metody to_string w celu stworzenia String z literału łańcuchowego

Powyższy kod tworzy łańcuch zawierający wstępna zawartość.

By stworzyć String z literału łańcuchowego możemy również użyć funkcji String::from. Kod widoczny na listingu 8-13 stanowi równowartość kodu z listingu 8-12. Pierwszy utylizuje to_string a drugi String::from.

fn main() {
    let s = String::from("initial contents");
}

Listing 8-13: Użycie funkcji String::from w celu stworzenia String z literału łańcuchowego

Ponieważ istnieje mnóstwo zastosowań łańcuchów, możemy do nich używać wielu interfejsów generycznych. W konsekwencji, mamy przed sobą cały szereg opcji i choć niektóre z nich mogą wydawać się niepotrzebne, wszystkie mają swoją rolę do odegrania! W przypadku gdy String::from i to_string wykonują tę samą czynność, wybór między nimi sprowadza się do kwestii stylu i czytelności.

Należy pamiętać, że łańcuchy są zakodowane w UTF-8 i dzięki temu możemy wprowadzić do nich jakiekolwiek poprawnie zakodowane dane, co pokazane jest na listingu 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");
}

Listing 8-14: Przechowywanie odpowiednika “Dzień dobry/Cześć” w różnych językach za pomocą łańcuchów

Wszystkie powyższe wartości String są poprawne.

Modyfikowanie Łańcucha Znaków

Podobnie do Vec<T>, String może się powiększać a jego zawartość zmieniać jeśli wprowadzimy do niego więcej danych. Ponadto, możemy użyć operatora + i makra format! , żeby w poręczny sposób połączyć ze sobą wartości String.

Dodawanie Elementów do Łańcucha za Pomocą push_str i push

Używając metody push_str możemy powiększyć String i dodać do niego wycinek łańcucha tak jak jest to pokazane na listingu 8-15.

fn main() {
    let mut s = String::from("foo");
    s.push_str("bar");
}

Listing 8-15: Dodawanie wycinka łańcucha do String za pomocą metody push_str

Po napisaniu powyższych dwóch linijek kodu, s będzie zawierać w sobie foobar. Przy push_str używamy wycinka łańcucha ponieważ możemy nie chcieć, żeby metoda przejęła własność nad tym parametrem. Za przykład weźmy kod pokazany na listingu 8-16, gdzie chcemy móc ponownie 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 is {s2}");
}

Listing 8-16: Użycie wycinka łańcucha po dołączeniu jego zawartości do String

Gdyby metoda push_str przejęła własność nad s2, wyświetlenie jego zawartości w ostatniej linijce byłoby niemożliwe. Zamiast tego, kod działa tak jak tego oczekiwaliśmy!

Metoda push przyjmuje pojedynczy znak jako parametr i dodaje go do String. Kod na listingu 8-17 dodaje literę "l" do String za pomocą metody push.

fn main() {
    let mut s = String::from("lo");
    s.push('l');
}

Listing 8-17: Dodanie jednego znaku do wartości String za pomocą push

W rezultacie, s będzie zawierać w sobie lol.

Łączenie za Pomocą Operatora + lub Makra format!

Często wykonywaną operacją jest łączenie dwóch istniejących już łańcuchów. Pierwszym sposobem by to osiągnąć jest użycie operatora + tak jak jest to pokazane na listingu 8-18.

fn main() {
    let s1 = String::from("Witaj, ");
    let s2 = String::from("Świecie!");
    let s3 = s1 + &s2; // zauważ, że s1 został tutaj przeniesiony i nie możemy go użyć ponownie
}

Listing 8-18: Użycie operatora + by połączyć dwie wartości String i w ten sposób stworzyć nową wartość String

Łańcuch s3 będzie teraz zawierał Witaj, Świecie!. Utrata ważności przez s1 po dodaniu do łańcucha i użycie referencji do s2 są spowodowane sygnaturą metody wywoływanej przy użyciu operatora +. Operator + używa metody add, której sygnatura wygląda mniej więcej tak:

fn add(self, s: &str) -> String {

W bibliotece standardowej, add jest definiowane za pomocą typów generycznych i powiązanych. Tutaj wymieniliśmy je na typy konkretne, co jest prawidłowym zjawiskiem przy wywoływaniu metody add z wartościami String. Typami generycznymi zajmiemy się w rozdziale 10. Powyższa sygnatura dostarcza nam wskazówek potrzebnych do zrozumienia podchwytliwych elementów operatora +.

Po pierwsze, znak & stojący przed s2 oznacza, że do pierwszego łańcucha dodajemy referencję do drugiego łańcucha. Dzieje się tak za sprawą parametru s funkcji add. Dodanie do siebie dwóch wartości String jest niemożliwe, jedyne co możemy dodać do String to &str. Ale przecież typem &s2 jest &String a nie &str tak jak było pokazane w drugim parametrze funkcji add. Dlaczego w takim razie listing 8-18 kompiluje się?

Możemy użyć &s2 podczas wywoływania funkcji add ponieważ kompilator potrafi wymusić zmianę argumentu &String w argument &str. Podczas wywoływania metody add Rust wymusza dereferencję, która w tym przypadku zmienia &s2 w &s2[..]. Wymuszanie dereferencji omówimy szczegółowo w rozdziale 15. Ponieważ add nie przejmuje własności nad parametrem s, s2 wciąż będzie poprawnym String po tej operacji.

Po drugie, z sygnatury wynika, że add przejmuje własność nad self, ponieważ self nie zawiera &. Oznacza to, że w listingu 8-18 funkcja add przejmuje własność nad s1 i s1 traci ważność. Chociaż moglibyśmy pomyśleć, że let s3 = s1 + &s2; zduplikuje oba łańcuchy i stworzy nowy, nic takiego się nie dzieje. Zamiast tego, instrukcja przejmuje własność nad s1 dodaje do niego kopię zawartości s2, a potem oddaje własność nad rezultatem. Innymi słowy, let s3 = s1 + & s2; sprawia wrażenie tworzenia wielu duplikatów, ale tego nie robi. Ta implementacja jest wydajniejsza od kopiowania.

Przy łączeniu ze sobą wielu łańcuchów, operator + przestaje być poręczny:

fn main() {
    let s1 = String::from("tic");
    let s2 = String::from("tac");
    let s3 = String::from("toe");

    let s = s1 + "-" + &s2 + "-" + &s3;
}

Po napisaniu powyższego kodu, s będzie zawierać tic-tac-toe. Niestety, ilość znaków + i " utrudnia zrozumienie co się dzieje. Do bardziej złożonego łączenia łańcuchów, możemy 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}");
}

Powyższy kod również przypisuje s wartość tic-tac-toe. Działanie makra format! można porównać do println! z taką różnicą, że makro zwróci String razem z jego zawartością zamiast tylko wyświetlić rezultat. Użycie format! znacznie ułatwia czytanie kodu. Co więcej, kod generowany przez format! utylizuje referencje, żeby wywołanie metody nie przejęło własności na żadnym z jego parametrów.

Indeksowanie do Łańcuchów

W wielu językach programowania można uzyskać dostęp do pojedynczych znaków w łańcuchu używając indeksu i tworząc referencję do interesujących nas znaków. Jest to operacja poprawna i powszechnie używana, ale nie zadziała w Ruście. Jeśli spróbujemy użyć składni indeksowej by uzyskać dostęp do części String w Ruście, wyświetli nam się błąd. Spójrzmy na niepoprawny kod na listingu 8-19.

fn main() {
    let s1 = String::from("hello");
    let h = s1[0];
}

Listing 8-19: Próba użycia składni indeksowej z String

Przy kompilowaniu powyższego kodu, otrzymamy poniższy błąd:

$ cargo run
   Compiling collections v0.1.0 (file:///projects/collections)
error[E0277]: the type `String` cannot be indexed by `{integer}`
 --> src/main.rs:3:13
  |
3 |     let h = s1[0];
  |             ^^^^^ `String` cannot be indexed by `{integer}`
  |
  = help: the trait `Index<{integer}>` is not implemented for `String`
  = help: the following other types implement trait `Index<Idx>`:
            <String as Index<RangeFrom<usize>>>
            <String as Index<RangeFull>>
            <String as Index<RangeInclusive<usize>>>
            <String as Index<RangeTo<usize>>>
            <String as Index<RangeToInclusive<usize>>>
            <String as Index<std::ops::Range<usize>>>

For more information about this error, try `rustc --explain E0277`.
error: could not compile `collections` due to previous error

Powyższy błąd i notatka przekazują nam, że łańcuchy w Ruście nie obsługują indeksowania. Dlaczego? By odpowiedzieć na to pytanie, musimy porozmawiać o tym jak Rust przechowuje łańcuchy w pamięci.

Wewnętrzna Reprezentacja

String opakowuje Vec<u8>. Przyjrzyjmy się paru poprawnym łańcuchom UTF-8 z listingu 8-14. Jako pierwszy, omówimy poniższy łańcuch:

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, funkcja len zwróci wynik 4, co oznacza, że wektor przechowujący “Hola” ma długość czterech bajtów. Każda z tych liter waży jeden bajt jeśli została zakodowana w UTF-8. Następna linijka kodu może cię zaskoczyć. (Zauważ, że ten łańcuch rozpoczyna litera Ze alfabetu rosyjskiego a nie arabska cyfra 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");
}

Zapytany o długość tego łańcucha, mógłbyś odpowiedzieć 12 bajtów, ale tak naprawdę są to 24 bajty. Ponieważ każda wartość skalarna Unicode w tym łańcuchu zajmuje dwa bajty pamięci, potrzeba 24 bajtów by zakodować “Здравствуйте” w UTF-8. Dlatego też, indeks do bajtów łańcucha nie zawsze będzie korelował z poprawną wartością skalarną Unicode. W ramach przykładu, spójrz na poniższy kod:

let hello = "Здравствуйте";
let answer = &hello[0];

Wiesz już, że answer nie zwróci pierwszej litery czyli З. Ponieważ “Здравствуйте” jest zakodowane w UTF-8, pierwszym bajtem З jest 208 a drugim 151. Wydawałoby się zatem, że answer powinien zwracać 208, ale samo 208 nie jest poprawnym znakiem. Użytkownik prawdopodobnie nie chce, żeby program wyświetlił 208 kiedy prosi się go o pierwszą literę w łańcuchu, ale są to jedyne dane jakie Rust posiada przy indeksie bajtu 0. Wyświetlenie wartości bajtu zazwyczaj nie jest pożądane nawet jeśli łańcuch zawiera tylko litery alfabetu łacińskiego. Gdyby &"hello"[0] było poprawnie napisanym kodem i wyświetlało wartość bajtu, zwróciłoby 104 zamiast h.

Aby uniknąć zwracania niespodziewanych wartości i błędów, które nie rzucają się w oczy, Rust nie kompiluje tego kodu i zapobiega nieporozumieniom na wczesnym etapie rozwoju programu.

Bajty i Wartości Skalarne i Zbitki Grafemów! O Matko!

Porozmawiajmy teraz o kolejnej ważnej kwestii związanej z UTF-8, a mianowicie o sposobach patrzenia na łańcuchy przez Rusta. Spośród nich możemy wyłonić trzy naprawdę istotne perspektywy: łańcuchy jako bajty, wartości skalarne i jako zbitki grafemów (jednostka, której najbliżej do litery).

Przykładowo, słowo hindi “नमस्ते” zapisane w piśmie dewanagari jest 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]

Powyżej widzimy 18 bajtów i sposób w jaki komputery koniec końców przechowują te dane. Jeśli spojrzymy na te bajty jako wartości skalarne Unicode, które są rodzajem char Rusta, będą one wyglądać tak:

['न', 'म', 'स', '्', 'त', 'े']

Mamy tutaj sześć wartości char, ale czwarta i szósta nie są literami. Są znakami diakrytycznymi, które same w sobie nie mają sensu. Wreszcie, jeśli spojrzymy na nie jako na zbitki grafemów, otrzymamy coś co można by nazwać czterema literami, z których składa się słowo “नमस्ते”:

["न", "म", "स्", "ते"]

Rust pozwala na różne interpretacje surowych danych z łańcucha, które są przechowywane przez komputer. Dzięki temu, każdy program może wybrać interpretację, której potrzebuje bez względu na to w jakim ludzkim języku te dane są napisane.

Ostatnim powodem, dla którego Rust nie pozwala indeksować do String w celu uzyskania dostępu do znaku jest wymóg by operacje indeksowania (O(1)) zawsze zajmowały tyle samo czasu. W przypadku String nie można tego zagwarantować, ponieważ Rust musiałby przejść przez zawartość łańcucha od początku do indeksu by określić ilość poprawnych znaków w łańcuchu.

Cięcie Łańcuchów

Indeksowanie do łańcucha często jest złym pomysłem ponieważ nie wiadomo jaki powinien być rodzaj wyniku takiej operacji. Czy powinna to być wartość bajtu, znak, zbitek grafemów czy wycinek łańcucha? Dlatego jeśli naprawdę musisz użyć indeksów by stworzyć wycinki łańcucha, Rust prosi cię o konkrety.

Zamiast indeksować za pomocą [] z pojedynczą cyfrą, możesz użyć [] z zakresem by stworzyć wycinek łańcucha zawierający konkretne bajty:

#![allow(unused)]
fn main() {
let hello = "Здравствуйте";

let s = &hello[0..4];
}

Tutaj, s będzie &str, która zawiera pierwsze cztery bajty łańcucha. Wcześniej, wspomnieliśmy, że każda z tych liter waży 2 bajty, co oznacza, że s będzie równe Зд.

Jeśli mielibyśmy spróbować wyciąć tylko część bajtów litery za pomocą czegoś w rodzaju &hello[0..1], Rust spanikowałby w trakcie wykonywania programu. Zachowałby się tak samo gdybyś spróbował uzyskać dostęp do indeksu wykraczającego poza wektor:

$ cargo run
   Compiling collections v0.1.0 (file:///projects/collections)
    Finished dev [unoptimized + debuginfo] target(s) in 0.43s
     Running `target/debug/collections`
thread 'main' panicked at 'byte index 1 is not a char boundary; it is inside 'З' (bytes 0..2) of `Здравствуйте`', src/main.rs:4:14
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

Używaj zakresów do tworzenia wycinków łańcuchów ostrożnie, bo może to zakończyć się błędem.

Sposoby na Iterację po Łańcuchach

Na łańcuchach najlepiej operuje się jeśli dokładnie określimy czy wynikiem operacji mają być bajty czy znaki. Jeśli chcesz uzyskać wartości skalarne Unicode, zastosuj metodę chars. Wywołanie chars na “Зд” rozdziela i zwraca dwie wartości typu char. By uzyskać dostęp do każdego elementu, możesz iterować po wyniku:

#![allow(unused)]
fn main() {
for c in "Зд".chars() {
    println!("{c}");
}
}

Powyższy kod wyświetli:

З
д

Ewentualnie, bardziej może ci odpowiadać metoda bytes, która wyświetla każdy surowy bajt:

#![allow(unused)]
fn main() {
for b in "Зд".bytes() {
    println!("{b}");
}
}

Powyższy kod wyświetli cztery bajty, które znajdują się w tym łańcuchu:

208
151
208
180

Nie zapomnij, że poprawne wartości skalarne Unicode mogą składać się z więcej niż jednego bajta.

Proces otrzymania zbitka grafemów z łańcucha, jak przy piśmie dewanagari, jest złożony więc ta funkcjonalność nie jest dostępna w bibliotece standardowej. Jeżeli potrzebujesz tej funkcjonalności, na crates.io możesz znaleźć odpowiednie skrzynie.

Łańcuchy Nie Są Takie Proste

Podsumowując, łańcuchy są skomplikowane. Różne języki programowania wybierają różne sposoby na pokazanie tej złożoności programiście. Rust wybrał poprawne obchodzenie się z danymi ze String jako domyślne zachowanie wszystkich programów w nim napisanych. Oznacza to, że programiści muszą mieć z góry przemyślane co zrobią z danymi w UTF-8. Ten kompromis ujawnia więcej złożoności łańcuchów niż sposoby wybrane przez inne języki programowania, ale zapobiega konieczności radzenia sobie z błędami obejmującymi znaki inne niż ASCII na późniejszym etapie cyklu rozwoju oprogramowania.

Dobre wieści są takie, że biblioteka standardowa oferuje wiele funkcjonalności opartych na typach String i &str by pomóc ci poprawnie zająć się takimi złożonymi sytuacjami. Upewnij się, że przejrzałeś dokumentację i zapoznałeś się z takimi użytecznymi metodami jak contains, żeby przeszukać łańcuch i replace by zastąpić części łańcucha innym łańcuchem.

Przejdźmy teraz do czegoś nieco łatwiejszego: mapy z haszowaniem!