Зберігання Тексту у Кодуванні 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(); }
Цей рядок створює нову порожню стрічку, що називається 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(); }
Цей код створює стрічку, що містить початковий вміст
.
Також ми можемо скористатися функцією 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
може зростати і її вміст може змінюватися, як вміст 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
: ми можемо лише додати &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];
}
Цей код призведе до наступної помилки:
$ 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
на заміну частин стрічки на іншу стрічку.
Перейдемо до чогось менш складного: хеш-мапи!