Сохранение UTF-8-кодированного текста с использованием строк

Beginner

This tutorial is from open-source community. Access the source code

Введение

Добро пожаловать в Сохранение текста в кодировке UTF-8 с помощью строк. Этот практикум является частью Rust Book. Вы можете практиковать свои навыки Rust в LabEx.

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

Сохранение текста в кодировке UTF-8 с помощью строк

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

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

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

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

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

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

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

let mut s = String::new();

Listing 8-11: Создание новой пустой строки

Эта строка создает новую пустую строку под именем s, в которую мы можем затем загрузить данные. Чаще всего у нас есть некоторые начальные данные, с которыми мы хотим начать строчку. Для этого мы используем метод to_string, который доступен для любого типа, реализующего трейт Display, как это делает строковый литерал. Listing 8-12 показывает два примера.

let data = "initial contents";

let s = data.to_string();

// метод также работает непосредственно с литералом:
let s = "initial contents".to_string();

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

Этот код создает строку, содержащую initial contents.

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

let s = String::from("initial contents");

Listing 8-13: Использование функции String::from для создания String из строкового литерала

Поскольку строки используются для многих вещей, мы можем использовать многие разные обобщенные API для строк, что дает нам много вариантов. Некоторые из них могут показаться избыточными, но все они имеют свое место! В этом случае String::from и to_string делают одно и то же, поэтому выбор между ними - вопрос стиля и читаемости.

Помните, что строки кодируются в UTF-8, поэтому мы можем включать в них любые правильно закодированные данные, как показано в Listing 8-14.

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");

Listing 8-14: Сохранение приветствий на разных языках в строках

Все эти являются допустимыми значениями String.

Обновление строки

String может увеличивать размер и его содержимое может изменяться, точно так же, как содержимое Vec<T>, если вы добавляете в него больше данных. Кроме того, вы можете удобно использовать оператор + или макрос format! для конкатенации значений String.

Добавление в строку с помощью push_str и push

Мы можем увеличивать String, используя метод push_str для добавления строкового среза, как показано в Listing 8-15.

let mut s = String::from("foo");
s.push_str("bar");

Listing 8-15: Добавление строкового среза к String с использованием метода push_str

После этих двух строк s будет содержать foobar. Метод push_str принимает строковый срез, потому что мы не обязательно хотим взять владение параметром. Например, в коде в Listing 8-16 мы хотим иметь возможность использовать s2 после добавления его содержимого в s1.

let mut s1 = String::from("foo");
let s2 = "bar";
s1.push_str(s2);
println!("s2 is {s2}");

Listing 8-16: Использование строкового среза после добавления его содержимого в String

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

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

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

Listing 8-17: Добавление одного символа к значению String с использованием push

В результате s будет содержать lol.

Конкатенация с помощью оператора + или макроса format!

Часто вы захотите объединить две существующие строки. Одним из способов сделать это является использование оператора +, как показано в Listing 8-18.

let s1 = String::from("Hello, ");
let s2 = String::from("world!");
let s3 = s1 + &s2; // обратите внимание, что s1 была перемещена сюда и больше не может быть использована

Listing 8-18: Использование оператора + для объединения двух значений 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. Тогда почему Listing 8-18 компилируется?

Причина, по которой мы можем использовать &s2 в вызове add, заключается в том, что компилятор может принудительно преобразовать аргумент &String в &str. Когда мы вызываем метод add, Rust использует принудительное приведение по делименованию (deref coercion), которое здесь преобразует &s2 в &s2[..]. Мы обсудим принудительное приведение по делименованию более подробно в главе 15. Поскольку add не берет владение параметром s, s2 по-прежнему будет действительным String после этой операции.

Во - вторых, мы можем видеть в сигнатуре, что add берет владение self, потому что self не имеет &. Это означает, что s1 в Listing 8-18 будет перемещен в вызов add и больше не будет действительным после этого. Таким образом, хотя let s3 = s1 + &s2; выглядит так, будто он скопирует обе строки и создаст новую, на самом деле этот оператор берет владение s1, добавляет копию содержимого s2 и затем возвращает владение результатом. Другими словами, кажется, что он делает много копий, но на самом деле не делает; реализация более эффективна, чем копирование.

Если нам нужно конкатенировать несколько строк, поведение оператора + становится неудобным:

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!:

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, вы получите ошибку. Рассмотрим недействительный код в Listing 8-19.

let s1 = String::from("hello");
let h = s1[0];

Listing 8-19: Попытка использовать синтаксис индексирования с String

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

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`

Ошибка и примечание рассказывают всю историю: Rust строки не поддерживают индексирование. Но почему же? Чтобы ответить на этот вопрос, нужно обсудить, как Rust хранит строки в памяти.

Внутреннее представление

String - это обертка над Vec<u8>. Посмотрим на некоторые из наших правильно закодированных примеров строк в UTF-8 из Listing 8-14. Во - первых, этот:

let hello = String::from("Hola");

В этом случае len будет равным 4, что означает, что вектор, хранящий строку "Hola", имеет длину 4 байта. Каждая из этих букв занимает один байт при кодировании в UTF-8. Однако, следующая строка может удиви вас (заметьте, что эта строка начинается с заглавной кириллической буквы Ze, а не арабской цифры 3):

let hello = String::from("Здравствуйте");

Если бы вас попросили сказать, какова длина строки, вы, возможно, бы ответили 12. Фактически Rust ответит 24: это количество байт, которое требуется для кодирования "Здравствуйте" в UTF-8, потому что каждый кодовая точка Юникода в этой строке занимает 2 байта памяти. Поэтому индекс в байты строки не всегда будет соответствовать валидной кодовой точке Юникода. Чтобы продемонстрировать это, рассмотрим этот недействительный код на 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: как байты, кодовые точки и кластеры графем (ближайшее к тому, что мы называем буквами).

Если мы посмотрим на индийское слово "नमस्ते", написанное в देवанागари, оно хранится в виде вектора значений 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 требует, чтобы вы были более конкретными.

Вместо индексирования с использованием [] с одним числом, вы можете использовать [] с диапазоном, чтобы создать срез строки, содержащий определенные байты:

let hello = "Здравствуйте";

let s = &hello[0..4];

Здесь s будет &str, содержащий первые четыре байта строки. Ранее мы упоминали, что каждый из этих символов был двумя байтами, что означает, что s будет Зд.

Если бы мы попытались взять срез только части байтов символа, например, с помощью &hello[0..1], Rust бы столкнулся с ошибкой во время выполнения точно так же, как если бы был доступен недействительный индекс в векторе:

thread 'main' panicked at 'byte index 1 is not a char boundary;
it is inside 'З' (bytes 0..2) of `Здравствуйте`', src/main.rs:4:14

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

Методы для итерации по строкам

Лучший способ работать с частями строк - быть явным в том, нужны ли вам символы или байты. Для отдельных кодовых точек Юникода используйте метод chars. Вызов chars для "Зд" разделяет и возвращает два значения типа char, и вы можете итерироваться по результату, чтобы получить доступ к каждому элементу:

for c in "Зд".chars() {
    println!("{c}");
}

Этот код выведет следующее:

З
д

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

for b in "Зд".bytes() {
    println!("{b}");
}

Этот код выведет четыре байта, составляющие эту строку:

208
151
208
180

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

Получение кластеров графем из строк, как в случае с देवанागари, является сложной задачей, поэтому эта функциональность не предоставляется стандартной библиотекой. Если вам нужна эта функциональность, вы можете найти соответствующие пакеты на https://crates.io.

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

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

Хорошие новости заключаются в том, что стандартная библиотека предлагает много функциональности, основанной на типах String и &str, чтобы помочь правильно обрабатывать эти сложные ситуации. Обязательно ознакомьтесь с документацией по полезным методам, таким как contains для поиска в строке и replace для замены частей строки на другую строку.

Перейдем к чему-то немного менее сложному: ассоциативным массивам!

Резюме

Поздравляем! Вы завершили лабораторную работу по хранению UTF-8-кодированного текста с использованием строк. Вы можете практиковаться в других лабораторных работах в LabEx, чтобы улучшить свои навыки.