Программирование PC Rust: коллекции стандартной библиотеки Sun, September 08 2024  

Поделиться

Нашли опечатку?

Пожалуйста, сообщите об этом - просто выделите ошибочное слово или фразу и нажмите Shift Enter.

Rust: коллекции стандартной библиотеки Печать
Добавил(а) microsin   

Стандартная библиотека Rust включает в себя несколько очень полезных структур данных, которые называются коллекциями (collections). Большинство других типов данных представляют одно специальное значение, однако коллекции могут содержать в себе несколько значений. В отличие от встроенных типов массивов (array) и кортежей (tuple), данные этих коллекций сохраняются в куче (heap). Это означает, что количество данных этих типов не нужно знать во время компиляции, и это количество может увеличиваться и уменьшаться по мере работы программы (runtime). Каждый вид коллекции отличается по своим возможностям и стоимости, и выбор подходящей их них зависит от конкретной ситуации и ваших навыков в программировании, которые вы будете развивать постепенно.

В этой главе (перевод документации [1]) мы обсудим три коллекции, которые очень часто используются в программах Rust:

• Vector (вектор) позволит вам сохранить переменное количество значений рядом с друг другом. Это аналог массива, но его размер не статический, и может меняться runtime.
• String (строка) это коллекция символов. Мы уже встречались ранее с типом String ранее [2], однако здесь обсудим его более подробно.
• Hash map (хеш-карта) позволит вам связать значение с определенным ключом. Это особая реализация более общей структуры данных, называемой map.

Чтобы узнать больше про другие коллекции, предоставляемые в стандартной библиотеке, см. документацию [3]. Здесь мы обсудим, как создавать и обновлять векторы, строки, хеш-карты, а также все то, что делает эти коллекции такими особенными.

[Сохранение списков значений с помощью векторов]

Первый тип коллекции, который мы рассмотрим, это Vec< T>, также известный как vector (вектор). Вектор позволяет вам сохранять больше одного значения в одной структуре данных, которая размещает все эти значения в памяти рядом друг с другом (как в массиве). Вектор может сохранять в себе значения любого, но только одного типа, который был указан при его создании. Это полезно, когда у вас есть список элементов, таких как строки текста в файле, или прайс элементов в корзине покупок.

Создание нового вектора. Чтобы создать новый пустой вектор, мы вызываем функцию Vec::new, как показано в листинге 8-1.

    let v: Vec< i32> = Vec::new();

Листинг 8-1. Создание нового, пустого вектора, который может хранить значения типа i32.

Обратите внимание, что мы добавили здесь аннотацию типа. Поскольку здесь мы не вставили какие-либо значения в этот вектор, то Rust не знает, какого вида элементы мы собираемся сохранять в векторе. Это важный момент. Вектора реализованы с использованием generic-типов; мы расскажем, как использовать generics с вашими собственными типами данных в главе 10. Сейчас просто уясните, что тип Vec< T>, предоставляемый стандартной библиотекой, может хранить в себе любой тип. Когда мы создаем вектор определенного типа, мы можем указать этот тип в угловых скобках. В листинге 8-1 мы сказали Rust, что Vec< T> в v будет хранить элементы типа i32.

Более часто мы будем создавать Vec< T> начальными значениями, и тогда Rust сам выведет тип значения из того, что вы захотели сохранить, так что редко понадобится специально давать аннотацию типа сохраняемых значений. Rust для удобства содержит реализацию макроса vec!, который создаст новый вектор, который хранит значения, которые вы предоставите. Листинг 8-2 создает новый Vec< i32>, который сразу хранит в себе значения 1, 2 и 3. Целочисленный тип i32 здесь используется потому, что это тип по умолчанию, что мы обсуждали в секции "Типы данных" главы 3 (см. [3]).

    let v = vec![1, 2, 3];

Листинг 8-2. Создание нового вектора, содержащего значения 1, 2 и 3.

Поскольку здесь мы предоставили начальные значения i32, компилятор Rust может сам вывести тип v как Vec< i32>, и поэтому аннотация типа необязательна. Теперь мы рассмотрим модификацию вектора.

Обновление вектора. Чтобы добавлять элементы в вектор, мы можем использовать метод push, как показано в листинге 8-3.

    let mut v = Vec::new();
v.push(5); v.push(6); v.push(7); v.push(8);

Листинг 8-3. Использование метода push для добавления значений в вектор.

Как и любая переменная, если мы хотим поменять её значение, нам надо сделать переменную мутируемой с помощью ключевого слова mut, как обсуждалось в главе 3 (см. [4]). Числа, которые мы помещаем в вектор, все типа i32, и Rust выведет тип из этих данных, так что нам не надо использовать аннотацию Vec< i32>.

Чтение элементов вектора. Есть 2 способа обращаться к значению, сохраненному в векторе: по индексу, или с помощью метода get. В следующих примерах мы применяли аннотацию типов значений, которые возвращаются из функций, для дополнительной ясности.

Листинг 8-4 показывает оба метода доступа к значению в векторе, с помощью синтаксиса индекса, и с помощью метода get.

    let v = vec![1, 2, 3, 4, 5];
let third: &i32 = &v[2]; println!("Третий элемент это {third}");
let third: Option< &i32> = v.get(2); match third { Some(third) => println!("Третий элемент это {third}"), None => println!("Здесь нет третьего элемента."), }

Листинг 8-4. Использование синтаксиса индексации или метода get для доступа к элементу в векторе.

Обратите внимание здесь на несколько деталей. Мы используем индекс 2 для получения третьего элемента, потому что вектора индексируются по номеру, начинающемуся с нуля. Использование & и [] дает нам ссылку на элемент по значению индекса. Когда мы используем метод get, то передаем ему индекс в качестве аргумента, и тогда получаем Option< &T>, который можно использовать вместе с ветвлением match.

Причина, по которой Rust предоставляет эти два способа ссылки на элемент, состоит в том, что вы можете поведение программы при попытке использовать значение индекса за пределами диапазона существующих элементов. В качестве примера давайте рассмотрим, что произойдет, когда у нас вектор из 5 элементов, и мы попытаемся взять элемент по индексу 100 с помощью каждой техники, как показано в листинге 8-5.

    let v = vec![1, 2, 3, 4, 5];
let does_not_exist = &v[100]; let does_not_exist = v.get(100);

Листинг 8-5. Попытка доступа к несуществующему элементу. По индексу 100 нет значения, потому что вектор содержит только 5 элементов.

Когда мы запустим этот код, первый метод [] приведет к панике программы, потому что произошла попытка обращения к несуществующему элементу. Этот метод лучше всего походит, когда вы хотите, чтобы ваша программа падала при попытке доступа к элементу за последним элементом вектора.

Когда в метод get передается индекс, который выходит за пределы вектора, то он возвратит None, и программа не будет паниковать. Поэтому метод get следует использовать, если обращение к элементу за пределами диапазона вектора может иногда происходить при нормальных обстоятельствах. В этом случае ваш код будет содержать логику для обработки либо варианта Some(&element), либо варианта None, как это обсуждалось в главе 6 (см. секцию "Перечисление Option" в статье [5]). Например, индекс может поступить от человека, который вводит число. Если он случайно введет слишком большой индекс, и программа получит значение None, вы должны сказать пользователю, сколько на самом деле элементов находится в текущем векторе, и должны дать ему шанс повторной попытки ввода допустимого значения индекса. Это более дружелюбный вариант реализации программы, чем падение при случайной опечатке пользователя.

Когда программа имеет достоверную ссылку, средство проверки заимствования (borrow checker) применяет правила владения и заимствования (это рассматривалось в главе 4, см. [6]) для гарантии, что эта ссылка и любые другие ссылки на содержимое вектора остаются достоверными. Вспомните правило, по которому нельзя иметь изменяемые и не изменяемые ссылки в одной и той же области видимости. Это правило применяется в листинге 8-6, где мы сохраняем немутируемую ссылку на первый элемент в векторе, и также пытаемся добавить элемент в конец вектора. Эта программа не заработает, если мы попытаемся обратиться к элементу позже в функции:

    let mut v = vec![1, 2, 3, 4, 5];
    let first = &v[0];
    v.push(6);
    println!("The first element is: {first}");

Листинг 8-6. Попытка добавить элемент к вектору, в то время как удерживается ссылка на его элемент.

Компиляция этого кода даст ошибку:

$ cargo run
   Compiling collections v0.1.0 (file:///projects/collections)
error[E0502]: cannot borrow `v` as mutable because it is also borrowed as immutable
 --> src/main.rs:6:5
  |
4 |     let first = &v[0];
  |                  - immutable borrow occurs here
5 |
6 |     v.push(6);
  |     ^^^^^^^^^ mutable borrow occurs here
7 |
8 |     println!("The first element is: {first}");
  |                                     ------- immutable borrow later used here
For more information about this error, try `rustc --explain E0502`. error: could not compile `collections` (bin "collections") due to 1 previous error

Код в листинге 8-6 на первый взгляд кажется рабочим: почему ссылка на первый элемент должна заботиться об изменениях в конце вектора? Эта ошибка связана с тем, как работают векторы: поскольку векторы помещают значения в память рядом друг с другом, добавление нового элемента может потребовать выделения новой области памяти и копирование старых элементов в новое выделенное пространство, если недостаточно места для размещения элементов рядом друг с другом, где сейчас находится вектор. В этом случае ссылка на первый элемент будет ошибочно указывать на освобожденную память. Правила заимствования не позволяет программам оказаться в такой ситуации.

Примечание: для дополнительной информации о типе Vec< T>, см. [7].

Итерация по значениям вектора. Чтобы обратиться к каждому элементу вектора по очереди мы будем перебирать все элементы вместо того, чтобы использовать для этого индексы. Листинг 8-7 показывает, как использовать цикл for для получения немутируемых ссылок на каждый элемент в вектора значений i32 и печати их.

    let v = vec![100, 32, 57];
    for i in &v {
        println!("{i}");
    }

Листинг 8-7. Печать каждого элемента в векторе путем итерации в цикле for.

Мы можем также выполнить итерацию по мутируемым ссылкам к каждому элементу в мутируемом векторе, чтобы изменить значение каждого элемента. Цикл for в листинге 8-8 добавит 50 к каждому элементу.

    let mut v = vec![100, 32, 57];
    for i in &mut v {
        *i += 50;
    }

Листинг 8-8. Итерация мутируемыми ссылками с обращением к каждому элементу вектора.

Чтобы изменить значение, на которое указывает мутируемая ссылка, мы должны использовать оператор разыменования * (dereference operator), чтобы получить значение по ссылке i, после этого мы можем использовать оператор +=. Мы поговорим больше про про оператор разыменования в секции "Following the Pointer to the Value with the Dereference Operator" главы 15.

Итерация по вектору, немутируемая или мутируемая, осуществляется в безопасном режиме, потому что применяется проверка правил заимствования (borrow checker rules). Если мы вдруг попытается вставить или удалить элементы в теле цикла for в листинге 8-7 и листинге 8-8, то получим ошибку компилятора, подобную той, что показана в листинге 8-6. Ссылка на вектор, которую содержит цикл for, не дает модифицировать вектор целиком.

Использование перечисления для сохранения нескольких типов. Векторы могут сохранять только значения одинакового типа. Это может быть неудобным; определенно могут быть варианты использования, когда необходимо сохранить список элементов разных типов. К счастью, варианты enum объединяются под одним и тем же типом перечисления, так что когда нам нужно представить в векторе элементы разных типов, мы можем для этого использовать перечисление!

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

    enum SpreadsheetCell {
        Int(i32),
        Float(f64),
        Text(String),
    }
let row = vec![ SpreadsheetCell::Int(3), SpreadsheetCell::Text(String::from("blue")), SpreadsheetCell::Float(10.12), ];

Листинг 8-9. Определение перечисления, где вариант может хранить значения разных типов. Это перечисление используется для создания вектора.

Для Rust нужно знать в момент компиляции, какие типы будут храниться в векторе, чтобы точно обозначить, сколько надо будет выделить памяти из кучи для каждого элемента вектора. Мы должны также явно описать, какие типы разрешено хранить в этом векторе. Если Rust позволяет для вектора хранить любой тип, то существует вероятность того, что один или несколько типов приведут к ошибкам в операциях, выполняемых элементами вектора. Использование enum вместе с выражением match означает, что Rust будет гарантировать на момент компиляции, что будет обработан каждый возможный случай, что обсуждалось в главе 6 (см. [5], секция "Совпадения должны быть исчерпывающими").

Если вы не знаете исчерпывающий набор типов, который программа получит во время выполнения для хранения в векторе, то метод на основе техники enum не сработает. Вместо этого вы можете использовать объект трейта (trait), который мы рассмотрим в главе 17.

Теперь, когда мы обсудили некоторые самые часто применяемые способы работы с векторами, обязательно ознакомьтесь с документацией API векторов [8], где приведено много полезных методов типа Vec< T> стандартной библиотеки. Например, в дополнение к методу push существует метод pop, который удаляет и возвращает последний элемент вектора (в результате вектор может работать как стек FILO).

Отбрасывание вектора выбрасывает его элементы. Наподобие любой другой структуре, вектор освобождается, когда он выходит за область действия, как отмечается в листинге 8-10.

    {
        let v = vec![1, 2, 3, 4];
// здесь можно выполнять с вектором v какие-нибудь действия } // < - v выходит из области действия, и здесь освобождается

Листинг 8-10. Где вектор отбрасывается вместе со всеми его элементами.

Когда вектор отбрасывается, выбрасывается все его содержимое, т. е. все сохраняемые в нем значения очищаются. Система проверки заимствования (borrow checker) гарантирует, что содержимое вектора используется только тогда, когда сам вектор достоверен.

Давайте перейдем к рассмотрению следующего типа коллекции: String.

[String: сохранение текста в кодировке UTF-8]

Мы уже сталкивались со строками в главе 4 (см. [6]), и теперь поговорим про них подробнее. Новички в Rust обычно сталкиваются с проблемами, когда пытаются применять строки, по трем основным причинам: склонность компилятора Rust выявлять возможные ошибки, у строк более сложная структура данных, чем можно было бы ожидать новичку, и кодировка UTF-8. Комбинация этих факторов приводит к тому, что работа со строками кажется трудной, когда вы переходите на Rust с других языков программирования.

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

Что такое String. Давайте сначала разберемся, что подразумевается по термином "строка". В языке Rust существует только один встроенный в ядро языка строковый тип. Это слайс строки str, который обычно заимствуется из &str. В главе 4 [6], мы уже говорили про слайсы строк, которые являются ссылками на некоторые закодированные в UTF-8 данные строки, хранящиеся где-то в памяти. Например, литералы String сохраняются в бинарнике программы, и по этой причине являются слайсами строк.

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

Создание нового экземпляра String. Многие те же самые операции, доступные для Vec< T>, также доступны и для String, потому что String фактически реализован как обертка вокруг вектора байтов, с некоторыми гарантиями, ограничениями и возможностями. Пример функции, которая работает одинаково как с Vec< T>, так и со String, это функция new для создания экземпляра объекта:

    let mut s = String::new();

Листинг 8-11. Создание новой, пустой строки String.

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

    let data = "начальное содержимое";
let s = data.to_string();
// Этот метод также работает напрямую с литеральной строкой: let s = "начальное содержимое".to_string();

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

Этот код создает строку, содержащую какое-то заданное начальное значение.

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

    let s = String::from("начальное содержимое");

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

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

Помните, что строки кодируются в UTF-8, так что мы легко можем вывести текст на любом языке, см. пример в листинге 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");

Листинг 8-14. Представление строки приветствия на различных языках.

Все полученные таким способом строки String содержат допустимые значения.

Обновление строки. String может увеличиваться в размере, и её содержимое может меняться, наподобие как изменяется содержимое Vec< T>, если вы проталкиваете (push) в строку данные. Дополнительно вы можете для удобства использовать оператор + или макрос format!, чтобы склеивать значения String.

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

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

Листинг 8-15. Добавление к String слайса строки с помощью метода push_str.

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

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

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

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

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

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

Листинг 8-17. Добавление одного символа к значению String, используя push.

В результате получится lol.

Конкатенация оператором + или макросом format!. Часто вам будет нужно объединять друг с другом две существующие строки. Один из способов это оператор +, как показано в листинге 8-18.

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

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

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

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

В стандартной библиотеке Rust вы увидите add, определенный с помощью generic-программ и связанных с ними типов. Здесь мы подставили конкретные типы, что происходит, когда мы вызываем этот метод со значениями String. Более подробно мы обсудим generic-и в главе 10. Эта сигнатура дает нам подсказки, которые нам нужны для понимая тонкостей работы оператора +.

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

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

Далее, мы можем видеть в сигнатуре add, что здесь берется владение над self, потому что у self нет символа ссылки &. Это значит, что s1 в листинге 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!, использует ссылки, так что этот вызов макроса format! не берет во владение ни один из своих параметров.

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

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

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

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

$ cargo run
   Compiling collections v0.1.0 (file:///projects/collections)
error[E0277]: the type `String` cannot be indexed by `{integer}`
 --> src/main.rs:3:16
  |
3 |     let h = s1[0];
  |                ^ `String` cannot be indexed by `{integer}`
  |
  = help: the trait `Index< {integer}>` is not implemented for `String`
  = help: the following other types implement trait `Index< Idx>`:
            < String as Index< RangeFull>>
            < String as Index< std::ops::Range< usize>>>
            < String as Index< RangeFrom< usize>>>
            < String as Index< RangeTo< usize>>>
            < String as Index< RangeInclusive< usize>>>
            < String as Index< RangeToInclusive< usize>>>
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. Как мы уже упоминали, String это обертка над Vec< u8>. Давайте посмотрим на некоторые из наших корректно закодированных строк из листинга 8-14. Сначала на эту:

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

В этом случае длина строки будет 4, т. е. вектор, хранящий строку "Hola", будет иметь длину 4 байта. Каждая из букв 'H', 'o', 'l', 'a' будут занимать по 1 байту в кодировке UTF-8. Другая строка однако может вас удивить (обратите внимание, что строка начинается на заглавную кириллическую букву "зе", это не цифра 3):

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

На первый взгляд может показаться, что у этой строки длина 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, заключается в том, что существуют 3 соответствующие способа взглянуть на строки с точки зрения Rust: как на байты, как на скалярные значения, и как на кластеры графем (самое близкое к тому, что мы называем буквами).

Если мы посмотрим на слово языка Хинди "नमस्ते", написанное письмом Деваганари (Devanagari script), то оно сохранено как вектор значений 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 придется выполнить итерацию по всему содержимому строки, чтобы определить, сколько в ней было действительных символов.

Нарезка строк (slicing). Индексация по строке часто совсем плохая идея, потому что ни разу не ясно, каким должен быть результат индексирования строки: значение байта, символа, кластера графемы или же слайс строки. Если вам действительно нужно использовать индексы для создания слайсов строк, то Rust попросит вас быть более конкретным.

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

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

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

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

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

$ 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 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

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

Методы для итерации по строке String. Самый лучший способ работать с фрагментами строк - четко указывать, нужны вам символы или байты. Для отдельных скалярных значений Unicode используйте метод chars. Вызов на строке "Зд" разделит её и вернет 2 значения типа char, и вы можете следующим образом получить доступ к каждому элементу:

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

Этот код напечатает:

З
д

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

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

Этот код напечатает 4 байта этой строки:

208
151
208
180

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

Извлечение кластеров графемы из строк, таких как Devanagari script, это сложная тема, так что такой функционал не предоставлен стандартной библиотекой. Если этот функционал нужен, то соответствующие библиотеки доступны на crates.io.

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

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

Давайте теперь рассмотрим что-то более простое: hash map.

[Сохранение пар ключ-значение в hash map]

Последнее, что мы рассмотрим из общих коллекций это hash map (хеш-карта). Тип HashMap< K, V> сохраняет привязанные друг к другу тип K со значением типа V, используя функцию хеширования, которая определяет, как разместить эти ключи и значения в памяти. Многие языки программирования поддерживают этот вид структуры данных, однако они часто используют другое имя, такое как hash, map, object, hash table, dictionary, или associative array, и это только несколько имен для примера.

Hash map-ы полезны, когда вы хотите находить данные не по индексу, как с векторами, а по ключу, который может быть любого типа. Например, в игре вы могли мы отслеживать очки каждой команды, где ключом было бы имя команды, а значениями были бы очки команды. По имени команды (ключ) вы можете получить её очки (значение).

В этой секции мы рассмотрим базовый API хеш-карт, но в функциях, определенных на HashMap< K, V> стандартной библиотеки, скрыто много больше полезного функционала. Как и всегда изучайте документацию на стандартную библиотеку для получения дополнительной информации.

Создание новой hash map. Один из способов создания пустой хеш-карты - использование generic-функции new, с последующим добавлением элементов методом insert. В листинге 8-20, мы отслеживаем очки двух команд, которым даны имена Blue и Yellow. Команда Blue начала игру с 10 очками, а команда Yellow с 50.

    use std::collections::HashMap;
let mut scores = HashMap::new();
scores.insert(String::from("Blue"), 10); scores.insert(String::from("Yellow"), 50);

Листинг 8-20. Создание новой hash map и вставка в неё некоторых ключей и значений.

Обратите внимание, что нам сначала нужно использовать ключевое слов use для добавления в область действия HashMap из части collections стандартной библиотеке. Из наших трех рассмотренных коллекций эта используется реже всего, поэтому она не включена в автоматически добавляемые (prelude). Хеш-карты также обладают меньшей поддержкой со стороны стандартной библиотеки; например, нет строенного макроса для их конструирования.

Точно так же, как и векторы, хеш-карты сохраняют свои данные в куче (heap). В нашем примере тип HashMap имеет ключи типа String и значения типа i32. Наподобие векторов, хеш-карты гомогенны: все ключи должны иметь одинаковый тип, и все значения также должны иметь одинаковый тип.

Доступ к значениям в hash map. Мы можем получить значение из хеш-карты предоставлением ключа методу get, как показано в листинге 8-21.

    use std::collections::HashMap;
let mut scores = HashMap::new();
scores.insert(String::from("Blue"), 10); scores.insert(String::from("Yellow"), 50);
let team_name = String::from("Blue"); let score = scores.get(&team_name).copied().unwrap_or(0);

Листинг 8-21. Доступ к очкам команды Blue, сохраненным в hash map.

Здесь очки связаны с командой Blue, а результат очков score будет 10. Метод get вернет Option< &V>; если в хеш-карте нет значения с указанным ключом, то get вернет None. Эта программа обрабатывает Option путем вызова копированного результата get Option< i32> вместо Option< &i32>, затем unwrap_or для установки score в 0, если для этого ключа в хеш-карте нет записи.

Мы можем реализовать итерацию по каждой паре ключ/значение в хеш-карте похожим образом, как это делалось с вектором, с помощью цикла for:

    use std::collections::HashMap;
let mut scores = HashMap::new();
scores.insert(String::from("Blue"), 10); scores.insert(String::from("Yellow"), 50);
for (key, value) in &scores { println!("{key}: {value}"); }

Этот код напечатает каждую пару в произвольном порядке:

Yellow: 50
Blue: 10

Hash map и Ownership. Для типов, которые реализуют трейт Copy, наподобие i32, значения копируются в hash map. Для значений, которые берут во владение (owned), наподобие String, значения будут перемещаться, и hash map будет владельцем этих значений, как демонстрируется в листинге 8-22.

    use std::collections::HashMap;
let field_name = String::from("Favorite color"); let field_value = String::from("Blue");
let mut map = HashMap::new(); map.insert(field_name, field_value); // field_name и field_value в этом месте недопустимы. Попытайтесь их здесь // использовать, и получите ошибку компилятора!

Листинг 8-22. Пример показывает, что ключи и значения взяты во владение hash map, как только они вставлены.

Мы не можем использовать переменные field_name и field_value после того, как они были перемещены в hash map, когда мы вызвали insert.

Если мы вставили ссылки на значения в hash map, то значения не перемещаются в hash map. Значения, на которые указывают эти ссылки, должны оставаться достоверными, пока достоверна hash map. Мы более подробно поговорим об этих проблемах в секции "Validating References with Lifetimes" главы 10.

Обновление hash map. Хотя количество пар key-value по мере добавления нарастает, каждый ключ обязательно должен быть уникальным, и каждый ключ может иметь только одно связанное с ним значение (обратное не верно: например, у обоих команд Blue и Yellow может быть по 10 очков в hash map).

Когда вы хотите поменять данные в hash map, вы должны решить, как обработать случай, когда ключ уже существует, т. е. ему назначено значение. Можно заменить старое значение на новое, полностью игнорируя старое значение. Вы также можете сохранить старое значение и игнорировать новое значение, только лишь добавляя новое значение, если ключ еще не имеет значения. Или вы можете объединить старое значение и новое значение. Давайте посмотрим, как можно выполнить каждый такой вариант взаимодействия.

Перезапись значения. Если мы вставили ключ и значение в hash map, и затем вставляем тот же ключе с другим значением, то значение, связанное с этим ключом, будет заменено. Даже если код листинга 8-23 вызывает insert дважды, hash map будет содержать только одну пару key/value с ключом "Blue", потому что каждый раз мы указали один и тот же ключ.

    use std::collections::HashMap;
let mut scores = HashMap::new();
scores.insert(String::from("Blue"), 10); scores.insert(String::from("Blue"), 25);
println!("{scores:?}");

Листинг 8-23. Замена значения, сохраненного с определенным ключом (в этом примере ключ это текстовая строка Blue).

Запуск этого кода напечатает {"Blue": 25}. Оригинальное значение 10 было перезаписано.

Добавление ключа и значения только если ключа еще нет. Обычно проверяется, существует ли определенный ключ в hash map со значением, а затем выполняются следующие действия: если ключ действительно в hash map существует, то существующее значение должно оставаться таким, какое есть. Если ключе не существует, то он вставляется вместе со значением.

У хеш-карт есть специальный API-метод для этого: entry, в качестве параметра он принимает проверяемый ключ. Возвращаемое значение метода entry это перечисление, называемое Entry, оно представляет значение, которое может существовать или не существовать. Допустим, что мы хотим проверить, имеет ли ключ команды Yellow связанное с ключом значение. Если нет, то мы вставим значение 50, и то же самое сделаем для команды Blue. С использованием entry API код будет выглядеть как в листинге 8-24.

    use std::collections::HashMap;
let mut scores = HashMap::new(); scores.insert(String::from("Blue"), 10);
scores.entry(String::from("Yellow")).or_insert(50); scores.entry(String::from("Blue")).or_insert(50);
println!("{scores:?}");

Листинг 8-24. Использование метода entry чтобы вставить ключ/значение только если у ключа еще нет значения.

Метод or_insert на Entry определен для возврата мутируемой ссылки на значение соответствующего ключа Entry, если ключ существует, и если нет, то вставит параметр как новое значение для этого ключа, и вернет мутируемую ссылку на новое значение. Эта техника более понятная, чем если все это реализовывать самому вручную, и дополнительно работает более надежно благодаря системе проверки заимствований (borrow checker).

Запущенный код в листинге 8-24 напечатает {"Yellow": 50, "Blue": 10}. Первый вызов entry вставит ключ для команды Yellow со значением 50, потому что у команды Yellow еще не было значения для этого ключа. Второй вызов entry не поменяет hash map, потому что у команды Blue уже было значение 10.

Обновление значения на основе старого значения. Другой общий случай для хеш-карт - найти определенный ключ и затем обновить его на базе старого значения. Например, листинг 8-25 показывает код, который подсчитывает, сколько раз каждое слово появляется в некотором тексте. Мы используем hash map со словами в качестве ключей, и инкрементируем значения этих ключей, чтобы отследить, сколько раз с тексте появлялось слово. Если слово попалось первый раз, то мы вставим значение 0.

    use std::collections::HashMap;
let text = "hello world wonderful world";
let mut map = HashMap::new();
for word in text.split_whitespace() { let count = map.entry(word).or_insert(0); *count += 1; }
println!("{map:?}");

Листинг 8-25. Подсчет вхождений слов с помощью hash map, в которой сохраняются слова и счетчики.

Этот код вернет {"world": 2, "hello": 1, "wonderful": 1}. Вы можете увидеть те же самые пары key/value, напечатанные в другом порядке: см. выше секцию "Доступ к значениям в hash map", где итерация по хеш-карте приводит к выводу ключей в произвольном порядке.

Метод split_whitespace возвратит итератор по суб-слайсам, разделенным пробелами, в виде значений в тексте. Метод or_insert возвратит мутируемую ссылку (&mut V) на значение для указанного ключа. Здесь мы сохраняем мутируемую ссылку в переменную count, и чтобы назначить новое значение, мы должны выполнить разыменование count, используя звездочку (*). Мутируемая ссылка выходит из области действия по окончанию цикла for, так что все эти изменения безопасны и разрешаются правилами заимствования.

Функции хеширования. По умолчанию HashMap использует функцию хеширования SipHash, которая может предоставить защиту от атак типа "отказ в обслуживании" (Denial of Service, DoS) на основе таблиц хеша [9]. Это не самый быстрый алгоритм хеширования, но компромисс в пользу большей безопасности, связанный с потерей производительности, все-таки того стоит. Если вы профилируете свой код и обнаружите, что хеш-функция по умолчанию слишком медленная для ваших целей, то можете переключиться на другую функцию, указав другой hasher. Здесь hasher это тип, который реализует трейт BuildHasher. Мы поговорим про трейты, и как их реализовать, в главе 10. Вам необязательно реализовывать свой собственный hasher с нуля; в crates.io есть библиотеки, предоставленные другими пользователями Rust, где реализованы hasher-ы на основе многих общеизвестных алгоритмов.

[Общие выводы]

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

• По списку целых чисел можно использовать вектор и возвратить медиану (при сортировке это значение в средней позиции) и режим (значение, которое встречается чаще всего; здесь поможет как раз hash map) списка.

• Преобразование строк в свиную латынь (pig latin). Первая согласная каждого слова помещается в конец слова и добавляется "ay", так что "first" превращается в "irst-fay". К словам, начинающимся с гласной, в конце добавляется "hay" ("apple" превратится в "apple-hay"). Имейте в виду подробности кодирования UTF-8!

• Используя hash map и векторы создайте текстовый интерфейс, чтобы дать возможность пользователю добавлять имена сотрудников в отдел компании. Например, "Добавить Салли в Инженерию" или "Добавить Амира в Продажи". Затем разрешите пользователю запросить список всех людей в отделе, или всех людей в компании по отделам, отсортированный по алфавиту.

Документация по API стандартной библиотеки описывает методы, которые помогут применить векторы, строки и хеш-карты для решения этих задач.

При написании все более сложных программ, в которых операции могут потерпеть неудачу, становится очень важным обработка ошибок (error handling). Об этом мы поговорим в следующей главе 9.

[Ссылки]

1. Rust Common Collections site:rust-lang.org.
2. Rust Module std::collections site:rust-lang.org.
3. Rust: использование структуры для взаимосвязанных данных.
4. Rust: общая концепция программирования.
5. Rust: перечисления (enum) и совпадение шаблонов (match).
6. Rust: что такое Ownership.
7. Rust Example: Implementing Vec site:rust-lang.org.
8. Rust Struct std::vec::Vec site:rust-lang.org.
9. SipHash site:wikipedia.org.

 

Добавить комментарий


Защитный код
Обновить

Top of Page