Строки: текст в UTF-8
О строках мы уже говорили в главе 4, а теперь разберём их подробнее. Новички в Rust часто спотыкаются о строки по трём причинам сразу: Rust любит показывать возможные ошибки; строки — более сложная структура данных, чем считают многие программисты; и наконец, UTF-8. Вместе это может показаться трудным, если вы пришли из других языков.
Мы обсуждаем строки в контексте коллекций, потому что строка реализована как
коллекция байтов плюс набор методов, которые полезны, когда эти байты
интерпретируются как текст. В этом разделе мы поговорим об операциях над
String, которые есть у любой коллекции: создании, изменении и чтении. А ещё
обсудим, чем String отличается от других коллекций: почему к ней нельзя
просто обратиться по индексу. Всё дело в том, что люди и компьютеры
по-разному понимают, что лежит в строке.
Что такое строка
Сначала определимся, что мы называем строкой. В самом ядре языка Rust есть
только один строковый тип — строковый срез str, который обычно встречается
в заимствованной форме, &str. В главе 4 мы говорили о строковых срезах:
это ссылки на данные строки в кодировке UTF-8, хранящиеся где-то ещё.
Например, строковые литералы хранятся в бинарном файле программы и поэтому
являются строковыми срезами.
Тип String предоставляется стандартной библиотекой, а не встроен в ядро
языка. Это растущий, изменяемый строковый тип в кодировке 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 = "initial contents";
let s = data.to_string();
// Метод можно вызвать и прямо у литерала:
let s = "initial contents".to_string();
}
String из строкового литерала методом to_stringЭтот код создаёт строку с содержимым initial contents.
Создать String из строкового литерала можно и функцией String::from. Код в
листинге 8-13 равнозначен коду из листинга 8-12 с to_string.
fn main() {
let s = String::from("initial contents");
}
String из строкового литерала функцией String::fromСтроки используются для очень многих задач, поэтому для них есть множество
разных обобщённых 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 мы добавляем в String букву l методом 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; // обратите внимание: s1 перемещена сюда, и использовать её больше нельзя
}
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[..]. Подробнее о неявном разыменовании мы поговорим в главе 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!, использует ссылки, так что этот вызов не
забирает владение ни одним из своих параметров.
Обращение к строке по индексу
Во многих языках программирования обращение к отдельным символам строки по
индексу — нормальная и распространённая операция. Но если в Rust попытаться
обратиться к части 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: `usize` implements trait `SliceIndex<T>`
--> /rustc/88d9e12ae178fab0fb5cc050a94da85685d449ea/library/core/src/slice/index.rs:179:0
|
= note: `SliceIndex<[T]>`
--> /rustc/88d9e12ae178fab0fb5cc050a94da85685d449ea/library/core/src/bstr/traits.rs:197:0
|
= note: `SliceIndex<ByteStr>`
= 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 занимает 1 байт. А вот следующая строка
может вас удивить:
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: каждое скалярное значение Юникода в этой строке занимает 2 байта. Поэтому индекс байта в строке не всегда соответствует целому скалярному значению Юникода. Чтобы это показать, рассмотрим такой некорректный код на Rust:
let hello = "Здравствуйте";
let answer = &hello[0];
Вы уже знаете, что answer не будет равно З, первой букве. В UTF-8 первый
байт З — это 208, а второй — 151. Казалось бы, тогда answer должно
быть 208, но 208 сам по себе — не символ. Пользователь, который попросил
первую букву строки, вряд ли хотел получить 208, но других данных по
индексу 0 у Rust нет. Обычно пользователям не нужно значение байта, даже если
в строке только латиница: если бы &"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 байт — именно так компьютер в конечном счёте хранит эти данные. Если
смотреть на них как на скалярные значения Юникода, то есть на значения типа
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` profile [unoptimized + debuginfo] target(s) in 0.43s
Running `target/debug/collections`
thread 'main' (6017738) panicked at src/main.rs:4:19:
end byte index 1 is not a char boundary; it is inside 'З' (bytes 0..2 of string)
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
Создавая строковые срезы по диапазонам, будьте осторожны: это может уронить программу.
Перебор строк
Работать с частями строк лучше всего, явно указывая, что вам нужно — символы
или байты. Для отдельных скалярных значений Юникода есть метод 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
Но обязательно помните, что корректное скалярное значение Юникода может состоять из нескольких байтов.
Получить из строки графемные кластеры, как в случае с деванагари, — сложная задача, поэтому стандартная библиотека этого не умеет. Если вам нужна такая функциональность, поищите подходящий крейт на crates.io.
Строки — это непросто
Подведём итог: строки — штука сложная. Разные языки программирования
по-разному решают, как показать эту сложность программисту. Rust выбрал путь,
при котором корректная обработка данных String — это поведение по умолчанию
во всех программах на Rust. Значит, программистам приходится заранее
продумывать работу с UTF-8. Из-за этого сложность строк в Rust заметнее, чем в
других языках, зато вам не придётся потом, на поздних этапах разработки,
разбираться с ошибками из-за не-ASCII-символов.
Хорошая новость: в стандартной библиотеке много функциональности на основе
типов String и &str, которая помогает правильно справляться с такими
сложными ситуациями. Обязательно загляните в документацию — там есть полезные
методы вроде contains для поиска в строке и replace для замены частей
строки другой строкой.
А теперь перейдём к чему-нибудь чуть попроще — к хеш-таблицам!