Зберігання Тексту у Кодуванні UTF-8 в Стрічках
Ми говорили про стрічки в Розділі 4, але тепер ми розглянемо їх глибше. Нові растацеанці зазвичай застрягають на стрічках через поєднання трьох причин: схильність Rust до виявлення можливих помилок, стрічки є складнішою структурою даних, ніж вважають багато програмістів, та UTF-8. Ці фактори комбінуються таким чином, що може здатися вам складним, коли ви переходите з інших мов програмування.
Ми обговорюємо стрічки в контексті колекцій, оскільки стрічки реалізовані як колекція байтів, плюс деякі методи для надання корисної функціональності, коли ці байти розглядаються як текст. У цьому підрозділі ми поговоримо про операції на String, які має кожен тип колекцій, такі як створення, змінна та читання. Ми також обговоримо, чим String відрізняється від інших колекцій, а саме чому індексування String ускладнене відмінністю між тим, як люди і комп'ютери розглядають дані у String.
Що Таке Стрічка?
Спочатку ми визначимо те, що ми маємо на увазі під терміном стрічка. Rust має лише один стрічковий тип у ядрі мови, а саме стрічковий слайс str, який зазвичай буває у запозиченій формі &str. У Розділі 4 ми говорили про стрічкові слайси, які є посиланням на деякі дані, закодовані в UTF-8, які зберігаються деінде. Наприклад, стрічкові літерали зберігаються в двійковому файлі програми і, отже, є стрічковими слайсами.
Тип String, який надається стандартною бібліотекою Rust, а не закодовано в ядро мови, може зростати, бути мутабельним, володіє своїми даними і кодований в UTF-8. Коли растцеанці посилається на "стрічки" в Rust, то можуть посилатись або на тип String, або на стрічковий слайс &str, а не лише один із цих типів. Хоча цей розділ багато в чому стосується String, обидва типи щедро використовуються в стандартній бібліотеці Rust, і як String, так і стрічкові слайси мають кодування UTF-8.
Створення Нової Стрічки
Багато операцій, доступних для Vec<T>, також доступні для String, тому що String фактично реалізований як обгортка навколо вектора байтів з деякими додатковими гарантіями, обмеженнями і можливостями. Приклад функції, яка працює однаково як і з Vec<T> і String, це функція new, що створює екземпляр, як показано в Блоці коду 8-11.
fn main() { let mut s = String::new(); }
Блок коду 8-11: Створення нової порожньої String
Цей рядок створює нову порожню стрічку, що називається 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(); }
Блок коду 8-12: використання методу to_string для створення String зі стрічкового літерала
Цей код створює стрічку, що містить початковий вміст.
Також ми можемо скористатися функцією String::from, щоб створити String зі стрічкового літерала. Код у Блоці коду 8-13 еквівалентний коду зі Блоку коду 8-12, який використовує to_string.
fn main() { let s = String::from("initial contents"); }
Блок коду 8-13: використання функції String::from для створення String зі стрічкового літерала
Оскільки стрічки використовуються для великої кількості речей, ми можемо використати багато різних узагальнених 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"); }
Блок коду 8-14: збереження вітань різними мовами у стрічках
Усе це коректні значення 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"); }
Блок коду 8-15: додавання стрічкового слайсу до String за допомогою методу push_str
Після цих двох рядків 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}"); }
Блок коду 8-16: використання стрічкового слайсу після додавання його вмісту до String
Якби метод push_str взяв володіння s2, ми б не змогли вивести його значення в останньому рядку. Але цей код працює, як ми очікуємо!
Метод push приймає параметром один символ і додає його до String. Блок коду 8-17 додає букву "l" до String за допомогою методу push.
fn main() { let mut s = String::from("lo"); s.push('l'); }
Блок коду 8-17: Додавання одного символу до значення String за допомогою push
У результаті, 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 }
Блок коду 8-18: використання оператора + для об'єднання двох значень String у нову String
Стрічка s3 міститиме Hello, world!. Причина, чому s1 більше не дійсна після додавання, і причина, чому ми використовували посилання на s2, стосується сигнатури методу, викликаного, коли ми скористалися оператором +. Оператор + використовує метод add, сигнатура якого виглядає приблизно так:
fn add(self, s: &str) -> String {
У стандартній бібліотеці ви побачите add, визначений за допомогою узагальнених і асоційованих типів. Тут ми підставили конкретні типи, що й стається, коли ми викликаємо цей метод зі значеннями String. Узагальнені типи ми обговоримо у Розділі 10. Ця сигнатура дає нам підказки, потрібні для розуміння тонких місць оператора +.
По-перше, s2 має &, що означає, що ми додаємо посилання на другу стрічку до першої стрічки. Так зроблено через параметр s у функції add: ми можемо лише додати &str до String; ми не можемо скласти разом два значення String. Але чекайте — типом &s2 є &String, а не &str, як зазначено в другому параметрі add. То чому ж Блок коду 8-18 компілюється?
Причина, з якої ми можемо використовувати &s2 у виклику add полягає в тому, що компілятор може привести аргумент &String до &str. Коли ми викликаємо метод add, Rust використовує приведення розіменування, що тут перетворює &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("hello");
let h = s1[0];
}
Блок коду 8-19: спроба використати синтаксис індексів для String
Цей код призведе до наступної помилки:
$ 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>>>
<str as Index<I>>
For more information about this error, try `rustc --explain E0277`.
error: could not compile `collections` due to 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. Однак наступний рядок може вас здивувати.
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"); }
Якщо вас спитати, якої довжини стрічка, ви можете сказати 6. Насправді Rust відповість, що 12 - це число байтів, потрібних, щоб закодувати "Привіт" у UTF-8, оскільки кожен скалярне значення Unicode у цій стрічці займає 2 байти пам'яті. Таким чином, індекс по байтах стрічки не завжди буде співвідноситися з припустимим скалярним значенням Unicode. Для демонстрації, розгляньмо цей некоректний код Rust:
let hello = "Привіт";
let answer = &hello[0];
Ви вже знаєте, що answer не буде П, першою літерою. У кодуванні UTF-8 перший байт П буде 208, а другий 159, тож здається, що answer має бути 208, але 208 сам по собі не є допустимим символом. Швидше за все, користувач, що запитує першу літеру у стрічці, не хоче отримати 208; однак за індексом 0 Rust має лише ці дані. Користувачі, як правило, не хочуть отримувати значення байтів, навіть якщо стрічка містить лише латинські літери: якби &"hello"[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 повинен проглянути вміст з початку до індексу, щоб визначити, скільки там було валідних символів.
Створення Стрічкових Слайсів
Індексація стрічки часто є поганою ідеєю, бо не зрозуміло, яке значення має повертати операція індексування: байт, символ, кластер графем чи стріковий слайс. Тому, якщо вам дійсно треба використати індекси для створення стрічкових слайсів, 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 [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
Ви маєте використовувати діапазони для створення стрічкових слайсів з обережністю, тому що так може зламати програму.
Методи для Ітерації над Стрічками
Найкращий спосіб оперувати фрагментами стрічкок - це чітко визначити, потрібні вам символи чи байти. Для роботи з окремими скалярними значеннями 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}"); } }
Цей код виведе чотири байти, з яких складається ця стрічка:
208
159
209
128
Але не забувайте, що припустимі скалярні значення Unicode можуть бути складені з більш ніж 1 байту.
Отримання кластерів графем зі стрічок, як у письмі деванагарі, є складним, тому ця функціональність не надається стандартною бібліотекою. На crates.io є відповідні крейти, якщо ви потребуєте такого функціонала.
Стрічки Не Такі Прості
Підсумуємо: стрічки є складними. Різні мови програмування роблять різні вибори стосовно того, як представити цю складність програмісту. Rust обрав коректну обробку даних у String поведінкою за замовчуванням для всіх програм Rust, що означає, що програмісти повинні краще продумувати заздалегідь обробку даних UTF-8. Цей компроміс розкриває більшу частину складності стрічок, ніж зазвичай в інших мовах програмування, але це убезпечує вас від помилок із символами поза межами ASCII пізніше у життєвому циклі розробки.
Доброю новиною є те, що стандартна бібліотека пропонує велику кількість функціонала, побудованого на типах String і &str, щоб допомогти правильно впоратися з цими складними ситуаціями. Обов'язково перевірте документацію про корисні методи, такі як contains для пошуку в стрічці і replace на заміну частин стрічки на іншу стрічку.
Перейдемо до чогось менш складного: хеш-мапи!