Хранение текста в кодировке UTF-8 с помощью строк
Мы уже говорили о строках в Главе 4, но пришло время рассмотреть их более подробно. Новички в Rust обычно застревают на строках из-за трёх причин: 1) пристрастие компилятора Rust к выявлению возможных ошибок, 2) строки сложнее, чем (как многим программистам кажется) могли бы быть, и 3) UTF-8. Эти факторы объединяются таким образом, что текущая тема может показаться сложной, если вы пришли из других языков программирования.
We discuss strings in the context of collections because strings are implemented as a collection of bytes, plus some methods to provide useful functionality when those bytes are interpreted as text. In this section, we’ll talk about the operations on String that every collection type has, such as creating, updating, and reading. We’ll also discuss the ways in which String is different from the other collections, namely, how indexing into a String is complicated by the differences between how people and computers interpret String data.
Defining Strings
We’ll first define what we mean by the term string. Rust has only one string type in the core language, which is the string slice str that is usually seen in its borrowed form, &str. In Chapter 4, we talked about string slices, which are references to some UTF-8 encoded string data stored elsewhere. String literals, for example, are stored in the program’s binary and are therefore string slices.
Тип 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();
}
StringЭта строка создаёт новую пустую строку s, в которую мы можем затем загрузить данные. Часто у нас есть некоторые начальные данные, которые мы хотим назначить строке. Для этого мы используем метод to_string доступный для любого типа, который реализует трейт Display, как у строковых литералов. Листинг 8-12 показывает эти два способа.
fn main() {
let data = "исходный текст";
let s = data.to_string();
// Этот метод также можно вызвать напрямую на литерале:
let s = "исходный текст".to_string();
}
to_string method to create a String from a string literalЭтот код создаёт строку с текстом исходный текст.
Мы также можем использовать функцию String::from для создания String из строкового литерала. Код Листинга 8-13 является эквивалентным коду из Листинга 8-12, использующему функцию to_string:
fn main() {
let s = String::from("исходный текст");
}
String::from function to create a String from a string literalПоскольку строки используются для очень многих вещей, для них есть много инструментов, решающих одну и ту же задачу. Некоторые из них могут показаться избыточными, но у всех них есть свой смысл! В данном случае, 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 в одну.
Appending with push_str or push
Мы можем нарастить String, используя метод push_str который добавит в исходное значение новый строковый срез, как показано в Листинге 8-15.
fn main() {
let mut s = String::from("foo");
s.push_str("bar");
}
String using the push_str methodПосле этих двух строк кода 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: {s2}");
}
StringЕсли бы метод push_str стал владельцем переменной s2, мы не смогли бы напечатать его значение в последней строке. Однако этот код работает так, как мы ожидали!
Метод push принимает один символ в качестве параметра и добавляет его к String. В Листинге 8-17 показан код, добавляющий букву e к String, используя метод push.
fn main() {
let mut s = String::from("not");
s.push('e');
}
String value using pushВ результате s будет содержать note.
Concatenating with + or format!
Часто хочется объединить две существующие строки. Один из возможных способов — это использовать оператор +, как показано в Листинге 8-18.
fn main() {
let s1 = String::from("Hello, ");
let s2 = String::from("world!");
let s3 = s1 + &s2; // обратите внимание, что s1 здесь была перемещена в метод
// и далее не может быть использована
}
+ operator to combine two String values into a new String valueСтрока s3 будет содержать Hello, world!. Причина того, что s1 после добавления больше не действительна, и причина, по которой мы использовали ссылку на s2, имеют отношение к сигнатуре метода, вызываемого при использовании оператора +. Оператор + использует метод add, чья сигнатура выглядит примерно так:
fn add(self, s: &str) -> String {
В стандартной библиотеке вы увидите метод add определённым с использованием обобщённых и связанных типов. Здесь мы привели вам сигнатуру, подставив конкретные типы, заменяющие обобщённый; это и происходит, когда данный метод вызывается со значениями String. Мы обсудим обобщённые типы в Главе 10. Эта сигнатура даёт нам ключ к пониманию особенностей оператора +.
First, s2 has an &, meaning that we’re adding a reference of the second string to the first string. This is because of the s parameter in the add function: We can only add a string slice to a String; we can’t add two String values together. But wait—the type of &s2 is &String, not &str, as specified in the second parameter to add. So, why does Listing 8-18 compile?
The reason we’re able to use &s2 in the call to add is that the compiler can coerce the &String argument into a &str. When we call the add method, Rust uses a deref coercion, which here turns &s2 into &s2[..]. We’ll discuss deref coercion in more depth in Chapter 15. Because add does not take ownership of the s parameter, s2 will still be a valid String after this operation.
Во-вторых, как можно видеть в сигнатуре, 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, используя синтаксис индексации, то получите ошибку. Рассмотрим неверный код Листинга 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
The error tells the story: Rust strings don’t support indexing. But why not? To answer that question, we need to discuss how Rust stores strings in memory.
Внутреннее устройство
Тип 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");
}
In this case, len will be 4, which means the vector storing the string "Hola" is 4 bytes long. Each of these letters takes 1 byte when encoded in UTF-8. The following line, however, may surprise you (note that this string begins with the capital Cyrillic letter Ze, not the number 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");
}
If you were asked how long the string is, you might say 12. In fact, Rust’s answer is 24: That’s the number of bytes it takes to encode “Здравствуйте” in UTF-8, because each Unicode scalar value in that string takes 2 bytes of storage. Therefore, an index into the string’s bytes will not always correlate to a valid Unicode scalar value. To demonstrate, consider this invalid Rust code:
let hello = "Здравствуйте";
let answer = &hello[0];
You already know that answer will not be З, the first letter. When encoded in UTF-8, the first byte of З is 208 and the second is 151, so it would seem that answer should in fact be 208, but 208 is not a valid character on its own. Returning 208 is likely not what a user would want if they asked for the first letter of this string; however, that’s the only data that Rust has at byte index 0. Users generally don’t want the byte value returned, even if the string contains only Latin letters: If &"hi"[0] were valid code that returned the byte value, it would return 104, not h.
Таким образом, чтобы предотвратить возврат непредвиденного значения, вызывающего ошибки, которые не могут быть сразу обнаружены, Rust просто не компилирует такой код и предотвращает недопонимание на ранних этапах процесса разработки.
Bytes, Scalar Values, and Grapheme Clusters
Another point about UTF-8 is that there are actually three relevant ways to look at strings from Rust’s perspective: as bytes, scalar values, and grapheme clusters (the closest thing to what we would call letters).
Если посмотреть на слово “नमस्ते” языка хинди, написанное письмом деванагари, то оно хранится как вектор значений u8, который выглядит следующим образом:
[224, 164, 168, 224, 164, 174, 224, 164, 184, 224, 165, 141, 224, 164, 164,
224, 165, 135]
Эти 18 байт являются именно тем, как компьютеры в конечном итоге сохранят в памяти эту строку. Если мы посмотрим на это слово как на символы Unicode (которыми и является тип char), то байты предстанут перед нами в таком виде:
['न', 'म', 'स', '्', 'त', 'े']
There are six char values here, but the fourth and sixth are not letters: They’re diacritics that don’t make sense on their own. Finally, if we look at them as grapheme clusters, we’d get what a person would call the four letters that make up the Hindi word:
["न", "म", "स्", "ते"]
Rust предоставляет различные способы интерпретации необработанных (хранимых в удобном компьютеру виде) строковых данных, так, чтобы каждой программе можно было выбрать необходимую интерпретацию, независимо от того, на каком человеческом языке представлены эти данные.
Последняя причина, по которой Rust не позволяет нам обращаться к String по индексу для получения символов, является то, что программисты ожидают, что операции индексирования всегда имеют постоянное время выполнения — O(1). Но невозможно гарантировать такую производительность для String, потому что Rust понадобилось бы проходиться по содержимому от начала до индекса, чтобы определить, сколько было действительных символов.
Взятие срезов строк
Использование индексов со строками часто является плохой идеей, потому что не ясно, каким должен быть возвращаемый тип такой операции: байтом, символом, кластером графем или срезом строки. Поэтому Rust просит вас быть более конкретным, если действительно требуется использовать индексы для создания срезов строк.
Вместо индексации с помощью указания индекса в [], вы можете использовать в [] оператор диапазона, чтобы создавать срез строки:
#![allow(unused)]
fn main() {
let hello = "Здравствуйте";
let s = &hello[0..4];
}
Here, s will be a &str that contains the first 4 bytes of the string. Earlier, we mentioned that each of these characters was 2 bytes, which means s will be Зд.
Что бы произошло, если бы мы использовали &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
Вы должны использовать диапазоны для создания срезов строк с осторожностью, потому что это может привести к сбою вашей программы.
Iterating Over Strings
Лучший способ работать с фрагментами строк — чётко указать, нужны ли вам символы или байты. Для получения символов 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}");
}
}
This code will print the 4 bytes that make up this string:
208
151
208
180
But be sure to remember that valid Unicode scalar values may be made up of more than 1 byte.
Извлечение кластеров графем из строк, как в случае с письмом деванагари, является сложным, поэтому эта функциональность не предусмотрена стандартной библиотекой. На crates.io можно найти крейты с необходимыми вам инструментами.
Handling the Complexities of Strings
Подводя итог, становится ясно, что строки сложны. Различные языки программирования реализуют строки по-своему, и все — по-своему сложно. В Rust решили сделать правильную обработку данных String поведением по умолчанию для всех программ Rust, что означает, что программисты должны заранее продумать обработку текста в кодировке UTF-8. Этот компромисс раскрывает большую сложность строк, чем в других языках программирования, но это предотвращает от необходимости обрабатывать ошибки, связанные с не-ASCII символами, которые могут появиться позже в ходе разработки.
Хорошая новость состоит в том, что стандартная библиотека предлагает множество инструментов для типов String и &str, которые могут помочь правильно обрабатывать эти сложные ситуации. Обязательно ознакомьтесь с документацией, чтобы узнать о таких полезных методах, как contains (для поиска в строке) и replace (для замены частей строки другой строкой).
Давайте переключимся на что-то немного менее сложное: хеш-таблицы!