Зберігання UTF-8-кодованого тексту за допомогою String
Ми говорили про рядки в Розділі 4, але зараз розглянемо їх детальніше. Нові растацеанці (Rustaceans) часто застрягають на рядках з поєднання трьох причин: схильність Rust виявляти можливі помилки, рядки є складнішою структурою даних, ніж багато програмістів вважають, і UTF-8. Ці чинники поєднуються так, що це може здаватися складним, коли ви переходите з інших мов програмування.
Ми обговорюємо рядки в контексті колекцій, тому що рядки реалізовані як колекція байтів, плюс деякі методи, щоб забезпечити корисну функціональність, коли ці байти інтерпретуються як текст. У цьому розділі ми поговоримо про операції над String, які є в кожного типу колекції, такі як створення, оновлення та читання. Ми також обговоримо способи, у які String відрізняється від інших колекцій, а саме — як індексування в String ускладнюється відмінностями між тим, як люди та комп’ютери інтерпретують дані String.
Визначення рядків (Strings)
Спочатку ми визначимо, що мається на увазі під терміном string. Rust має лише один тип рядка в основній мові, а саме слайс рядка str, який зазвичай бачать у його запозиченій формі, &str. У розділі 4 ми говорили про слайси рядків, які є посиланнями на деякі UTF-8-кодовані дані рядка, що зберігаються в іншому місці. Рядкові літерали, наприклад, зберігаються в бінарному файлі програми і, отже, є слайсами рядка.
Тип String, який надається стандартною бібліотекою Rust, а не вбудований в основну мову, — це зростаючий, змінний, власний UTF-8-кодований тип рядка. Коли растацеанці (Rustaceans) кажуть про “strings” у Rust, вони можуть мати на увазі або тип String, або слайс рядка &str, а не лише один із цих типів. Хоча цей розділ здебільшого про String, обидва типи широко використовуються в стандартній бібліотеці Rust, і і String, і слайси рядка є UTF-8-кодованими.
Створення нового String
Багато з тих самих операцій, доступних з Vec<T>, також доступні з String, тому що String насправді реалізований як обгортка навколо вектора байтів із деякими додатковими гарантіями, обмеженнями та можливостями. Прикладом функції, яка працює так само з Vec<T> і String, є функція new для створення екземпляра, показана в Лістингу 8-11.
fn main() {
let mut s = String::new();
}
Цей рядок створює новий порожній рядок під назвою s, у який ми потім можемо завантажити дані. Часто в нас буде деякі початкові дані, з яких ми хочемо почати рядок. Для цього ми використовуємо метод to_string, який доступний для будь-якого типу, що реалізує трейт Display, як це роблять рядкові літерали. Лістинг 8-12 показує два приклади.
fn main() {
let data = "initial contents";
let s = data.to_string();
// The method also works on a literal directly:
let s = "initial contents".to_string();
}
Цей код створює рядок, що містить initial contents.
Ми також можемо використати функцію String::from, щоб створити String із рядкового літерала. Код у Лістингу 8-13 еквівалентний коду в Лістингу 8-12, який використовує to_string.
fn main() {
let s = String::from("initial contents");
}
Оскільки рядки використовуються для дуже багатьох речей, ми можемо використовувати багато різних узагальнених API для рядків, що надає нам багато варіантів. Деякі з них можуть здаватися надлишковими, але кожен має своє місце! У цьому випадку String::from і to_string роблять одне й те саме, тож який із них ви оберете — це питання стилю та читабельності.
Пам’ятайте, що рядки є UTF-8-кодованими, тож ми можемо включати в них будь-які правильно закодовані дані, як показано у Лістингу 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");
}
Усі ці значення є дійсними String.
Оновлення String
String може збільшуватися в розмірі, і його вміст може змінюватися, так само як вміст Vec<T>, якщо ви додаєте в нього більше даних. Крім того, ви можете зручно використовувати оператор + або макрос format!, щоб конкатенувати значення String.
Додавання за допомогою push_str або push
Ми можемо збільшувати String, використовуючи метод push_str для додавання слайса рядка, як показано у Лістингу 8-15.
fn main() {
let mut s = String::from("foo");
s.push_str("bar");
}
Після цих двох рядків s міститиме foobar. Метод push_str приймає слайс рядка, тому що ми не обов’язково хочемо брати володіння над параметром. Наприклад, у коді у Лістингу 8-16 ми хочемо мати змогу використовувати s2 після додавання його вмісту до s1.
fn main() {
let mut s1 = String::from("foo");
let s2 = "bar";
s1.push_str(s2);
println!("s2 is {s2}");
}
Якби метод push_str брав володіння над s2, ми не змогли б надрукувати його значення в останньому рядку. Однак цей код працює так, як ми й очікували!
Метод push приймає один символ як параметр і додає його до String. Лістинг 8-17 додає літеру l до String за допомогою методу push.
fn main() {
let mut s = String::from("lo");
s.push('l');
}
У результаті s міститиме lol.
Конкатенація за допомогою + або format!
Часто вам потрібно буде об’єднати два наявні рядки. Один зі способів зробити це — використати оператор +, як показано у Лістингу 8-18.
fn main() {
let s1 = String::from("Hello, ");
let s2 = String::from("world!");
let s3 = s1 + &s2; // note s1 has been moved here and can no longer be used
}
Рядок s3 міститиме Hello, world!. Причина, чому s1 більше не є дійсним після додавання, і причина, чому ми використали посилання на s2, пов’язані з сигнатурою методу, який викликається, коли ми використовуємо оператор +. Оператор + використовує метод add, сигнатура якого виглядає приблизно так:
fn add(self, s: &str) -> String {
У стандартній бібліотеці ви побачите, що add визначено з використанням узагальнених типів і асоційованих типів. Тут ми підставили конкретні типи, що і відбувається, коли ми викликаємо цей метод зі значеннями String. Ми обговоримо узагальнені типи в Розділі 10. Ця сигнатура дає нам підказки, потрібні для того, щоб зрозуміти складні моменти оператора +.
По-перше, s2 має &, що означає, що ми додаємо посилання на другий рядок до першого рядка. Це пов’язано з параметром s у функції add: ми можемо додати лише слайс рядка до String; ми не можемо додати два значення String разом. Але зачекайте — тип &s2 є &String, а не &str, як зазначено в другому параметрі add. Тож чому Лістингу 8-18 компілюється?
Причина, через яку ми можемо використати &s2 у виклику add, полягає в тому, що компілятор може перетворити аргумент &String на &str. Коли ми викликаємо метод add, Rust використовує приведення при розіменуванні (deref coercion), яке тут перетворює &s2 на &s2[..]. Ми обговоримо приведення при розіменуванні детальніше в Розділі 15. Оскільки add не бере володіння над параметром s, s2 усе ще буде дійсним String після цієї операції.
По-друге, ми можемо побачити в сигнатурі, що add бере володіння над self, тому що self не має &. Це означає, що s1 у Лістингу 8-18 буде переміщено в виклик add і більше не буде дійсним після цього. Отже, хоча let s3 = s1 + &s2; виглядає так, ніби воно скопіює обидва рядки та створить новий, цей оператор насправді бере володіння над s1, додає копію вмісту s2, а потім повертає володіння над результатом. Іншими словами, виглядає так, ніби він робить багато копій, але це не так; реалізація ефективніша за копіювання.
Якщо нам потрібно конкатенувати кілька рядків, поведінка оператора + стає громіздкою:
fn main() {
let s1 = String::from("tic");
let s2 = String::from("tac");
let s3 = String::from("toe");
let s = s1 + "-" + &s2 + "-" + &s3;
}
На цьому етапі s буде tic-tac-toe. З усіма символами + і " важко зрозуміти, що відбувається. Для об’єднання рядків більш складними способами ми натомість можемо використати макрос format!:
fn main() {
let s1 = String::from("tic");
let s2 = String::from("tac");
let s3 = String::from("toe");
let s = format!("{s1}-{s2}-{s3}");
}
Цей код також встановлює s у tic-tac-toe. Макрос format! працює як println!, але замість того, щоб друкувати вивід на екран, він повертає String із вмістом. Версія коду з використанням format! набагато легше читається, а код, згенерований макросом format!, використовує посилання, тож цей виклик не бере володіння над жодним зі своїх параметрів.
Індексування в рядках
У багатьох інших мовах програмування доступ до окремих символів у рядку шляхом посилання на них за індексом є дійсною і поширеною операцією. Однак якщо ви спробуєте отримати доступ до частин String за допомогою синтаксису індексування в Rust, ви отримаєте помилку. Розгляньте недійсний код у Лістингу 8-19.
fn main() {
let s1 = String::from("hi");
let h = s1[0];
}
Цей код призведе до такої помилки:
$ 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
Помилка розповідає історію: рядки Rust не підтримують індексування. Але чому? Щоб відповісти на це запитання, нам потрібно обговорити, як Rust зберігає рядки в пам’яті.
Внутрішнє представлення
String — це обгортка над Vec<u8>. Розгляньмо деякі наші правильно закодовані приклади UTF-8-рядків із Лістингу 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");
}
У цьому випадку len буде 4, що означає, що вектор, який зберігає рядок "Hola", має довжину 4 байти. Кожна з цих літер займає 1 байт у кодуванні UTF-8. Наступний рядок, однак, може вас здивувати (зверніть увагу, що цей рядок починається з великої кириличної літери (Ze) Зе, а не з цифри 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");
}
Якби вас запитали, якою є довжина рядка, ви могли б сказати 12. Насправді відповідь Rust — 24: це кількість байтів, потрібних, щоб закодувати “Здравствуйте” у UTF-8, тому що кожне скалярне значення Unicode в цьому рядку займає 2 байти пам’яті. Отже, індекс у байтах рядка не завжди відповідатиме дійсному скалярному значенню Unicode. Щоб продемонструвати це, розгляньте цей недійсний код Rust:
let hello = "Здравствуйте";
let answer = &hello[0];
Ви вже знаєте, що answer не буде З, першою літерою. У UTF-8 перший байт З — це 208, а другий — 151, тож може здаватися, що answer насправді має бути 208, але 208 не є дійсним символом сам по собі. Повернення 208 імовірно не було б тим, чого користувач хотів би, якби попросив першу літеру цього рядка; однак це єдині дані, які Rust має в байтовому індексі 0. Користувачі загалом не хочуть, щоб поверталося байтове значення, навіть якщо рядок містить лише латинські літери: якби &"hi"[0] був дійсним кодом, що повертає байтове значення, він повернув би 104, а не h.
Отже, відповідь така: щоб уникнути повернення неочікуваного значення та спричинення помилок, які можуть бути виявлені не одразу, Rust взагалі не компілює цей код і запобігає непорозумінням на ранньому етапі процесу розробки.
Байти, скалярні значення та графемні кластери
Ще один момент про UTF-8 полягає в тому, що насправді є три відповідні способи дивитися на рядки з точки зору Rust: як на байти, скалярні значення та графемні кластери (найближче до того, що ми називали б літерами).
Якщо ми подивимося на хіндійське слово “नमस्ते”, написане письмом деванагарі, воно зберігається як вектор значень u8, який виглядає так:
[224, 164, 168, 224, 164, 174, 224, 164, 184, 224, 165, 141, 224, 164, 164,
224, 165, 135]
Це 18 байтів, і саме так комп’ютери зрештою зберігають ці дані. Якщо ми подивимося на них як на скалярні значення Unicode, якими є значення типу char у Rust, ці байти виглядають так:
['न', 'म', 'स', '्', 'त', 'े']
Тут є шість значень char, але четверте і шосте — не літери: це діакритичні знаки, які самі по собі не мають сенсу. Нарешті, якщо ми подивимося на них як на графемні кластери, ми отримаємо те, що людина назвала б чотирма літерами, з яких складається хіндійське слово:
["न", "म", "स्", "ते"]
Rust надає різні способи інтерпретації сирих рядкових даних, які зберігають комп’ютери, щоб кожна програма могла вибрати інтерпретацію, яка їй потрібна, незалежно від того, якою людською мовою є ці дані.
Остання причина, чому Rust не дозволяє нам індексувати в String, щоб отримати символ, полягає в тому, що операції індексування очікуються такими, що завжди займають константний час (O(1)). Але з String неможливо гарантувати таку продуктивність, тому що Rust довелося б пройтися по вмісту від початку до індексу, щоб визначити, скільки було дійсних символів.
Зрізи рядків (String Slicing)
Індексування в рядку часто є поганою ідеєю, тому що незрозуміло, яким має бути тип повернення операції індексування рядка: байтове значення, символ, графемний кластер чи слайс рядка. Тому, якщо вам справді потрібно використовувати індекси для створення слайсів рядка, Rust просить вас бути точнішими.
Замість індексування за допомогою [] з одним числом, ви можете використовувати [] з діапазоном, щоб створити слайс рядка, що містить певні байти:
#![allow(unused)]
fn main() {
let hello = "Здравствуйте";
let s = &hello[0..4];
}
Тут s буде &str, що містить перші 4 байти рядка. Раніше ми згадували, що кожен із цих символів мав 2 байти, а це означає, що s буде Зд.
Якби ми спробували зробити слайс лише частини байтів символу за допомогою чогось на кшталт &hello[0..1], Rust би згенерував паніку під час виконання так само, як це було б при доступі до недійсного індексу у векторі:
$ 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
Ви повинні бути обережні, коли створюєте слайси рядка за допомогою діапазонів, тому що це може призвести до аварійного завершення вашої програми.
Ітерування по рядках
Найкращий спосіб працювати з частинами рядків — чітко визначити, чи ви хочете символи, чи байти. Для окремих скалярних значень Unicode використовуйте метод chars. Виклик chars для “Зд” розділяє та повертає два значення типу char, і ви можете ітерувати по результату, щоб отримати доступ до кожного елемента:
#![allow(unused)]
fn main() {
for c in "Зд".chars() {
println!("{c}");
}
}
Цей код надрукує таке:
З
д
Альтернативно, метод bytes повертає кожен сирий байт, що може бути доречним для вашої предметної області:
#![allow(unused)]
fn main() {
for b in "Зд".bytes() {
println!("{b}");
}
}
Цей код надрукує 4 байти, з яких складається цей рядок:
208
151
208
180
Але не забудьте, що дійсні скалярні значення Unicode можуть складатися більш ніж з 1 байта.
Отримання графемних кластерів із рядків, як у письмі деванагарі, є складним, тому ця функціональність не надається стандартною бібліотекою. Крейтів доступно на crates.io, якщо це потрібна вам функціональність.
Обробка складнощів рядків
Підсумовуючи, рядки складні. Різні мови програмування роблять різний вибір щодо того, як показувати цю складність програмісту. Rust обрав зробити правильну обробку даних String поведінкою за замовчуванням для всіх програм Rust, що означає, що програмістам доводиться заздалегідь більше думати про обробку UTF-8-даних. Цей компроміс виявляє більше складності рядків, ніж це видно в інших мовах програмування, але він позбавляє вас необхідності пізніше у вашому життєвому циклі розробки обробляти помилки, пов’язані з не-ASCII-символами.
Добра новина полягає в тому, що стандартна бібліотека пропонує багато функціональності, побудованої на типах String і &str, щоб правильно допомагати обробляти такі складні ситуації. Обов’язково перегляньте документацію для корисних методів, таких як contains для пошуку в рядку та replace для заміни частин рядка іншим рядком.
Давайте перейдемо до чогось трохи менш складного: хеш-мапи!