Хранение текста в кодировке UTF-8 с помощью строк
Мы говорили о строках в главе 4, но теперь рассмотрим их подробнее. Новички в Rust часто застревают на строках из-за сочетания трех причин: склонности Rust показывать возможные ошибки, того факта, что строки являются более сложной структурой данных, чем многие программисты привыкли считать, и UTF-8. Эти факторы соединяются так, что при переходе с других языков программирования строки могут казаться трудными.
Мы обсуждаем строки в контексте коллекций, потому что строки реализованы как
коллекция байтов плюс набор методов, которые предоставляют полезную
функциональность, когда эти байты интерпретируются как текст. В этом разделе
мы поговорим об операциях над String, которые есть у каждого типа коллекции,
таких как создание, обновление и чтение. Также мы обсудим, чем String
отличается от других коллекций, а именно почему индексация в String
усложняется различиями между тем, как люди и компьютеры интерпретируют данные
String.
Определение строк
Сначала определим, что мы имеем в виду под термином строка. В самом языке
Rust есть только один строковый тип: строковый срез str, который обычно
встречается в заимствованной форме, &str. В главе 4 мы говорили о строковых
срезах, которые являются ссылками на данные строки в кодировке UTF-8,
хранящиеся где-то еще. Например, строковые литералы хранятся в бинарном файле
программы и поэтому являются строковыми срезами.
Тип String, предоставляемый стандартной библиотекой Rust, а не встроенный в
сам язык, является строковым типом с владением, кодировкой UTF-8, изменяемым и
способным расти. Когда разработчики Rust говорят о «строках» в 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();
}
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();
}
to_string для создания String из строкового литералаЭтот код создает строку, содержащую initial contents.
Мы также можем использовать функцию String::from, чтобы создать String из
строкового литерала. Код в листинге 8-13 эквивалентен коду из листинга 8-12,
который использует to_string.
fn main() {
let s = String::from("initial contents");
}
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");
}
Все эти значения являются допустимыми значениями String.
Обновление строки
String может увеличиваться в размере, а ее содержимое может изменяться, как
и содержимое Vec<T>, если добавить в нее больше данных. Кроме того, для
конкатенации значений String можно удобно использовать оператор + или
макрос format!.
Добавление с помощью push_str или push
Мы можем увеличить String, используя метод push_str для добавления
строкового среза, как показано в листинге 8-15.
fn main() {
let mut s = String::from("foo");
s.push_str("bar");
}
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}");
}
StringЕсли бы метод push_str получал владение s2, мы не смогли бы вывести ее
значение в последней строке. Однако этот код работает так, как мы ожидаем!
Метод push принимает один символ как параметр и добавляет его к String.
Листинг 8-17 добавляет букву l к String с помощью метода push.
fn main() {
let mut s = String::from("lo");
s.push('l');
}
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
}
+ для объединения двух значений String в новое значение StringСтрока 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[..]. Мы
подробнее обсудим deref coercion в главе 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];
}
StringЭтот код приведет к следующей ошибке:
$ 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. Однако следующая строка может вас удивить (обратите
внимание, что эта строка начинается с заглавной кириллической буквы Зе, а не
с цифры 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, то есть как на то, чем
является тип Rust char, эти байты выглядят так:
['न', 'म', 'स', '्', 'त', 'े']
Здесь шесть значений 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` 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 для замены частей строки другой строкой.
Переключимся на что-то немного менее сложное: хеш-карты!