Хранение текста в кодировке UTF-8 с помощью строк
Мы уже говорили о строках в Главе 4, но пришло время рассмотреть их более подробно. Новички в Rust обычно застревают на строках из-за трёх причин: 1) пристрастие компилятора Rust к выявлению возможных ошибок, 2) строки сложнее, чем (как многим программистам кажется) могли бы быть, и 3) UTF-8. Эти факторы объединяются таким образом, что текущая тема может показаться сложной, если вы пришли из других языков программирования.
Мы говорим о строках в контексте коллекций, потому что строки в Rust — это список байтов плюс некоторые методы для работы с ними как с текстом. В этом разделе мы поговорим об операциях над 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 = "исходный текст"; let s = data.to_string(); // Этот метод также можно вызвать напрямую на литерале: let s = "исходный текст".to_string(); }
Этот код создаёт строку с текстом исходный текст
.
Мы также можем использовать функцию String::from
для создания String
из строкового литерала. Код Листинга 8-13 является эквивалентным коду из Листинга 8-12, использующему функцию to_string
:
fn main() { let s = String::from("исходный текст"); }
Поскольку строки используются для очень многих вещей, для них есть много инструментов, решающих одну и ту же задачу. Некоторые из них могут показаться избыточными, но у всех них есть свой смысл! В данном случае, 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: {s2}"); }
Если бы метод push_str
стал владельцем переменной s2
, мы не смогли бы напечатать его значение в последней строке. Однако этот код работает так, как мы ожидали!
Метод push
принимает один символ в качестве параметра и добавляет его к String
. В Листинге 8-17 показан код, добавляющий букву e к String
, используя метод push
.
fn main() { let mut s = String::from("not"); s.push('e'); }
В результате s
будет содержать note
.
Объединение строк с помощью оператора +
или макроса format!
Часто хочется объединить две существующие строки. Один из возможных способов — это использовать оператор +
, как показано в Листинге 8-18.
fn main() { let s1 = String::from("Hello, "); let s2 = String::from("world!"); let s3 = s1 + &s2; // обратите внимание, что s1 здесь была перемещена в метод // и далее не может быть использована }
Строка 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
, используя синтаксис индексации, то получите ошибку. Рассмотрим неверный код Листинга 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 `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}`, which is required by `String: Index<_>`
= 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 trait `SliceIndex<[_]>` is implemented for `usize`
= help: for that trait implementation, expected `[_]`, found `str`
= 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 байта. Каждая из этих букв занимает один байт при кодировании в 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"); }
Отвечая на вопрос, какова длина строки, вы можете ответить 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 просто не компилирует такой код и предотвращает недопонимание на ранних этапах процесса разработки.
Байты, символы Unicode и кластеры графем
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
), то байты предстанут перед нами в таком виде:
['न', 'म', 'स', '्', 'त', 'े']
Здесь есть шесть значений char
, но четвёртый и шестой являются не буквами: они — это диакритика, то есть специальные значки, которые не имеют смысла сами по себе. Наконец, если мы посмотрим на байты как на кластеры графем, то получим то, что человек назвал бы словом на хинди, состоящим из четырёх графем:
["न", "म", "स्", "ते"]
Rust предоставляет различные способы интерпретации необработанных (хранимых в удобном компьютеру виде) строковых данных, так, чтобы каждой программе можно было выбрать необходимую интерпретацию, независимо от того, на каком человеческом языке представлены эти данные.
Последняя причина, по которой Rust не позволяет нам обращаться к String
по индексу для получения символов, является то, что программисты ожидают, что операции индексирования всегда имеют постоянное время выполнения — O(1). Но невозможно гарантировать такую производительность для String
, потому что Rust понадобилось бы проходиться по содержимому от начала до индекса, чтобы определить, сколько было действительных символов.
Взятие срезов строк
Использование индексов со строками часто является плохой идеей, потому что не ясно, каким должен быть возвращаемый тип такой операции: байтом, символом, кластером графем или срезом строки. Поэтому Rust просит вас быть более конкретным, если действительно требуется использовать индексы для создания срезов строк.
Вместо индексации с помощью указания индекса в []
, вы можете использовать в []
оператор диапазона, чтобы создавать срез строки:
#![allow(unused)] fn main() { let hello = "Здравствуйте"; let s = &hello[0..4]; }
Здесь переменная s
будет иметь тип &str
и содержаит первые четыре байта строки. Ранее мы упоминали, что каждый из этих символов занимал по два байта, что означает, что 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}"); } }
Этот код выведет четыре байта, составляющих эту строку:
208
151
208
180
Но делая так, обязательно помните, что символы Unicode могут состоять более чем из одного байта.
Извлечение кластеров графем из строк, как в случае с письмом деванагари, является сложным, поэтому эта функциональность не предусмотрена стандартной библиотекой. На crates.io можно найти крейты с необходимыми вам инструментами.
Строки не так просты
Подводя итог, становится ясно, что строки сложны. Различные языки программирования реализуют строки по-своему, и все — по-своему сложно. В Rust решили сделать правильную обработку данных String
поведением по умолчанию для всех программ Rust, что означает, что программисты должны заранее продумать обработку текста в кодировке UTF-8. Этот компромисс раскрывает большую сложность строк, чем в других языках программирования, но это предотвращает от необходимости обрабатывать ошибки, связанные с не-ASCII символами, которые могут появиться позже в ходе разработки.
Хорошая новость состоит в том, что стандартная библиотека предлагает множество инструментов для типов String
и &str
, которые могут помочь правильно обрабатывать эти сложные ситуации. Обязательно ознакомьтесь с документацией, чтобы узнать о таких полезных методах, как contains
(для поиска в строке) и replace
(для замены частей строки другой строкой).
Давайте переключимся на что-то немного менее сложное: хеш-таблицы!