Сохранение текста с UTF-8 кодировкой в строках

Мы говорили о строках в главе 4, но сейчас мы рассмотрим их более подробно. Новички в Rust обычно застревают на строках из-за комбинации трёх причин: склонность Rust компилятора к выявлению возможных ошибок, более сложная структура данных чем считают многие программисты и UTF-8. Эти факторы объединяются таким образом, что тема может показаться сложной, если вы пришли из других языков программирования.

Полезно обсуждать строки в контексте коллекций, потому что строки реализованы в виде набора байтов, плюс некоторые методы для обеспечения полезной функциональности, когда эти байты интерпретируются как текст. В этом разделе мы поговорим об операциях над String, которые есть у каждого типа коллекций такие как создание, обновление и чтение. Мы также обсудим какими особенностями String отличается от других коллекций, а именно, как индексирование в String осложняется различием между тем, как люди и компьютеры интерпретируют данные заключённые в String.

Что же такое строка?

Сначала мы определим, что мы подразумеваем под термином строка (string). В Rust есть только один строковый тип в ядре языка - срез строки str, обычно используемый в заимствованном виде как &str. В Главе 4 мы говорили о срезах строк, string slices, которые являются ссылками на некоторые строковые данные в кодировке UTF-8. Например, строковые литералы хранятся в двоичном файле программы и поэтому являются срезами строк.

Тип String предоставляемый стандартной библиотекой Rust, не встроен в ядро языка и является расширяемым, изменяемым, владеющим, строковым типом в UTF-8 кодировке. Когда Rust разработчики говорят о "строках" то, они обычно имеют ввиду типы String и строковые срезы &str, а не просто один из них. Хотя этот раздел в основном посвящён String, оба типа интенсивно используются в стандартной библиотеке Rust, оба, и String, и строковые срезы, кодируются в UTF-8.

Стандартная библиотека Rust также включает ряд других строковых типов, таких как OsString, OsStr, CString и CStr. Библиотечные крейты могут предоставить даже большее количество возможностей для хранения строковых данных. Видите, как все имена этих типов заканчиваются на String или Str? Они относятся к собственным и заимствованным вариантам, так же как типы String и str которые вы видели ранее. Эти типы строк могут хранить текст в различных кодировках или, например, быть по-другому представлены в памяти. Мы не будем обсуждать эти другие типы строк в данной главе; посмотрите документацию API для получения дополнительной информации о том как их использовать и когда каждый тип уместен.

Создание новых строк

Многие из тех же операций, которые доступны Vec<T>, доступны также в String, начиная с new функции для создания строки, показанной в листинге 8-11.

fn main() {
    let mut s = String::new();
}

Листинг 8-11. Создание новой пустой 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();
}

Листинг 8-12. Использование метода to_string для создания экземпляра типа String из строкового литерала

Эти выражения создают строку с initial contents.

Мы также можем использовать функцию String::from для создания String из строкового литерала. Код листинга 8-13 является эквивалентным коду из листинга 8-12, который использует функцию to_string:

fn main() {
    let s = String::from("initial contents");
}

Листинг 8-13. Использование функции 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");
}

Листинг 8-14. Хранение приветствий в строках на разных языках

Все это допустимые 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");
}

Листинг 8-15: Добавление среза строки к 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);
}

Листинг 8-16. Использование фрагмента строки после его добавления в состав другого String

Если метод push_str стал бы владельцем переменнойs2, мы не смогли бы напечатать его значение в последней строке. Однако этот код работает так, как мы ожидали!

Метод push принимает один символ в качестве параметра и добавляет его к String. В листинге 8-17 показан код, добавляющий букву "l" к String, используя метод push.

fn main() {
    let mut s = String::from("lo");
    s.push('l');
}

Листинг 8-17. Добавление одного символа в 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
}

Листинг 8-18. Использование оператора + для объединения двух значений String в новое String значение

Строка s3 будет содержать Hello, world! как результат выполнения этого кода. Причина того, что s1 после добавления больше недействительна и причина, по которой мы использовали ссылку на s2 имеют отношение к сигнатуре вызываемого метода при использовании оператора +. Оператор + использует метод add, чья сигнатура выглядит примерно так:

fn add(self, s: &str) -> String {

Это не точная сигнатура из стандартной библиотеки: в стандартной библиотеке add определён с помощью обобщённых типов. Здесь мы видим сигнатуру add с конкретными типами, заменяющими обобщённый, что происходит когда вызывается данный метод со значениями String. Мы обсудим обобщённые типы в Главе 10. Эта сигнатура даёт нам ключ для понимания особенностей оператора +.

Во-первых, перед s2 мы видим &, что означает что мы складываем ссылку (reference) на вторую строку с самой первой строкой. Из-за параметра s в функции add, которая может только добавлять тип &str к типу String, мы не можем складывать два значения String вместе. Но подождите - тип &s2 является типом &String, а не типом &str, как указано в сигнатуре второго параметра функции add. Так почему код в листинге 8-18 компилируется?

Причина, по которой мы можем использовать &s2 в вызове add заключается в том, что компилятор может принудительно привести (coerce) аргумент типа &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! значительно легче читается и не забирает во владение ни один из его параметров.

Индексирование в строках

Доступ к отдельным символам в строке, при помощи ссылки на них по индексу, является допустимой и распространённой операцией во многих других языках программирования. Тем не менее, если вы попытаетесь получить доступ к частям String, используя синтаксис индексации в Rust, то вы получите ошибку. Рассмотрим неверный код в листинге 8-19.

fn main() {
    let s1 = String::from("hello");
    let h = s1[0];
}

Листинг 8-19. Попытка использовать синтаксис индекса со строкой

Этот код приведёт к следующей ошибке:

$ cargo run
   Compiling collections v0.1.0 (file:///projects/collections)
error[E0277]: the type `String` cannot be indexed by `{integer}`
 --> src/main.rs:3:13
  |
3 |     let h = s1[0];
  |             ^^^^^ `String` cannot be indexed by `{integer}`
  |
  = help: the trait `Index<{integer}>` is not implemented for `String`

error: aborting due to previous error

For more information about this error, try `rustc --explain E0277`.
error: could not compile `collections`

To learn more, run the command again with --verbose.

Ошибка и примечание говорит, что в 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. Пользователи обычно не хотят получить значение байта, даже если строка содержит только латинские буквы: если &"hello"[0] было бы допустимым кодом, который вернул значение байта, то он вернул бы 104, а не h. Чтобы предотвратить возврат непредвиденного значения, вызывающего ошибки которые не могут быть сразу обнаружены, Rust просто не компилирует такой код и предотвращает недопонимание на ранних этапах процесса разработки.

Байты, скалярные значения и кластеры графем! Боже мой!

Ещё один момент, касающийся UTF-8, заключается в том, что на самом деле существует три способа рассмотрения строк с точки зрения Rust: как байты, как скалярные значения и как кластеры графем (самая близкая вещь к тому, что мы назвали бы буквами).

Если посмотреть на слово языка хинди «नमस्ते», написанное в транскрипции Devanagari, то оно хранится как вектор значений u8 который выглядит следующим образом:

[224, 164, 168, 224, 164, 174, 224, 164, 184, 224, 165, 141, 224, 164, 164,
224, 165, 135]

Эти 18 байт являются именно тем, как компьютеры в конечном итоге сохранят в памяти эту строку. Если мы посмотрим на 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 [unoptimized + debuginfo] target(s) in 0.43s
     Running `target/debug/collections`
thread 'main' panicked at 'byte index 1 is not a char boundary; it is inside 'З' (bytes 0..2) of `Здравствуйте`', src/main.rs:4:14
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

Вы должны использовать диапазоны для создания срезов строк с осторожностью, потому что это может привести к сбою вашей программы.

Методы для перебора строк

Сейчас поговорим о предпочтительных способах доступа к элементам строки.

Если необходимо производить операции над отдельными элементами юникод-строки (не буквами, а char символами), то наилучший способ - использовать метод chars. Вызов chars у "नमस्ते" разделяет и возвращает 6 значений типа char. Далее, вы можете перебирать результат для доступа к каждому элементу:


#![allow(unused)]
fn main() {
for c in "नमस्ते".chars() {
    println!("{}", c);
}
}

Код напечатает следующее:

न
म
स
्
त
े

Метод bytes возвращает каждый байт, который может быть подходящим в другой предметной области:


#![allow(unused)]
fn main() {
for b in "नमस्ते".bytes() {
    println!("{}", b);
}
}

Этот код напечатает 18 байтов, составляющих эту строку String:

224
164
// --часть байтов вырезана--
165
135

Но делая так, обязательно помните, что валидные скалярные Unicode значения могут состоять более чем из одного байта.

Извлечение кластеров графем из строк сложно, поэтому данный функционал не предоставляется в стандартной библиотеке. На crates.io есть доступные библиотеки, если Вам нужен данный функционал.

Строки не так просты

Подводя итог, становится ясно, что строки сложны. Различные языки программирования реализуют различные варианты того, как представить эту сложность для программиста. В Rust решили сделать правильную обработку данных String поведением по умолчанию для всех программ Rust, что означает, что программисты должны заранее продумать обработку UTF-8 данных. Этот компромисс раскрывает большую сложность строк, чем в других языках программирования, но это предотвращает от необходимости обрабатывать ошибки, связанные с не-ASCII символами которые могут появиться в ходе разработки позже.

Давайте переключимся на что-то немного менее сложное: HashMap!