Хранение текста в кодировке UTF-8 с помощью строк
Мы уже говорили о строках в главе 4, но теперь рассмотрим их подробнее. Новички в Rust часто застревают на строках по трём причинам одновременно: склонность 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.
Создание новой строки
С String доступны многие из тех же операций, что и с Vec<T>, потому что
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: мы можем
добавлять только &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; выглядит так, как будто оно скопирует обе строки и
создаст новую, это утверждение вместо этого делает следующее:
addпринимает владениеs1,- добавляет копию содержимого
s2кs1, - а затем возвращает обратно владение
s1.
Если у s1 достаточно ёмкости для s2, то выделения памяти не происходит.
Однако, если у s1 недостаточно ёмкости для s2, то s1 внутренне сделает
большее выделение памяти, чтобы вместить обе строки.
Если нам нужно объединить несколько строк, поведение оператора + становится
неудобным:
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];
}
Этот код приведёт к следующей ошибке:
$ 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`
|
= 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<str>` is not implemented for `{integer}`
but 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. Однако следующая строка может вас удивить (обратите внимание,
что эта строка начинается с заглавной кириллической буквы Ze, а не с цифры 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-скаляра, которыми является
тип char в Rust, эти байты будут выглядеть так:
['न', 'म', 'स', '्', 'त', 'े']
Здесь шесть значений 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 для замены частей строки другой строкой.
Давайте перейдём к чему-то немного менее сложному: хэш-картам!