Зберігання тексту у кодуванні 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.

Додавання до 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`

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 `Здравствуйте`', library/core/src/str/mod.rs:127:5
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 на заміну частин стрічки на іншу стрічку.

Перейдемо на щось менш складне: хеш-відображення!