Язык программирования Rust

от авторов Steve Klabnik, Carol Nichols и участников сообщества Rust

В этой версии книги предполагается, что вы используете Rust 1.61 (выпущенный 2022-05-18) или более новый. Ознакомьтесь с разделом “Установка” в Главе 1 для установки или апгрейда Rust.

HTML-версия книги доступна онлайн по адресам https://doc.rust-lang.org/stable/book/(англ.) и https://doc.rust-lang.ru/book(рус.) и офлайн, при установке Rust с помощью rustup: просто запустите rustup docs --book чтобы открыть её.

Также доступны несколько переводов от сообщества.

Этот материал доступен в виде печатной книги в мягкой обложке и в формате электронной книги от No Starch Press .

Предисловие

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

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

Rust разрушает эти барьеры, устраняя старые подводные камни и предоставляя дружелюбный, отполированный набор инструментов, который поможет вам на этом пути. Программисты, которым необходимо «погрузиться» в низкоуровневое управление, могут сделать это с помощью Rust, не беря на себя привычный риск аварий или дыр в безопасности и не изучая тонкости изменчивых наборов инструментов. Более того, язык предназначен для того, чтобы легко вести вас к надёжному коду, который эффективен с точки зрения скорости и использования памяти.

Программисты, которые уже работают с низкоуровневым кодом, могут использовать Rust для повышения своих амбиций. Например, внедрение параллелизма в Rust является операцией с относительно низким риском: компилятор поймает для вас классические ошибки. И вы можете заняться более агрессивной оптимизацией в своём коде с уверенностью, что не будете случайно добавлять в код сбои или уязвимости.

Но Rust не ограничивается низкоуровневым системным программированием. Он достаточно выразителен и эргономичен, чтобы приложения CLI (Command Line Interface – консольные программы), веб-серверы и многие другие виды кода были довольно приятными для написания — позже вы найдёте простые примеры того и другого в книге. Работа с Rust позволяет вырабатывать навыки, которые переносятся из одной предметной области в другую; вы можете изучить Rust, написав веб-приложение, а затем применить те же навыки для Raspberry Pi.

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

— Nicholas Matsakis и Aaron Turon

Введение

Примечание: это издание книги так же, как и The Rust Programming Language доступно в печатном и электронном виде от No Starch Press.

Добро пожаловать в The Rust Programming Language, вводную книгу о Rust. Язык программирования Rust помогает создавать быстрые, более надёжные приложения. Хорошая эргономика и низкоуровневый контроль часто являются противоречивыми требованиями для дизайна языков программирования; Rust бросает вызов этому конфликту. Благодаря сбалансированности мощных технических возможностей c большим удобством разработки, Rust предоставляет возможности управления низкоуровневыми элементами (например, использование памяти) без трудностей, традиционно связанными с таким контролем.

Кому подходит Rust

Rust подходит для многих людей по разным причинам. Приведём несколько самых важных групп.

Команды разработчиков

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

Rust также предлагает современные инструменты разработки для системного программирования:

  • Cargo, встроенный менеджер зависимостей и инструмент сборки, добавляет, компилирует и управляет зависимостями безболезненно и согласованно, используя экосистему Rust.
  • Rustfmt обеспечивает согласованный стиль кодирования для всех разработчиков.
  • Языковой сервер Rust обеспечивает интеграцию с интегрированной средой разработки (IDE) для завершения кода и встроенных сообщений об ошибках.

Эти и другие инструменты экосистемы Rust обеспечивают разработчикам продуктивность при написании кода системного уровня.

Студенты

Rust полезен для студентов и тех, кто заинтересован в изучении системных концепций. Используя Rust, многие люди узнали о таких темах, как разработка операционных систем. Сообщество радушно и с удовольствием ответит на вопросы начинающих. Благодаря усилиям, таким как эта книга, команды Rust хотят сделать концепции систем более доступными для большего числа людей, особенно для новичков в программировании.

Компании

Сотни компаний, больших и малых, используют Rust для различных целей. Эти задачи включают в себя инструменты командной строки, веб-сервисы, инструменты DevOps, встраиваемые устройства, анализ и транскодирование аудио и видео, крипто-валюты, биоинформатика, поисковые системы, приложения интернета вещей, машинное обучение и даже основные части браузера Firefox.

Разработчики Open Source

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

Люди, которые ценят скорость и стабильность

Rust для людей, которые жаждут скорости и стабильности в языке. Под скоростью здесь мы подразумеваем и скорость программ, которые вы можете создать с помощью Rust, и скорость с которой Rust позволяет вам написать их. Проверки компилятора Rust обеспечивают стабильность через добавление функций и рефакторинг. Это в корне отличается от хрупкого устаревшего кода на языках без таких проверок, который разработчики часто боятся изменить. Стремясь к абстракциям с нулевой стоимостью, компилируя высокоуровневые функции в код более низкого уровня так же быстро, как код, написанный вручную, Rust стремится сделать безопасный код также и быстрым.

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

Для кого эта книга

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

Как использовать эту книгу

В целом, эта книга предполагает, что вы читаете её последовательно от начала до конца. Более поздние главы основываются на концепциях предыдущих. Иногда более ранние главы могут не углубляться в детали темы; мы обычно возвращаемся к теме в последующих главах.

В этой книге вы найдёте два вида глав: концептуальные главы и главы проекта. В концептуальных главах вы узнаете об аспектах Rust. В главах проекта мы будем вместе строить небольшие программы, применяя то, что вы узнали. Главы 2, 12 и 20 являются главами проекта; остальные являются концептуальными главами.

В главе 1 объясняется, как установить Rust, написать программу “Hello, world!” и использовать сборщик Cargo и менеджер пакетов в одном лице. Глава 2 является практическим введением в язык Rust. В ней объясняются концепции верхнего уровня и в более поздних главах предоставляются дополнительные детали о них. Если хотите сразу погрузиться в практику, то для этого предназначена глава 2. Вначале можно даже пропустить главу 3, которая рассказывает про возможности языка аналогичные тем, что есть в других языках и перейти к главе 4, для изучения системы владения в Rust. Однако, если вы дотошный ученик, предпочитающий изучить каждую особенность до перехода к следующей, то можно пропустить главу 2 и перейти сразу к главе 3, возвращаясь к главе 2, если захочется поработать над проектом и применить полученные знания.

Глава 5 описывает структуры и методы, а глава 6 охватывает перечисления, выражения match и конструкции управления потоком if let. Вы будете использовать структуры и перечисления для создания пользовательских типов в Rust.

В главе 7 вы узнаете о системе модулей Rust и о правилах конфиденциальности для организации вашего кода и его публичного программного интерфейса - Application Programming Interface (API). В главе 8 обсуждаются некоторые общие коллекции структур данных, которые предоставляет стандартная библиотека, например, векторы, строки и HashMap. Глава 9 исследует философию и методы обработки ошибок Rust.

В главе 10 рассматриваются шаблонные типы данных, типажи и времена жизни, которые дают вам силу разрабатывать код, который может использоваться разными типами. Глава 11 посвящена тестированию, которое даже с гарантиями безопасности в Rust, необходимо для обеспечения правильной логики вашей программы. В главе 12 мы создадим собственную реализацию подмножества функциональности инструмента командной строки grep, предназначенного для поиска текста в файлах. Для этого мы будем использовать многие концепции, которые обсуждались в предыдущих главах.

Глава 13 исследует замыкания и итераторы: особенности Rust, которые пришли из функциональных языков программирования. В главе 14 мы подробнее рассмотрим Cargo и расскажем о лучших способах предоставления пользования вашими библиотеками другим разработчикам. Глава 15 обсуждает умные указатели, которые предоставляет стандартная библиотека и свойства, которые обеспечивают их функциональность.

В главе 16 мы рассмотрим различные модели параллельного программирования и поговорим о том, как Rust помогает вам безбоязненно программировать в нескольких потоках. Глава 17 рассказывает о том, как идиомы Rust сравниваются с принципами объектно-ориентированного программирования, с которыми вы, возможно, знакомы.

Глава 18 является справочником по шаблонам и сопоставлению с образцом, которые являются мощным способом выражения идей в программах на Rust. Глава 19 содержит обзор продвинутых тем, представляющих интерес, включая небезопасный Rust, макросы, больше о временах жизни, шаблонах, типах, функциях и замыканиях.

В главе 20 мы завершим проект, в котором мы реализуем низкоуровневый многопоточный веб-сервер!

Наконец, некоторые приложения содержат полезную информацию о языке в формате, более похожем на справочник. В приложении A описаны ключевые слова Rust, в приложении B описаны операторы и символы Rust, в приложении C описаны производные свойства, предоставляемые стандартной библиотекой, в приложении D описаны некоторые полезные инструменты разработки, а в приложении E описаны редакции Rust.

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

Важной частью процесса обучения Rust является изучение того, как читать сообщения об ошибках, которые отображает компилятор: они приведут вас к работающему коду. Мы изучим много примеров, которые не компилируются и отображают ошибки в сообщениях компилятора в разных ситуациях. Знайте, что если вы введёте и запустите случайный пример, он может не скомпилироваться! Убедитесь, что вы прочитали окружающий текст, чтобы понять, не предназначен ли пример, который вы пытаетесь запустить, для демонстрации ошибки. Ferris также поможет вам различить код, который не предназначен для работы:

FerrisПояснения
Ferris with a question markЭтот код не компилируется!
Этот код вызывает панику!
Этот код не даёт желаемого поведения.

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

Исходные коды

Файлы с исходным кодом, используемым в этой книге, можно найти на GitHub.

Начало работы

Начнём наше путешествие в Rust! Нужно много всего изучить, но каждое путешествие с чего-то начинается. В этой главе мы обсудим:

  • установку Rust на Linux, macOS и Windows,
  • написание программы, печатающей Hello, world!,
  • использование cargo, менеджера пакетов и системы сборки в одном лице для Rust.

Установка

Первым шагом является установка Rust. Мы загрузим Rust, используя инструмент командной строки rustup, предназначенный для управлениями версиями Rust и другими связанными с ним инструментами. Вам понадобится интернет соединение для его загрузки.

Примечание: Если вы по каким-то причинам предпочитаете не использовать rustup, пожалуйста, посетите страницу «Другие методы установки Rust» для получения дополнительных опций.

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

Условные обозначения командной строки

В этой главе и на протяжении всей книги мы демонстрируем команды в терминале. Все строки, которые вы должны вводить в терминал, начинаются с $. Вам не нужно вводить символ $; это указатель командной строки, отображаемый для указания начала каждой команды. Строки, не начинающиеся с $, обычно показывают результат предыдущей команды. Кроме того, в примерах, специфичных для PowerShell, будет использоваться > вместо $.

Установка rustup на Linux или macOS

Если вы используете Linux или macOS, пожалуйста, выполните следующую команду:

$ curl --proto '=https' --tlsv1.3 https://sh.rustup.rs -sSf | sh

Команда скачивает скрипт и начинает установку инструмента rustup, одновременно с установкой последней стабильной версии Rust. Вас могут запросить ввести локальный пароль. При успешной установке вы увидите следующий вывод:

Rust is installed now. Great!

Вам также понадобится компоновщик - программа, которую Rust использует для объединения своих скомпилированных выходных данных в один файл. Скорее всего, он у вас уже есть. При возникновении ошибок компоновки, вам следует установить компилятор C, который обычно будет включать в себя и компоновщик. Компилятор C также полезен, потому что некоторые распространённые пакеты Rust зависят от кода C и нуждаются в компиляторе C.

На macOS вы можете получить компилятор C, выполнив команду:

$ xcode-select --install

Пользователи Linux, как правило, должны устанавливать GCC или Clang в соответствии с документацией их дистрибутива. Например, при использовании Ubuntu можно установить пакет build-essential.

Установка rustup на Windows

В Windows перейдите на https://www.rust-lang.org/tools/install и следуйте инструкциям по установке Rust. В какой-то момент установки вы получите сообщение о том, что вам также потребуются инструменты сборки MSVC для Visual Studio 2013 или более поздней версии. Чтобы получить инструменты сборки, вам необходимо установить Visual Studio 2022. Когда вас спросят, что именно установить, укажите:

  • “Desktop Development with C++”
  • The Windows 10 or 11 SDK
  • Английский языковой пакет вместе с любым другим языковым пакетом по вашему выбору.

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

Устранение возможных ошибок

Чтобы проверить, правильно ли у вас установлен Rust, откройте оболочку и введите эту строку:

$ rustc --version

Вы должны увидеть номер версии, хэш коммита и дату выпуска последней стабильной версии в следующем формате:

rustc x.y.z (abcabcabc yyyy-mm-dd)

Если вы видите данную информацию, то вы установили всё успешно! Если вы не видите этой информации и используете Windows, проверьте, что путь к Rust находится в системной переменной %PATH%. Если он корректный, но Rust все ещё не работает, то есть множество мест, где можно получить помощь. Самое простое это канал #beginners официального Rust Discord сервера. Там вы можете пообщаться с другими Rustaceans (это наше шуточное прозвище), которые смогут вам помочь. Другие замечательные ресурсы включают Пользовательский форум и Stack Overflow.

Обновление и удаление

После установки Rust через rustup при выходе новой версии Rust обновление до последней версии не составит труда. В вашем терминале запустите следующий скрипт обновления:

$ rustup update

Чтобы удалить Rust и rustup, выполните
следующую команду:

$ rustup self uninstall

Локальная документация

Установка Rust также включает в себя локальную копию документации, поэтому вы можете читать её в оффлайн режиме. Запустите rustup doc, чтобы открыть локальную документацию в браузере.

Каждый раз, когда есть какой-либо тип или какая-либо функция, предоставляемые стандартной библиотекой, а вы не знаете, что они делают и как их использовать, воспользуйтесь документацией по интерфейсу прикладного программирования (API) для поиска!

Привет, мир!

Итак, когда Rust уже установлен, можно приступать к написанию вашей первой программы. Общая традиция при изучении нового языка программирования - писать маленькую программу, которая печатает в строке вывода "Hello, world!". Давайте сделаем то же самое.

Примечание. В этой книге предполагается базовое знакомство с командной строкой. Rust не предъявляет особых требований к вашему редактору кода или инструментам, поэтому, если вы предпочитаете использовать интегрированную среду разработки (IDE) вместо командной строки, не стесняйтесь использовать свою любимую IDE. Многие IDE имеют поддержку Rust; подробности можно найти в документации по IDE. В последнее время команда Rust сосредоточилась на обеспечении отличной поддержки IDE, и в этом направлении был достигнут большой прогресс!

Создание папки проекта

Прежде всего начнём с создания директории, в которой будем сохранять наш код на языке Rust. На самом деле не важно, где сохранять наш код. Однако, для упражнений и проектов, обсуждаемых в данной книге, мы советуем создать директорию projects в вашем домашнем каталоге, там же и хранить в будущем код программ из книги.

Откройте терминал и введите следующие команды для того, чтобы создать директорию projects для хранения кода разных проектов, и, внутри неё, директорию hello_world для проекта “Hello, world!”.

Для Linux, macOS и PowerShell на Windows, введите:

$ mkdir ~/projects
$ cd ~/projects
$ mkdir hello_world
$ cd hello_world

Для Windows в CMD, введите:

> mkdir "%USERPROFILE%\projects"
> cd /d "%USERPROFILE%\projects"
> mkdir hello_world
> cd hello_world

Написание и запуск первой Rust программы

Затем создайте новый исходный файл и назовите его main.rs. Файлы Rust всегда заканчиваются расширением .rs. Если вы используете более одного слова в имени файла, принято разделять их символом подчёркивания. Например, используйте hello_world.rs вместо helloworld.rs.

Теперь откроем файл main.rs для редактирования и введём следующие строки кода:

Название файла: main.rs

fn main() {
    println!("Hello, world!");
}

Листинг 1-1: Программа которая печатает Hello, world!

Сохраните файл и вернитесь в окно терминала в каталог ~/projects/hello_world. В Linux или macOS введите следующие команды для компиляции и запуска файла:

$ rustc main.rs
$ ./main
Hello, world!

В Windows, введите команду .\main.exe вместо ./main:

> rustc main.rs
> .\main.exe
Hello, world!

Независимо от операционной системы, строка Hello, world! должна напечататься в окне вашего терминала. Если вы не увидели вывода, вернитесь в часть "Решение проблем" "Troubleshooting" раздела "Установка" для получения помощи.

Если напечаталось Hello, world!, то примите наши поздравления! Вы написали программу на Rust, что делает вас Rust программистом — добро пожаловать!

Анатомия программы на Rust

Давайте рассмотрим «Hello, world!» программу в деталях. Вот первая часть головоломки:

fn main() {

}

Эти строки определяют функцию с именем main. Функция main особенная: это всегда первый код, который запускается в каждой исполняемой программе Rust. Первая строка объявляет функцию с именем main, которая не имеет параметров и ничего не возвращает. Если бы были параметры, они бы заключались в круглые скобки ().

Тело функции заключено в {}. Rust требует фигурных скобок вокруг всех тел функций. Хороший стиль — поместить открывающую фигурную скобку на ту же строку, что и объявление функции, добавив между ними один пробел.

Примечание. Если вы хотите придерживаться стандартного стиля в проектах на Rust, вы можете использовать инструмент автоматического форматирования под названием rustfmt для форматирования кода в определённом стиле (подробнее о rustfmt в Приложении D). ). Команда Rust включила этот инструмент в стандартный дистрибутив Rust, например, rustc, поэтому он уже должен быть установлен на вашем компьютере!

Тело функции main содержит следующий код:


#![allow(unused)]
fn main() {
    println!("Hello, world!");
}

Эта строка делает всю работу в этой маленькой программе: печатает текст на экран. Можно заметить четыре важных детали.

Первая, не столь заметная, - в стиле Rust для отступа используются четыре пробела, а не знак табуляции.

Во-вторых, println! вызывает макрос Rust. Если бы вместо этого была вызвана функция, она была бы введена как println (без !). Мы обсудим макросы Rust более подробно в Главе 19. Сейчас вам просто нужно знать, что использование ! означает, что вы вызываете макрос вместо обычной функции, и что макросы не всегда следуют тем же правилам, что и функции.

В-третьих, вы видите строку "Hello, world!". Мы передаём её в качестве аргумента функции println!, и она выводится на экран.

В-четвёртых, мы заканчиваем строку точкой с запятой (;), которая указывает на то, что это выражение закончилось и готово начаться следующее. Большинство строк кода на Rust заканчиваются точкой с запятой.

Компиляция и выполнение кода являются отдельными шагами

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

Перед запуском программы на Rust вы должны скомпилировать её с помощью компилятора Rust, введя команду rustc и передав ей имя вашего исходного файла, например:

$ rustc main.rs

Если у вас есть опыт работы с C или C++, вы заметите, что это похоже на gcc или clang. После успешной компиляции Rust выводит двоичный исполняемый файл.

На Linux, macOS и с PowerShell на Windows вы можете увидеть исполняемый файл, введя команду ls в своём терминале. В Linux и macOS вы увидите два файла. С PowerShell в Windows вы увидите те же три файла, что и при использовании CMD.

$ ls
main  main.rs

В CMD на Windows следует ввести следующие команды:

> dir /B %= the /B option says to only show the file names =%
main.exe
main.pdb
main.rs

Это показывает исходный код файла с расширением .rs, исполняемый файл (main.exe на Windows, но main на всех других платформах) и, при использовании Windows, файл, содержащий отладочную информацию с расширением .pdb. Отсюда вы запускаете файлы main или main.exe, например:

$ ./main # для Linux
> .\main.exe # для Windows

Если ваш main.rs — это ваша программа «Hello, world!», эта строка выведет в терминал Hello, world!.

Если вы лучше знакомы с динамическими языками, такими как Ruby, Python или JavaScript, возможно, вы не привыкли компилировать и запускать программу как отдельные шаги. Rust — это предварительно скомпилированный язык, то есть вы можете скомпилировать программу и передать исполняемый файл кому-то другому, и он сможет запустить его даже без установленного Rust. Если вы даёте кому-то файл .rb , .py или .js, у него должна быть установлена реализация Ruby, Python или JavaScript (соответственно). Но в этих языках вам нужна только одна команда для компиляции и запуска вашей программы. В дизайне языков программирования всё — компромисс.

Компиляция с помощью rustc подходит для простых программ, но по мере роста вашего проекта вы захотите управлять всеми параметрами и упростить передачу кода. Далее мы познакомим вас с инструментом Cargo, который поможет вам писать программы из реального мира на Rust.

Привет, Cargo!

Cargo - это система сборки и менеджер пакетов Rust. Большая часть разработчиков используют данный инструмент для управления проектами, потому что Cargo выполняет за вас множество задач, таких как сборка кода, загрузка библиотек, от которых зависит ваш код, и создание этих библиотек. (Мы называем библиотеки, которые нужны вашему коду, зависимостями.)

Самые простые программы на Rust, подобные той, которую мы написали, не имеют никаких зависимостей. Если бы мы сделали проект «Hello, world!» с Cargo, он бы использовал только ту часть Cargo, которая отвечает за компиляцию вашего кода. По мере написания более сложных программ на Rust вы будете добавлять зависимости, а если вы начнёте проект с использованием Cargo, добавлять зависимости станет намного проще.

Так как большая часть проектов использует Cargo, то остальная часть книги подразумевает, что вы также используете Cargo. Cargo устанавливается вместе с Rust при использовании официальных установщиков обсуждаемых в разделе "Установка Rust". Если вы установили Rust другим способом, то проверьте, работает ли он, введя команду проверки версии Cargo в терминале:

$ cargo --version

Если команда выдала номер версии, то значит Cargo установлен. Если вы видите ошибку, вроде command not found ("команда не найдена"), загляните в документацию для использованного вами способа установки, чтобы выполнить установку Cargo отдельно.

Создание проекта с помощью Cargo

Давайте создадим новый проект с помощью Cargo и посмотрим, как он отличается от нашего начального проекта "Hello, world!". Перейдите обратно в папку projects (или любую другую, где вы решили сохранять код). Затем, в любой операционный системе, запустите команду:

$ cargo new hello_cargo
$ cd hello_cargo

Первая команда создаёт новый каталог и проект с именем hello_cargo. Мы назвали наш проект hello_cargo, и Cargo создаёт свои файлы в каталоге с тем же именем.

Перейдём в каталог hello_cargo и посмотрим файлы. Увидим, что Cargo сгенерировал два файла и одну директорию: файл Cargo.toml и каталог src с файлом main.rs внутри.

Кроме того, cargo инициализировал новый репозиторий Git вместе с файлом .gitignore. Файлы Git не будут сгенерированы, если вы запустите cargo new в существующем репозитории Git; вы можете изменить это поведение, используя cargo new --vcs=git.

Примечание. Git — это распространённая система контроля версий. Вы можете изменить cargo new так, чтобы использовать другую систему контроля версий или не использовать систему контроля версий, используя флаг --vcs. Запустите cargo new --help, чтобы увидеть доступные параметры.

Откройте файл Cargo.toml в любом текстовом редакторе. Он должен выглядеть как код в листинге 1-2.

Файл: Cargo.toml

[package]
name = "hello_cargo"
version = "0.1.0"
edition = "2021"

[dependencies]

Листинг 1-2: Содержимое Cargo.toml, сгенерированное с помощью cargo new

Это файл в формате TOML (Tom’s Obvious, Minimal Language), который является форматом конфигураций Cargo.

Первая строка, [package], является заголовочной секцией, которая указывает что следующие инструкции настраивают пакет. По мере добавления больше информации в данный файл, будет добавляться больше секций и инструкций (строк).

Следующие три строки задают информацию о конфигурации, необходимую Cargo для компиляции вашей программы: имя, версию и редакцию Rust, который будет использоваться. Мы поговорим о ключе edition в Приложении E.

Последняя строка, [dependencies] является началом секции для списка любых зависимостей вашего проекта. В Rust, это внешние пакеты кода, на которые ссылаются ключевым словом crate. Нам не нужны никакие зависимости в данном проекте, но мы будем использовать их в первом проекте главы 2, так что нам пригодится данная секция зависимостей потом.

Откройте файл src/main.rs и загляните в него:

Файл: src/main.rs

fn main() {
    println!("Hello, world!");
}

Cargo сгенерировал для вас программу «Hello, world!», точно такую же, как та, которую мы написали в Листинге 1-1! На данный момент различия между нашим проектом и проектом, сгенерированным Cargo, заключаются в том, что Cargo поместил код в каталог src, и у нас есть файл конфигурации Cargo.toml в каталоге верхнего уровня.

Cargo ожидает, что ваши исходные файлы находятся внутри каталога src. Каталог верхнего уровня проекта предназначен только для файлов README, информации о лицензии, файлы конфигурации и чего то ещё не относящего к вашему коду. Использование Cargo помогает организовывать проект. Есть место для всего и все находится на своём месте.

Если вы начали проект без использования Cargo, как мы делали для "Hello, world!" проекта, то можно конвертировать его в проект с использованием Cargo. Переместите код в подкаталог src и создайте соответствующий файл Cargo.toml в папке.

Сборка и запуск Cargo проекта

Посмотрим, в чем разница при сборке и запуске программы "Hello, world!" с помощью Cargo. В каталоге hello_cargo соберите проекта следующей командой:

$ cargo build
   Compiling hello_cargo v0.1.0 (file:///projects/hello_cargo)
    Finished dev [unoptimized + debuginfo] target(s) in 2.85 secs

Данная команда создаёт выполняемый файл в папке target/debug/hello_cargo (или target\debug\hello_cargo.exe на Windows), а не в текущей директории проекта. Можно запустить исполняемый файл командой:

$ ./target/debug/hello_cargo # or .\target\debug\hello_cargo.exe on Windows
Hello, world!

Если все хорошо, то Hello, world! печатается в терминале. Запуск команды cargo build в первый раз также приводит к созданию нового файла Cargo.lock в папке верхнего уровня. Данный файл хранит точные версии зависимостей вашего проекта. Так как у нас нет зависимостей, то файл пустой. Вы никогда не должны менять этот файл вручную: Cargo сам управляет его содержимым для вас.

Мы только что собрали проект командой cargo build и запустили его из ./target/debug/hello_cargo. Но мы также можем использовать команду cargo run для компиляции кода и затем его запуска одной командой:

$ cargo run
    Finished dev [unoptimized + debuginfo] target(s) in 0.0 secs
     Running `target/debug/hello_cargo`
Hello, world!

Использование cargo run более удобно, чем необходимость помнить и запускать cargo build, а затем использовать весь путь к бинарному файлу, поэтому большинство разработчиков используют cargo run.

Обратите внимание, что на этот раз мы не видели вывода, указывающего на то, что Cargo компилирует hello_cargo. Cargo выяснил, что файлы не изменились, поэтому не стал пересобирать, а просто запустил бинарный файл. Если бы вы изменили свой исходный код, Cargo пересобрал бы проект перед его запуском, и вы бы увидели этот вывод:

$ cargo run
   Compiling hello_cargo v0.1.0 (file:///projects/hello_cargo)
    Finished dev [unoptimized + debuginfo] target(s) in 0.33 secs
     Running `target/debug/hello_cargo`
Hello, world!

Cargo также предоставляет команду, называемую cargo check. Эта команда быстро проверяет ваш код, чтобы убедиться, что он компилируется, но не создаёт исполняемый файл:

$ cargo check
   Checking hello_cargo v0.1.0 (file:///projects/hello_cargo)
    Finished dev [unoptimized + debuginfo] target(s) in 0.32 secs

Почему вам не нужен исполняемый файл? Часто cargo check выполняется намного быстрее, чем cargo build, поскольку пропускает этап создания исполняемого файла. Если вы постоянно проверяете свою работу во время написания кода, использование cargo check ускорит процесс информирования вас о том, что ваш проект всё ещё компилируется! Таким образом, многие Rustacean периодически запускают cargo check, когда пишут свои программы, чтобы убедиться, что она компилируется. Затем они запускают cargo build, когда готовы использовать исполняемый файл.

Повторим полученные знания про Cargo:

  • Мы можем создать проект с помощью cargo new.
  • можно собирать проект, используя команду cargo build,
  • можно одновременно собирать и запускать проект одной командой cargo run,
  • можно собрать проект для проверки ошибок с помощью cargo check, не тратя время на кодогенерацию исполняемого файла,
  • cargo сохраняет результаты сборки не в директорию с исходным кодом, а в отдельный каталог target/debug.

Дополнительным преимуществом использования Cargo является то, что его команды одинаковы для разных операционных систем. С этой точки зрения, мы больше не будем предоставлять отдельные инструкции для Linux, macOS или Windows.

Сборка финальной версии (Release)

Когда проект, наконец, готов к релизу, можно использовать команду cargo build --release для его компиляции с оптимизацией. Данная команда создаёт исполняемый файл в папке target/release в отличии от папки target/debug. Оптимизации делают так, что Rust код работает быстрее, но их включение увеличивает время компиляции. По этой причине есть два отдельных профиля: один для разработки, когда нужно осуществлять сборку быстро и часто, и другой, для сборки финальной программы, которую будете отдавать пользователям, которая готова к работе и будет выполняться максимально быстро. Если вы замеряете время выполнения вашего кода, убедитесь, что собрали проект с оптимизацией cargo build --release и тестируете исполняемый файл из папки target/release.

Cargo как Конвенция

Для простых проектов Cargo не даёт большой пользы по сравнению с использованием rustc, но он докажет свою пользу как только ваши программы станут более запутанными. С помощью Cargo гораздо проще координировать сборку на сложных проектах, скомбинированных из множества внешних библиотек (crates).

Не смотря на то, что проект hello_cargo простой, теперь он использует большую часть реального инструментария, который вы будете повседневно использовать в вашей карьере, связанной с Rust. Когда потребуется работать над проектами размещёнными в сети, вы сможете просто использовать следующую последовательность команд для получения кода с помощью Git, перехода в каталог проекта, сборку проекта:

$ git clone example.org/someproject
$ cd someproject
$ cargo build

Для более детальной информации про Cargo, загляните в его документацию.

Итоги

Теперь вы готовы начать своё Rust путешествие! В данной главе вы изучили как:

  • установить последнюю стабильную версию Rust, используя rustup,
  • обновить Rust до последней версии,
  • открыть локально установленную документацию,
  • написать и запустить программу типа "Hello, world!", используя напрямую компилятор rustc,
  • создать и запустить новый проект, используя соглашения и команды Cargo.

Пришло время для создания более содержательной программы, чтобы привыкнуть к чтению и написанию кода на Rust. В главе 2 мы создадим программу для угадывания числа. Если вы хотите начать с изучения общих концепций программирования в Rust, загляните в главу 3, а затем вернитесь к главе 2.

Программируем игру Угадайка

Давайте погрузимся в Rust, вместе выполнив практический проект! Эта глава познакомит с несколькими распространёнными концепциями Rust, показав, как использовать их в реальной программе. Вы узнаете о let, match, методах, ассоциированных функциях, использовании внешних пакетов и многом другом! В следующих главах рассмотрим эти идеи более подробно. В этой главе вы на практике познакомитесь с основами.

Мы реализуем классическую для начинающих программистов задачу: игру в угадывание. Вот как это работает: программа генерирует случайное целое число в диапазоне от 1 до 100. Затем она предлагает игроку ввести отгадку. После ввода отгадки программа укажет, является ли отгадка слишком заниженной или слишком завышенной. Если отгадка верна, игра напечатает поздравительное сообщение и завершится.

Настройка нового проекта

Для настройки нового проекта перейдите в каталог projects, который вы создали в главе 1, и создайте новый проект с использованием Cargo, как показано ниже:

$ cargo new guessing_game
$ cd guessing_game

Первая команда, cargo new, принимает в качестве первого аргумента имя проекта (guessing_game). Вторая команда изменяет каталог на новый каталог проекта.

Загляните в созданный файл Cargo.toml:

Файл: Cargo.toml

[package]
name = "guessing_game"
version = "0.1.0"
edition = "2021"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies]

Как вы уже видели в Главе 1, cargo new создаёт программу "Hello, world!". Посмотрите файл src/main.rs:

Файл: src/main.rs

fn main() {
    println!("Hello, world!");
}

Теперь давайте скомпилируем программу "Hello, world!" и сразу на этом же этапе запустим её с помощью команды cargo run:

$ cargo run
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
    Finished dev [unoptimized + debuginfo] target(s) in 1.50s
     Running `target/debug/guessing_game`
Hello, world!

Команда run пригодится, когда необходимо ускоренно выполнить итерацию проекта, мы так собираемся сделать в этом проекте, быстро тестируя каждую итерацию, прежде чем перейти к следующей.

Снова откройте файл src/main.rs. Весь код вы будете писать в этом файле.

Обработка отгадки

Первая часть программы игры угадывания запрашивает ввод данных пользователем, обрабатывает их и проверяет, что вводимые данные имеют ожидаемую форму. Для начала мы позволим игроку ввести отгадку. Введите код из Листинга 2-1 в src/main.rs.

Файл: src/main.rs

use std::io;

fn main() {
    println!("Guess the number!");

    println!("Please input your guess.");

    let mut guess = String::new();

    io::stdin()
        .read_line(&mut guess)
        .expect("Failed to read line");

    println!("You guessed: {guess}");
}

Листинг 2-1: Код, который получает отгадку от пользователя и печатает её

Этот код содержит много информации, поэтому давайте рассмотрим его построчно. Чтобы получить пользовательский ввод и затем вывести результат в качестве вывода, нам нужно включить в область видимости библиотеку ввода/вывода io. Библиотека io является частью стандартной библиотеки, известной как std:

use std::io;

fn main() {
    println!("Guess the number!");

    println!("Please input your guess.");

    let mut guess = String::new();

    io::stdin()
        .read_line(&mut guess)
        .expect("Failed to read line");

    println!("You guessed: {guess}");
}

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

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

Как уже отмечалось в главе 1, функция main является точкой входа в программу:

use std::io;

fn main() {
    println!("Guess the number!");

    println!("Please input your guess.");

    let mut guess = String::new();

    io::stdin()
        .read_line(&mut guess)
        .expect("Failed to read line");

    println!("You guessed: {guess}");
}

Синтаксис fn объявляет новую функцию, круглые скобки () указывают на отсутствие параметров, а фигурная скобка { обозначает начало тела функции.

Также в главе 1 упоминалось, что println! - это макрос, который печатает строку на экран:

use std::io;

fn main() {
    println!("Guess the number!");

    println!("Please input your guess.");

    let mut guess = String::new();

    io::stdin()
        .read_line(&mut guess)
        .expect("Failed to read line");

    println!("You guessed: {guess}");
}

Этот код печатает подсказку об игре и запрашивает пользовательский ввод.

Хранение значений с помощью переменных

Далее мы создаём переменную для хранения пользовательского ввода, как показано ниже:

use std::io;

fn main() {
    println!("Guess the number!");

    println!("Please input your guess.");

    let mut guess = String::new();

    io::stdin()
        .read_line(&mut guess)
        .expect("Failed to read line");

    println!("You guessed: {guess}");
}

Вот теперь программа становится интересней! Очень многое происходит в этой маленькой строке. Для создания переменной мы используем оператор let. Вот ещё один пример:

let apples = 5;

Эта строка создаёт новую переменную с именем apples и привязывает её к значению 5. В Rust переменные неизменяемы по умолчанию, то есть, как только мы присвоим переменной значение, значение не изменится. Мы подробно обсудим эту концепцию в разделе «Переменные и изменчивость». в Главе 3. Чтобы сделать переменную изменяемой, мы добавляем mut перед её именем:

let apples = 5; // неизменяемая
let mut bananas = 5; // изменяемая

Примечание: Синтаксис // означает начало комментария, который продолжается до конца строки. Rust игнорирует все содержимое комментариев. Подробнее о комментариях мы поговорим в главе 3.

Возвращаясь к программе игры Угадайка, теперь вы знаете, что let mut guess предоставит изменяемую переменную с именем guess. Знак равенства (=) сообщает Rust, что сейчас нужно связать что-то с этой переменной. Справа от знака равенства находится значение, связанное с guess, которое является результатом вызова функции String::new, возвращающей новый экземпляр String. String - это тип строки, предоставляемый стандартной библиотекой, который является расширяемым фрагментом текста в кодировке UTF-8.

Синтаксис :: в строке ::new указывает, что new является ассоциированной функцией String типа. Ассоциированная функция - это функция, реализованная для типа, в данном случае String. Функция new создаёт новую, пустую строку. Функцию new можно встретить во многих типах, это типичное название для функции, которая создаёт новое значение какого-либо типа.

В целом, строка let mut guess = String::new(); создала изменяемую переменную, которая связывается с новым, пустым экземпляром String. Фух!

Получение пользовательского ввода

Напомним, мы подключили функциональность ввода/вывода из стандартной библиотеки с помощью use std::io; в первой строке программы. Теперь мы вызовем функцию stdin из модуля io, которая позволит нам обрабатывать пользовательский ввод:

use std::io;

fn main() {
    println!("Guess the number!");

    println!("Please input your guess.");

    let mut guess = String::new();

    io::stdin()
        .read_line(&mut guess)
        .expect("Failed to read line");

    println!("You guessed: {guess}");
}

Если бы мы не импортировали библиотеку io с помощью use std::io в начале программы, мы все равно могли бы использовать эту функцию, записав вызов этой функции как std::io::stdin. Функция stdin возвращает экземпляр std::io::Stdin, который является типом, представляющим дескриптор стандартного ввода для вашего терминала.

Далее строка .read_line(&mut guess) вызывает метод read_line на дескрипторе стандартного ввода для получения ввода от пользователя. Мы также передаём &mut guess в качестве аргумента read_line, сообщая ему, в какой строке хранить пользовательский ввод. Главная задача read_line - принять все, что пользователь вводит в стандартный ввод, и сложить это в строку (не переписывая её содержимое), поэтому мы передаём эту строку в качестве аргумента. Строковый аргумент должен быть изменяемым, чтобы метод мог изменить содержимое строки.

Символ & указывает, что этот аргумент является ссылкой, который предоставляет возможность нескольким частям вашего кода получить доступ к одному фрагменту данных без необходимости копировать эти данные в память несколько раз. Ссылки - это сложная функциональная возможность, а одним из главных преимуществ Rust является безопасность и простота использования ссылок. Чтобы дописать эту программу, вам не понадобится знать много таких подробностей. Пока вам достаточно знать, что ссылки, как и переменные, по умолчанию неизменяемы. Соответственно, чтобы сделать её изменяемой, нужно написать &mut guess, а не &guess. (В главе 4 ссылки будут описаны более подробно).

Обработка потенциального сбоя с помощью типа Result

Мы все ещё работаем над этой строкой кода. Сейчас мы обсуждаем третью строку, но обратите внимание, что она по-прежнему является частью одной логической строки кода. Следующая часть - метод:

use std::io;

fn main() {
    println!("Guess the number!");

    println!("Please input your guess.");

    let mut guess = String::new();

    io::stdin()
        .read_line(&mut guess)
        .expect("Failed to read line");

    println!("You guessed: {guess}");
}

Мы могли бы написать этот код так:

io::stdin().read_line(&mut guess).expect("Failed to read line");

Однако одну длинную строку трудно читать, поэтому лучше разделить её. При вызове метода с помощью синтаксиса .method_name() часто целесообразно вводить новую строку и другие пробельные символы, чтобы разбить длинные строки. Теперь давайте обсудим, что делает эта строка.

Как упоминалось ранее, read_line помещает всё, что вводит пользователь, в строку, которую мы ему передаём, но также возвращает значение Result. Result это перечисление, часто называемый enum, тип, который может находиться в одном из нескольких возможных состояний. Мы называем каждое такое состояние вариантом.

В главе 6 перечисления будут рассмотрены более подробно. Назначение всех типов Result заключается в передаче информации для обработки ошибок.

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

Значения типа Result, как и значения любого типа, имеют определённые для них методы. Экземпляр Result имеет expect метод, который можно вызвать. Если этот экземпляр Result является значением Err, expect вызовет сбой программы и отобразит сообщение, которое вы передали в качестве аргумента. Если метод read_line возвращает Err, это, скорее всего, результат ошибки базовой операционной системы. Если экземпляр Result является значением Ok, expect возьмёт возвращаемое значение, которое Ok удерживает, и вернёт вам только это значение, чтобы вы могли его использовать. В данном случае это значение представляет собой количество байтов, введённых пользователем.

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

$ cargo build
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
warning: unused `Result` that must be used
  --> src/main.rs:10:5
   |
10 |     io::stdin().read_line(&mut guess);
   |     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
   |
   = note: `#[warn(unused_must_use)]` on by default
   = note: this `Result` may be an `Err` variant, which should be handled

warning: `guessing_game` (bin "guessing_game") generated 1 warning
    Finished dev [unoptimized + debuginfo] target(s) in 0.59s

Rust предупреждает о не использовании значения Result, возвращаемого из read_line, показывая, что программа не учла возможность возникновения ошибки.

Правильный способ убрать предупреждение - это написать обработку ошибок, но в нашем случае мы просто хотим аварийно завершить программу при возникновении проблемы, поэтому используем expect. О способах восстановления после ошибок вы узнаете в главе 9.

Напечатать значений с помощью заполнителей println!

Кроме закрывающей фигурной скобки, в коде на данный момент есть ещё только одна строка для обсуждения:

use std::io;

fn main() {
    println!("Guess the number!");

    println!("Please input your guess.");

    let mut guess = String::new();

    io::stdin()
        .read_line(&mut guess)
        .expect("Failed to read line");

    println!("You guessed: {guess}");
}

Эта строка печатает строку, которая теперь содержит ввод пользователя. Набор фигурных скобок {} является заполнителем: думайте о {} как о маленьких крабовых клешнях, удерживающих значение на месте. С помощью фигурных скобок можно вывести более одного значения: первый набор фигурных скобок содержит первое значение, указанное после форматирующей строки, второй набор - второе значение и так далее. Печать нескольких значений за один вызов println! будет выглядеть следующим образом:


#![allow(unused)]
fn main() {
let x = 5;
let y = 10;

println!("x = {} and y = {}", x, y);
}

Этот код напечатает x = 5 and y = 10.

Тестирование первой части

Давайте протестирует первую часть игры. Запустите её используя cargo run:

$ cargo run
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
    Finished dev [unoptimized + debuginfo] target(s) in 6.44s
     Running `target/debug/guessing_game`
Guess the number!
Please input your guess.
6
You guessed: 6

На данном этапе первая часть игры завершена: мы получаем ввод с клавиатуры и затем печатаем его.

Генерация секретного числа

Далее нам нужно сгенерировать секретное число, которое пользователь попытается угадать. Секретное число должно быть каждый раз разным, чтобы в игру можно было играть несколько раз. Мы будем использовать случайное число в диапазоне от 1 до 100, чтобы игра не была слишком сложной. Rust пока не включает функциональность случайных чисел в свою стандартную библиотеку. Однако команда Rust предоставляет rand crate с подобной функциональностью.

Использование пакета для получения дополнительной функциональности

Запомните, что крейт — это набор файлов с исходным кодом Rust. Проект, который мы создавали, представляет собой двоичный крейт, являющийся исполняемым файлом. rand крейт — это библиотечный крейт, содержащий код, предназначенный для использования в других программах, и не может быть выполнен сам по себе.

Координация работы внешних пакетов является тем местом, где Cargo действительно блистает. Чтобы начать писать код, использующий rand, необходимо изменить файл Cargo.toml, включив в него в качестве зависимости пакет rand. Итак, откройте этот файл и добавьте следующую строку внизу под заголовком секции [dependencies], созданным для вас Cargo. Обязательно укажите rand в точности как здесь, с таким же номером версии, иначе примеры кода из этого урока могут не заработать.

Имя файла: Cargo.toml

rand = "0.8.3"

В файле Cargo.toml всё, что следует за заголовком, является частью этой секции, которая продолжается до тех пор, пока не начнётся следующая. В [dependencies] вы сообщаете Cargo, от каких внешних крейтов зависит ваш проект и какие версии этих крейтов вам нужны. В этом случае мы указываем крейт rand со спецификатором семантической версии 0.8.3. Cargo понимает семантическое версионирование (иногда называемое SemVer), которое является стандартом для описания версий. Число 0.8.3 на самом деле является сокращением от ^0.8.3, что означает любую версию не ниже 0.8.3, но ниже 0.9.0.

Cargo рассчитывает, что эти версии имеют общедоступное API, совместимое с версией 0.8.3, и вы получите последние версии исправлений, которые по-прежнему будут компилироваться с кодом из этой главы. Не гарантируется, что версия 0.9.0 или выше будет иметь тот же API, что и в следующих примерах.

Теперь, ничего не меняя в коде, давайте создадим проект, как показано в Листинге 2-2.

$ cargo build
    Updating crates.io index
  Downloaded rand v0.8.3
  Downloaded libc v0.2.86
  Downloaded getrandom v0.2.2
  Downloaded cfg-if v1.0.0
  Downloaded ppv-lite86 v0.2.10
  Downloaded rand_chacha v0.3.0
  Downloaded rand_core v0.6.2
   Compiling rand_core v0.6.2
   Compiling libc v0.2.86
   Compiling getrandom v0.2.2
   Compiling cfg-if v1.0.0
   Compiling ppv-lite86 v0.2.10
   Compiling rand_chacha v0.3.0
   Compiling rand v0.8.3
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
    Finished dev [unoptimized + debuginfo] target(s) in 2.53s

Листинг 2-2: Результат выполнения cargo build после добавления крейта rand в качестве зависимости

Вы можете увидеть другие номера версий (но все они будут совместимы с кодом, благодаря SemVer!), другие строки (в зависимости от операционной системы), а также строки могут быть расположены в другом порядке.

Когда мы включаем внешнюю зависимость, Cargo берет последние версии всего, что нужно этой зависимости, из реестра (registry), который является копией данных с Crates.io. Crates.io - это место, где участники экосистемы Rust размещают свои проекты Rust с открытым исходным кодом для использования другими.

После обновления реестра Cargo проверяет раздел [dependencies] и загружает все указанные в списке пакеты, которые ещё не были загружены. В нашем случае, хотя мы указали только rand в качестве зависимости, Cargo также захватил другие пакеты, от которых зависит работа rand. После загрузки пакетов Rust компилирует их, а затем компилирует проект с имеющимися зависимостями.

Если вы немедленно снова запустите cargo build без внесения каких-либо изменений, вы не получите никакого вывода, кроме строки Finished. Cargo знает, что он уже выгрузил и скомпилировал зависимости, и вы ничего не изменили в файле Cargo.toml. Cargo также знает, что вы ничего не меняли в своём коде, поэтому он также не станет перекомпилировать его. Ввиду отсутствия задач он просто выходит.

Если открыть файл src/main.rs, внести незначительные изменения, а затем сохранить его и снова произвести сборку, то вы увидите только две строки вывода:

$ cargo build
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
    Finished dev [unoptimized + debuginfo] target(s) in 2.53 secs

Эти строки показывают, что Cargo обновляет сборку только на основании вашего крошечного изменения в файле src/main.rs. Поскольку зависимости не изменились, Cargo знает, что может повторно использовать ранее загруженные и скомпилированные зависимости.

Обеспечение воспроизводимых сборок с помощью файла Cargo.lock

В Cargo есть механизм, обеспечивающий возможность пересобрать все тот же артефакт каждый раз, когда вы или кто-либо другой собирает ваш код. Пока вы не укажете обратное, Cargo будет использовать только те версии зависимостей, которые были заданы ранее. Например, допустим, что на следующей неделе выходит версия 0.8.4 пакета rand , и эта версия содержит важное исправление ошибки, но также содержит регрессию, которая может сломать ваш код. Чтобы справиться с этим, Rust создаёт файл Cargo.lock при первом запуске cargo build, поэтому теперь он есть в каталоге guessing_game.

Когда вы создаёте проект в первый раз, Cargo определяет все версии зависимостей, которые соответствуют критериям, а затем записывает их в файл Cargo.lock. Когда вы будете собирать свой проект в будущем, Cargo увидит, что файл Cargo.lock существует, и будет использовать указанные там версии, а не выполнять всю работу по выяснению версий заново. Это позволяет автоматически создавать воспроизводимую сборку. Другими словами, ваш проект останется на 0.8.3 до тех пор, пока вы явно не обновите его благодаря файлу Cargo.lock. Поскольку файл Cargo.lock важен для воспроизводимых сборок, он часто хранится в системе управления версиями вместе с остальным кодом проекта.

Обновление пакета для получения новой версии

Если вы захотите обновить пакет, Cargo предоставляет команду update, которая игнорирует файл Cargo.lock и определяет последние версии, соответствующие вашим спецификациям из файла Cargo.toml. После этого Cargo запишет эти версии в файл Cargo.lock. Иначе, по умолчанию, Cargo будет искать только версии больше 0.8.3 , но при этом меньше 0.9.0. Если пакет rand имеет две новые версии 0.8.4 и 0.9.0, то при запуске cargo update вы увидите следующее:

$ cargo update
    Updating crates.io index
    Updating rand v0.8.3 -> v0.8.4

Cargo игнорирует релиз 0.9.0. В этот момент также появится изменение в файле Cargo.lock, указывающее на то, что версия rand, которая теперь используется, равна 0.8.4. Чтобы использовать rand версии 0.9.0 или любой другой версии из серии 0.9.x, необходимо обновить файл Cargo.toml следующим образом:

[dependencies]
rand = "0.9.0"

В следующий раз, когда вы запустите Cargo cargo build, Cargo обновит реестр доступных крейтов и пересмотрит ваши требования к rand в соответствии с новой версией, которую вы указали.

Ещё многое возможно сказать о Cargo и его экосистеме, которые мы обсудим в Главе 14, а пока это всё, что вам нужно знать. Cargo упрощает повторное использование библиотек, поэтому Rustaceans могут создавать проекты меньшего размера, собранные из нескольких пакетов.

Генерация случайного числа

Давайте начнём использовать rand чтобы сгенерировать число для угадывания. Следующим шагом будет обновление src/main.rs, как показано в Листинге 2-3.

Имя файла: src/main.rs

use std::io;
use rand::Rng;

fn main() {
    println!("Guess the number!");

    let secret_number = rand::thread_rng().gen_range(1..=100);

    println!("The secret number is: {secret_number}");

    println!("Please input your guess.");

    let mut guess = String::new();

    io::stdin()
        .read_line(&mut guess)
        .expect("Failed to read line");

    println!("You guessed: {guess}");
}

Листинг 2-3: Добавление кода для генерации случайного числа

Сначала мы добавляем строку use rand::Rng. Типаж Rng определяет методы, реализующие генераторы случайных чисел, и этот типаж должен быть в области видимости, чтобы можно было использовать эти методы. В главе 10 мы подробно рассмотрим типажи.

Затем мы добавляем две строки посередине. В первой строке мы вызываем функцию rand::thread_rng, дающую нам генератор случайных чисел, который мы собираемся использовать: тот самый, который является локальным для текущего потока выполнения и запускается операционной системой. Затем мы вызываем метод gen_range генератора случайных чисел. Этот метод определяется Rng, который мы включили в область видимости с помощью оператора use rand::Rng. Метод gen_range принимает в качестве аргумента выражение диапазона и генерирует случайное число в этом диапазоне. Тип используемого выражения диапазона принимает форму start..=end и включает нижнюю и верхнюю границы, поэтому, чтобы запросить число от 1 до 100, нам нужно указать 1..=100.

Примечание: Не просто сразу разобраться, какие типажи использовать, какие методы и функции вызывать из пакета, поэтому каждый пакет имеет документацию с инструкциями по его использованию. Ещё одной замечательной особенностью Cargo является выполнение команды cargo doc --open, которая локально собирает документацию, предоставляемую всеми вашими зависимостями, и открывает её в браузере. К примеру, если интересна другая функциональность из пакета rand, запустите cargo doc --open и нажмите rand в боковой панели слева.

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

Попробуйте запустить программу несколько раз:

$ cargo run
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
    Finished dev [unoptimized + debuginfo] target(s) in 2.53s
     Running `target/debug/guessing_game`
Guess the number!
The secret number is: 7
Please input your guess.
4
You guessed: 4

$ cargo run
    Finished dev [unoptimized + debuginfo] target(s) in 0.02s
     Running `target/debug/guessing_game`
Guess the number!
The secret number is: 83
Please input your guess.
5
You guessed: 5

Вы должны получить разные случайные числа, и все они должны быть числами от 1 до 100. Отличная работа!

Сравнение догадки с секретным числом

Теперь, когда у нас есть пользовательский ввод и случайное число, мы можем их сравнить. Этот шаг показан в Листинге 2-4. Обратите внимание, что этот код ещё не удастся скомпилировать.

Имя файла: src/main.rs

use rand::Rng;
use std::cmp::Ordering;
use std::io;

fn main() {
    // --snip--
    println!("Guess the number!");

    let secret_number = rand::thread_rng().gen_range(1..=100);

    println!("The secret number is: {secret_number}");

    println!("Please input your guess.");

    let mut guess = String::new();

    io::stdin()
        .read_line(&mut guess)
        .expect("Failed to read line");

    println!("You guessed: {guess}");

    match guess.cmp(&secret_number) {
        Ordering::Less => println!("Too small!"),
        Ordering::Greater => println!("Too big!"),
        Ordering::Equal => println!("You win!"),
    }
}

Листинг 2-4: Обработка возможных возвращаемых значений сравнения двух чисел

Сначала добавим ещё один оператор use, который вводит тип с именем std::cmp::Ordering в область видимости из стандартной библиотеки. Тип Ordering является ещё одним перечислением и имеет варианты Less, Greater и Equal. Это три возможных исхода, при сравнении двух величин.

После чего ниже добавляем пять новых строк, использующих тип Ordering. Метод cmp сравнивает два значения и может вызываться для всего, что можно сравнить. Он принимает ссылку на все, что требуется сравнить: здесь сравнивается guess с secret_number. В результате возвращается вариант перечисления Ordering, которое мы ввели в область видимости с помощью оператора use. Для принятия решения о том, что делать дальше, мы используем выражение match, определяющее, какой вариант Ordering был возвращён из вызова cmp со значениями guess и secret_number.

Выражение match состоит из веток (arms). Ветка состоит из шаблона для сопоставления и кода, который будет запущен, если значение, переданное в match, соответствует шаблону этой ветки. Rust принимает значение, заданное match, и по очереди просматривает шаблон каждой ветки. Шаблоны и конструкция match - это мощные возможности Rust, позволяющие выразить множество ситуаций, с которыми может столкнуться ваш код, и гарантировать их обработку. Эти возможности будут подробно раскрыты в Главе 6 и Главе 18 соответственно.

Давайте рассмотрим пример с выражением match, которое мы используем здесь. Предположим, что пользователь угадал 50, а случайно сгенерированное секретное число на этот раз равно 38. Когда код сравнивает 50 с 38, метод cmp вернёт Ordering::Greater, поскольку 50 больше 38. Выражение match получает Ordering::Greater значение и начинает проверку шаблона каждой руки. Оно просматривает шаблон первой ветви, Ordering::Less, и видит, что значение Ordering::Greater не соответствует Ordering::Less, поэтому игнорирует код в этой ветви и переходит к следующей. Следующий образец руки — Ordering::Greater, который соответствует Ordering Ordering::Greater! Связанный код в этой руке будет выполняться и выводить Too big! на экран. Выражение match заканчивается после первого успешного совпадения, поэтому в этом сценарии оно не будет рассматривать последнюю руку.

Однако, код в листинге 2-4 все ещё не скомпилируется. Давайте попробуем:

$ cargo build
   Compiling libc v0.2.86
   Compiling getrandom v0.2.2
   Compiling cfg-if v1.0.0
   Compiling ppv-lite86 v0.2.10
   Compiling rand_core v0.6.2
   Compiling rand_chacha v0.3.0
   Compiling rand v0.8.3
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
error[E0308]: mismatched types
  --> src/main.rs:22:21
   |
22 |     match guess.cmp(&secret_number) {
   |                     ^^^^^^^^^^^^^^ expected struct `String`, found integer
   |
   = note: expected reference `&String`
              found reference `&{integer}`

error[E0283]: type annotations needed for `{integer}`
   --> src/main.rs:8:44
    |
8   |     let secret_number = rand::thread_rng().gen_range(1..=100);
    |         -------------                      ^^^^^^^^^ cannot infer type for type `{integer}`
    |         |
    |         consider giving `secret_number` a type
    |
    = note: multiple `impl`s satisfying `{integer}: SampleUniform` found in the `rand` crate:
            - impl SampleUniform for i128;
            - impl SampleUniform for i16;
            - impl SampleUniform for i32;
            - impl SampleUniform for i64;
            and 8 more
note: required by a bound in `gen_range`
   --> /Users/carolnichols/.cargo/registry/src/github.com-1ecc6299db9ec823/rand-0.8.3/src/rng.rs:129:12
    |
129 |         T: SampleUniform,
    |            ^^^^^^^^^^^^^ required by this bound in `gen_range`
help: consider specifying the type arguments in the function call
    |
8   |     let secret_number = rand::thread_rng().gen_range::<T, R>(1..=100);
    |                                                     ++++++++

Some errors have detailed explanations: E0283, E0308.
For more information about an error, try `rustc --explain E0283`.
error: could not compile `guessing_game` due to 2 previous errors

Суть ошибки заключается в наличии несовпадающих типов. У Rust строгая, статическая система типов. Однако он также имеет вывод типов. Когда мы написали let mut guess = String::new(), Rust смог сделать вывод, что guess должна быть String и не заставил указывать тип. С другой стороны, secret_number - это числовой тип. Несколько типов чисел в Rust могут иметь значение от 1 до 100: i32, 32-битное число; u32, беззнаковое 32-битное число; i64, 64-битное число, а также другие. Если не указано иное, Rust по умолчанию использует i32, который будет типом secret_number, если не добавлять информацию о типе в другом месте, которая заставит Rust вывести другой числовой тип. Причина ошибки заключается в том, что Rust не может сравнить строку и числовой тип.

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

Имя файла: src/main.rs

use rand::Rng;
use std::cmp::Ordering;
use std::io;

fn main() {
    println!("Guess the number!");

    let secret_number = rand::thread_rng().gen_range(1..=100);

    println!("The secret number is: {secret_number}");

    println!("Please input your guess.");

    // --snip--

    let mut guess = String::new();

    io::stdin()
        .read_line(&mut guess)
        .expect("Failed to read line");

    let guess: u32 = guess.trim().parse().expect("Please type a number!");

    println!("You guessed: {guess}");

    match guess.cmp(&secret_number) {
        Ordering::Less => println!("Too small!"),
        Ordering::Greater => println!("Too big!"),
        Ordering::Equal => println!("You win!"),
    }
}

Вот эта строка:

let guess: u32 = guess.trim().parse().expect("Please type a number!");

Мы создаём переменную с именем guess. Но подождите, разве в программе уже нет переменной с этим именем guess? Так и есть, но Rust позволяет нам затенять предыдущее значение guess новым. Затенение позволяет нам повторно использовать имя переменной guess, чтобы избежать создания двух уникальных переменных, таких как guess_str и guess, например. Мы рассмотрим это более подробно в главе 3, а пока знайте, что эта функция часто используется, когда необходимо преобразовать значение из одного типа в другой.

Мы связываем эту новую переменную с выражением guess.trim().parse(). Переменная guess в этом выражении относится к исходной переменной guess, которая содержала входные данные в виде строки. Метод trim на экземпляре String удалит любые пробельные символы в начале и конце строки для того, чтобы мы могли сопоставить строку с u32, которая содержит только числовые данные. Пользователь должен нажать enter, чтобы выполнить read_line и ввести свою догадку, при этом в строку добавится символ новой строки. Например, если пользователь набирает 5 и нажимает enter, guess будет выглядеть так: 5\n. Символ \n означает "новая строка". (В Windows нажатие enter сопровождается возвратом каретки и новой строкой, \r\n). Метод trim убирает \n или \r\n, оставляя только 5.

Метод parse строк преобразует строку в другой тип. Здесь мы используем его для преобразования строки в число. Нам нужно сообщить Rust точный числовой тип, который мы хотим, используя let guess: u32. Двоеточие ( : ) после guess говорит Rust, что мы аннотируем тип переменной. В Rust есть несколько встроенных числовых типов; u32, показанный здесь, представляет собой 32-битное целое число без знака. Это хороший выбор по умолчанию для небольшого положительного числа. Вы узнаете о других типах чисел в Главе 3. Кроме того, аннотация u32 в этом примере программы и сравнение с secret_number означает, что Rust сделает вывод, что secret_number должен быть u32. Итак, теперь сравнение будет между двумя значениями одного типа!

Метод parse будет работать только с символами, которые логически могут быть преобразованы в числа, и поэтому легко может вызвать ошибки. Если, например, строка содержит A👍%, преобразовать её в число невозможно. Так как метод parse может потерпеть неудачу, возвращается тип Result, так же как и метод read_line (обсуждалось ранее в разделе "Обработка потенциальной неудачи с помощью Result Type"). Мы будем точно так же обрабатывать данный Result, вновь используя метод expect. Если parse вернёт вариант Result Err, так как не смог создать число из строки, вызов expect аварийно завершит игру и распечатает переданное ему сообщение. Если parse сможет успешно преобразовать строку в число, он вернёт вариант Result Ok, а expect вернёт число, полученное из значения Ok.

Давайте запустим программу теперь!

$ cargo run
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
    Finished dev [unoptimized + debuginfo] target(s) in 0.43s
     Running `target/debug/guessing_game`
Guess the number!
The secret number is: 58
Please input your guess.
  76
You guessed: 76
Too big!

Хорошо! Несмотря на то, что были добавлены пробелы перед догадкой 76, программа все равно вывела пользовательскую догадку 76. Запустите программу несколько раз, чтобы проверить разное поведение при различных типах ввода: задайте число правильно, задайте слишком большое число и задайте слишком маленькое число.

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

Возможность нескольких догадок с помощью циклов

Ключевое слово loop создаёт бесконечный цикл. Мы добавляем цикл, чтобы дать пользователям больше шансов угадать число:

Имя файла: src/main.rs

use rand::Rng;
use std::cmp::Ordering;
use std::io;

fn main() {
    println!("Guess the number!");

    let secret_number = rand::thread_rng().gen_range(1..=100);

    // --snip--

    println!("The secret number is: {secret_number}");

    loop {
        println!("Please input your guess.");

        // --snip--


        let mut guess = String::new();

        io::stdin()
            .read_line(&mut guess)
            .expect("Failed to read line");

        let guess: u32 = guess.trim().parse().expect("Please type a number!");

        println!("You guessed: {guess}");

        match guess.cmp(&secret_number) {
            Ordering::Less => println!("Too small!"),
            Ordering::Greater => println!("Too big!"),
            Ordering::Equal => println!("You win!"),
        }
    }
}

Как видите, мы переместили все, начиная с подсказки ввода догадки, в цикл. Не забудьте добавить ещё по четыре пробела на отступы строк внутри цикла и запустите программу снова. Теперь программа будет бесконечно запрашивать ещё одну догадку, что фактически создаёт новую проблему. Похоже пользователь не сможет выйти из игры!

Пользователь может прервать выполнение программы с помощью сочетания клавиш ctrl-c. Но есть и другой способ спастись от этого ненасытного монстра, о котором говорилось при обсуждении parse в "Сравнение догадки с секретным числом": если пользователь введёт нечисловой ответ, программа завершится аварийно. Мы можем воспользоваться этим, чтобы позволить пользователю выйти из игры, как показано здесь:

$ cargo run
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
    Finished dev [unoptimized + debuginfo] target(s) in 1.50s
     Running `target/debug/guessing_game`
Guess the number!
The secret number is: 59
Please input your guess.
45
You guessed: 45
Too small!
Please input your guess.
60
You guessed: 60
Too big!
Please input your guess.
59
You guessed: 59
You win!
Please input your guess.
quit
thread 'main' panicked at 'Please type a number!: ParseIntError { kind: InvalidDigit }', src/main.rs:28:47
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

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

Выход после правильной догадки

Давайте запрограммируем игру на выход при выигрыше пользователя, добавив оператор break:

Файл: src/main.rs

use rand::Rng;
use std::cmp::Ordering;
use std::io;

fn main() {
    println!("Guess the number!");

    let secret_number = rand::thread_rng().gen_range(1..=100);

    println!("The secret number is: {secret_number}");

    loop {
        println!("Please input your guess.");

        let mut guess = String::new();

        io::stdin()
            .read_line(&mut guess)
            .expect("Failed to read line");

        let guess: u32 = guess.trim().parse().expect("Please type a number!");

        println!("You guessed: {guess}");

        // --snip--

        match guess.cmp(&secret_number) {
            Ordering::Less => println!("Too small!"),
            Ordering::Greater => println!("Too big!"),
            Ordering::Equal => {
                println!("You win!");
                break;
            }
        }
    }
}

Добавление строки break после You win! заставляет программу выйти из цикла, когда пользователь правильно угадает секретное число. Выход из цикла также означает выход из программы, так как цикл является последней частью main.

Обработка недопустимого ввода

Чтобы ещё улучшить поведение игры, вместо аварийного завершения программы, когда пользователь вводит не число, давайте заставим игру проигрывать этот случай, позволяя пользователю продолжить угадывание. Для этого необходимо изменить строку, в которой guess преобразуется из String в u32, как показано в Листинге 2-5.

Файл: src/main.rs

use rand::Rng;
use std::cmp::Ordering;
use std::io;

fn main() {
    println!("Guess the number!");

    let secret_number = rand::thread_rng().gen_range(1..=100);

    println!("The secret number is: {secret_number}");

    loop {
        println!("Please input your guess.");

        let mut guess = String::new();

        // --snip--

        io::stdin()
            .read_line(&mut guess)
            .expect("Failed to read line");

        let guess: u32 = match guess.trim().parse() {
            Ok(num) => num,
            Err(_) => continue,
        };

        println!("You guessed: {guess}");

        // --snip--

        match guess.cmp(&secret_number) {
            Ordering::Less => println!("Too small!"),
            Ordering::Greater => println!("Too big!"),
            Ordering::Equal => {
                println!("You win!");
                break;
            }
        }
    }
}

Листинг 2-5. Игнорирование предположения, отличного от числа, и запрос другого предположения вместо сбоя программы

Мы переключаем вызов expect на выражение match, чтобы перейти от аварийного завершения при ошибке к обработке ошибки. Помните, что parse возвращает тип Result, а Result - это перечисление, которое имеет варианты Ok и Err. Здесь мы используем выражение match, как и в случае с результатом Ordering метода cmp.

Если parse может успешно преобразовать строку в число, он вернёт значение Ok, содержащее полученное число. Это значение Ok будет соответствовать шаблону первой руки, а выражение match просто вернёт значение num, созданное parse, и поместит его внутрь значения Ok. Это число окажется именно там, где мы хотим, в новой переменной guess.

Если метод parse не способен превратить строку в число, он вернёт значение Err, которое содержит более подробную информацию об ошибке. Значение Err не совпадает с шаблоном Ok(num) в первой ветке match, но совпадает с шаблоном Err(_) второй ветки. Подчёркивание _ является всеохватывающим выражением. В этой ветке мы говорим, что хотим обработать совпадение всех значений Err, независимо от того, какая информация находится внутри Err. Поэтому программа выполнит код второй ветки, continue, который сообщает программе перейти к следующей итерации loop и запросить ещё одну догадку. В этом случае программа эффективно игнорирует все ошибки, с которыми может столкнуться parse!

Все в программе теперь должно работать как положено. Давайте попробуем:

$ cargo run
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
    Finished dev [unoptimized + debuginfo] target(s) in 4.45s
     Running `target/debug/guessing_game`
Guess the number!
The secret number is: 61
Please input your guess.
10
You guessed: 10
Too small!
Please input your guess.
99
You guessed: 99
Too big!
Please input your guess.
foo
Please input your guess.
61
You guessed: 61
You win!

Потрясающе! С помощью одной маленькой последней правки мы закончим игру в угадывание. Напомним, что программа все ещё печатает секретное число. Это хорошо подходило для тестирования, но это портит игру. Давайте удалим println!, который выводит секретное число. В Листинге 2-6 показан окончательный вариант кода.

Файл: src/main.rs

use rand::Rng;
use std::cmp::Ordering;
use std::io;

fn main() {
    println!("Guess the number!");

    let secret_number = rand::thread_rng().gen_range(1..=100);

    loop {
        println!("Please input your guess.");

        let mut guess = String::new();

        io::stdin()
            .read_line(&mut guess)
            .expect("Failed to read line");

        let guess: u32 = match guess.trim().parse() {
            Ok(num) => num,
            Err(_) => continue,
        };

        println!("You guessed: {guess}");

        match guess.cmp(&secret_number) {
            Ordering::Less => println!("Too small!"),
            Ordering::Greater => println!("Too big!"),
            Ordering::Equal => {
                println!("You win!");
                break;
            }
        }
    }
}

Листинг 2-6: Полный код игры в угадывание

Итоги

На данный момент вы успешно создали игру в угадывание. Поздравляем!

Этот проект - практический способ познакомить вас со многими новыми концепциями Rust: let, match, функции, использование внешних пакетов и многое другое. В следующих нескольких главах вы изучите эти концепции более подробно. Глава 3 охватывает понятия, которые есть в большинстве языков программирования, такие как переменные, типы данных и функции, и показывает, как использовать их в Rust. В главе 4 рассматривается владение, особенность, которая отличает Rust от других языков. В главе 5 обсуждаются структуры и синтаксис методов, а в главе 6 объясняется, как работают перечисления.

Общие концепции программирования

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

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

Ключевые слова

Как и в других языках, язык Rust содержит набор ключевых слов, которые зарезервированы для использования непосредственно в языке. Учтите, вы не можете использовать эти слова в качестве имён переменных или функций. Большинство ключевых слов имеют специальные назначения, и вы будете использовать их для решения различных задач в ваших программах Rust. Некоторые из них не имеют текущей функциональности, но были зарезервированы для функциональности, которая может быть добавлена в Rust в будущем. Список ключевых слов можно найти в Приложении A.

Переменные и понятие изменяемости

Как упоминалось в разделе “Сохранение значений в переменных”, по умолчанию переменные являются неизменяемыми. Это одна из многих подсказок, которые Rust даёт вам для написания кода таким образом, чтобы использовать преимущества безопасности и простого параллелизма, которые предлагает Rust. Однако у вас есть возможность сделать ваши переменные изменяемыми. Давайте рассмотрим, как и почему Rust поощряет неизменность, и почему иногда вы можете отказаться от этого.

Когда переменная неизменяемая, то её значение нельзя менять, как только значение привязано к её имени. Приведём пример использования этого типа переменной. Для этого создадим новый проект variables в каталоге projects при помощи команды: cargo new variables.

Потом в созданной папке проекта variables откройте исходный файл src/main.rs и замените содержимое следующим кодом, который пока не будет компилироваться:

Файл: src/main.rs

fn main() {
    let x = 5;
    println!("The value of x is: {x}");
    x = 6;
    println!("The value of x is: {x}");
}

Сохраните код программы и выполните команду cargo run. В командной строке вы увидите сообщение об ошибке:

$ cargo run
   Compiling variables v0.1.0 (file:///projects/variables)
error[E0384]: cannot assign twice to immutable variable `x`
 --> src/main.rs:4:5
  |
2 |     let x = 5;
  |         -
  |         |
  |         first assignment to `x`
  |         help: consider making this binding mutable: `mut x`
3 |     println!("The value of x is: {x}");
4 |     x = 6;
  |     ^^^^^ cannot assign twice to immutable variable

For more information about this error, try `rustc --explain E0384`.
error: could not compile `variables` due to previous error

Данный пример показывает, как компилятор помогает вам находить ошибки в ваших программах. Ошибки компилятора могут вызывать разочарование, но на самом деле они лишь означают, что ваша программа ещё не делает то, что вы от неё хотите. Они не означают, что вы не являетесь хорошим программистом! Опытные разработчики Rust также получают ошибки компиляции.

Сообщение об ошибке указывает, что причиной ошибки является то, что вы cannot assign twice to immutable variable x`` (не можете присвоить неизменяемой переменной новое значение), потому что вы пытались присвоить второе значение неизменяемой переменной x.

Важно, что мы получаем ошибку времени компиляции, при попытке изменить значение, обозначенное как неизменяемое, потому что такая ситуация может привести к ошибкам. Если одна часть нашего кода исходит из предположения, что значение никогда не изменится, а другая часть кода изменяет это значение, вполне возможно, что первая часть кода не будет делать то, для чего она была предназначена. Причину такого рода ошибок может быть трудно отследить постфактум, особенно когда второй фрагмент кода изменяет значение только иногда. Компилятор Rust гарантирует, что если вы заявите, что значение не изменится, оно действительно не изменится, поэтому вам не нужно следить за ним самостоятельно. Таким образом, ваш код легче понять.

Но изменяемость может быть очень полезной и может сделать код более удобным для написания. Хотя переменные неизменяемы по умолчанию, вы можете сделать их изменяемыми, добавив mut перед именем переменной, как вы делали это в Главе 2. Добавление mut также передаёт будущим читателям кода информацию о том, что другие части кода будут изменять значение этой переменной.

Например, изменим src/main.rs на следующий код:

Файл: src/main.rs

fn main() {
    let mut x = 5;
    println!("The value of x is: {x}");
    x = 6;
    println!("The value of x is: {x}");
}

Запустив программу, мы получим результат:

$ cargo run
   Compiling variables v0.1.0 (file:///projects/variables)
    Finished dev [unoptimized + debuginfo] target(s) in 0.30s
     Running `target/debug/variables`
The value of x is: 5
The value of x is: 6

Когда используется mut, мы можем изменять значение, привязанное к x, с 5 до 6. В конечном счёте, решение о том, использовать изменяемость или нет, зависит от вас и от того, что вы считаете наиболее приемлемым в данной конкретной ситуации.

Константы

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

Во-первых, не разрешается использовать mut с константами. Константы не просто неизменны по умолчанию — они неизменны всегда. Вы объявляете константы, используя ключевое слово const вместо ключевого слова let, и тип должен быть явно указан. Мы собираемся рассмотреть типы и аннотации типов в следующем разделе “Типы данных”, так что не беспокойтесь о деталях сейчас. Просто знайте, что вы всегда должны явно указывать тип.

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

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

Вот пример объявления константы:


#![allow(unused)]
fn main() {
const THREE_HOURS_IN_SECONDS: u32 = 60 * 60 * 3;
}

Имя константы THREE_HOURS_IN_SECONDS и её значение устанавливается в результате умножения числа 60 (количество секунд в минуте) на 60 (количество минут в часе) на 3 (количество часов, которое мы хотим подсчитать в этой программе). Соглашение об именах констант в Rust состоит в том, чтобы использовать все символы в верхнем регистре с символами подчёркивания между словами. Компилятор способен вычислить ограниченный набор операций во время компиляции, что позволяет нам записать это значение так, чтобы его было легче понять и проверить, вместо того, чтобы устанавливать для этой константы значение 10800. См раздел справочника Rust, посвящённый вычислению констант для получения дополнительной информации о том, какие операции можно использовать при объявлении констант.

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

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

Затенение (переменных)

Как вы видели в руководстве по игре Угадайка в Главе 2 , вы можете объявить новую переменную с тем же именем, что и предыдущая переменная. Rustaceans говорят, что первая переменная затенена второй, а это значит, что компилятор увидит вторую переменную, когда вы воспользуетесь именем переменной. По сути, вторая переменная затеняет первую, присваивая себе любое использование имени переменной до тех пор, пока либо она сама не будет затенена, либо область действия не закончится. Мы можем затенить переменную, используя то же имя переменной и повторив использование ключевого слова let следующим образом:

Файл: src/main.rs

fn main() {
    let x = 5;

    let x = x + 1;

    {
        let x = x * 2;
        println!("The value of x in the inner scope is: {x}");
    }

    println!("The value of x is: {x}");
}

Эта программа сначала привязывает x к значению 5. Затем она создаёт новую переменную x, повторяя let x =, беря исходное значение и добавляя 1, чтобы значение x стало равным 6. Затем во внутренней области видимости, созданной с помощью фигурных скобок, третий оператор let также затеняет x и создаёт новую переменную, умножая предыдущее значение на 2, чтобы дать x значение 12. Когда эта область заканчивается, внутреннее затенение заканчивается, и x возвращается к значению 6. Запустив эту программу, она выведет следующее:

$ cargo run
   Compiling variables v0.1.0 (file:///projects/variables)
    Finished dev [unoptimized + debuginfo] target(s) in 0.31s
     Running `target/debug/variables`
The value of x in the inner scope is: 12
The value of x is: 6

Затенение отличается от объявления переменной с помощью mut, так как мы получим ошибку компиляции, если случайно попробуем переназначить значение без использования ключевого слова let. Используя let, можно выполнить несколько превращений над значением, при этом оставляя переменную неизменяемой, после того как все эти превращения завершены.

Другой разницей между mut и затенением является то, что мы создаём совершенно новую переменную, когда снова используем слово let (ещё одну). Мы можем даже изменить тип значения, но снова использовать предыдущее имя. К примеру, наша программа спрашивает пользователя, сколько пробелов он хочет разместить между некоторым текстом, запрашивая символы пробела, но мы на самом деле хотим сохранить данный ввод как число:

fn main() {
    let spaces = "   ";
    let spaces = spaces.len();
}

Первая переменная spaces — является строковым типом, а вторая переменная spaces — числовым типом. Таким образом, затенение избавляет нас от необходимости придумывать разные имена, такие как spaces_str и spaces_num; вместо этого мы можем повторно использовать более простое имя spaces. Однако, если мы попытаемся использовать для этого mut, как здесь показано, то мы получим ошибку времени компиляции:

fn main() {
    let mut spaces = "   ";
    spaces = spaces.len();
}

Ошибка говорит, что не разрешается менять тип переменной:

$ cargo run
   Compiling variables v0.1.0 (file:///projects/variables)
error[E0308]: mismatched types
 --> src/main.rs:3:14
  |
2 |     let mut spaces = "   ";
  |                      ----- expected due to this value
3 |     spaces = spaces.len();
  |              ^^^^^^^^^^^^ expected `&str`, found `usize`

For more information about this error, try `rustc --explain E0308`.
error: could not compile `variables` due to previous error

Теперь, когда вы имеете представление о работе с переменными, посмотрим на большее количество типов данных, которые они могут иметь.

Типы данных

Каждое значение в Rust имеет конкретный тип данных, сообщающий Rust, какие данные используются и как с ними работать. Мы рассмотрим два подмножества типов данных: скалярные и сложные.

Не забывайте, что Rust является статически типизированным (statically typed) языком. Это означает, что он должен знать типы всех переменных во время компиляции. Обычно компилятор может вывести (infer) какой тип мы хотим использовать, основываясь на значении и на том, как мы с ним работаем. В случаях, когда может быть выведено несколько типов, необходимо вручную добавлять аннотацию типа. Например, когда мы конвертировали String в число с помощью вызова parse в разделе "Сравнение предположения с загаданным номером" Главы 2, мы должны добавить такую аннотацию:


#![allow(unused)]
fn main() {
let guess: u32 = "42".parse().expect("Not a number!");
}

Если мы не добавим аннотацию типа : u32 выше, Rust отобразит ошибку, что компилятору нужно больше информации от нас, чтобы узнать, какой тип мы хотим использовать:

$ cargo build
   Compiling no_type_annotations v0.1.0 (file:///projects/no_type_annotations)
error[E0282]: type annotations needed
 --> src/main.rs:2:9
  |
2 |     let guess = "42".parse().expect("Not a number!");
  |         ^^^^^ consider giving `guess` a type

For more information about this error, try `rustc --explain E0282`.
error: could not compile `no_type_annotations` due to previous error

В будущем вы увидите различные аннотации для разных типов данных.

Скалярные типы данных

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

Целочисленные типы

Целое число integer, — это число без дробной части. В Главе 2 мы использовали один целочисленный тип — тип u32. Это объявление типа указывает, что значение, с которым оно связано, должно быть целым числом без знака (типы целых чисел со знаком начинаются с i вместо u), которое занимает 32 бита памяти. В Таблице 3-1 показаны встроенные целочисленные типы в Rust. Мы можем использовать любой из этих вариантов для объявления типа целочисленного значения.

Таблица 3-1: Целочисленные типы в Rust

ДлинаЗнаковыйБеззнаковый
8-битi8u8
16-битi16u16
32-битi32u32
64-битi64u64
128-битi128u128
archisizeusize

Каждый вариант может быть как со знаком, так и без знака и имеет явный размер. Характеристика типа как знаковый и без знаковый определяет возможность числа быть отрицательным, другими словами, должно ли число иметь знак (знаковое) или оно всегда будет только положительным и, следовательно, может быть представлено без знака (беззнаковое). Это похоже на написание чисел на бумаге: когда знак имеет значение, число отображается со знаком плюс или со знаком минус; однако, когда можно с уверенностью предположить, что число положительное, оно отображается без знака. Числа со знаком хранятся с использованием дополнительного кода.

Каждый вариант со знаком может хранить числа от -(2 n - 1 ) до 2 n - 1 - 1 включительно, где n - количество битов, которые использует этот вариант. Таким образом, i8 может хранить числа от -(2 7 ) до 2 7 - 1, что равно от -128 до 127. Варианты без знака могут хранить числа от 0 до 2 n - 1, поэтому u8 может хранить числа от 0 до 2 8 - 1, что равно от 0 до 255.

Кроме того, isize и usize зависят от архитектуры компьютера, на котором запускается ваша программа, что отражено в таблице как «arch»: 64-битный, если вы используете 64-битную архитектуру, и 32-битный, если вы на 32-битной архитектуре.

Целочисленные литералы можно записывать в любой из форм, показанных в Таблице 3-2. Обратите внимание, что числовые литералы, которые могут быть несколькими числовыми типами, позволяют использовать суффикс типа, например 57u8, для обозначения типа. Числовые литералы также могут использовать _ в качестве визуального разделителя, чтобы число было легче читать, например 1_000, которое будет иметь то же значение, как если бы вы указали 1000.

Таблица 3-2: Целочисленные литералы в Rust

Числовые литералыПример
Десятичный98_222
Шестнадцатеричный0xff
Восьмеричный0o77
Двоичный0b1111_0000
Байт (только u8)b'A'

Как же узнать, какой тип целого числа использовать? Если вы не уверены, значения по умолчанию в Rust, как правило, подходят для начала: целочисленные типы по умолчанию i32. Основной случай, в котором вы должны использовать isize или usize, — это индексация какой-либо коллекции.

Целочисленное переполнение

Допустим, у вас есть переменная типа u8, которая может содержать значения от 0 до 255. Если вы попытаетесь изменить переменную на значение вне этого диапазона, например 256, произойдёт целочисленное переполнение, что может привести к одному из двух вариантов поведения. Когда вы компилируете в режиме отладки, Rust включает проверки целочисленного переполнения, которые вызывают панику вашей программы во время выполнения, если происходит переполнение. Rust использует термин «паника», когда программа завершает работу с ошибкой; мы обсудим паники более подробно в разделе «Неисправимые ошибки с panic!» в Главе 9.

Когда вы компилируете финальную версию программы с флагом --release, Rust не включает проверки целочисленного переполнения, вызывающего панику. Вместо этого, если происходит переполнение, Rust выполняет оборачивание дополнительного кода(two's complement). Короче говоря, значения, превышающие максимальное значение, которое тип может содержать, «переходят» к минимуму значений, которые может содержать тип. В случае u8 значение 256 становится равным 0, значение 257 становится равным 1 и так далее. Программа не будет паниковать, но переменная будет иметь значение, которое, вероятно, будет не таким, как вы ожидали. Полагаться на такое поведение считается ошибкой.

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

  • Обернуть все режимы с помощью wrapping_* методов, например wrapping_add
  • Вернуть значение None в случае переполнения при помощи методов checked_*
  • Вернуть значение и логическое значение, указывающее, было ли переполнение с помощью методов overflowing_*
  • Насыщать при минимальном или максимальном значениях с помощью методов saturating_*

Числа с плавающей запятой

В Rust также есть два примитивных типа для чисел с плавающей запятой, которые представляют собой числа с десятичными точками. Типы чисел с плавающей запятой в Rust — это f32 и f64, имеющие размер 32 и 64 бита соответственно. Тип по умолчанию — f64, потому что на современных процессорах он примерно такой же скорости, как f32, но обеспечивает большую точность. Все типы с плавающей запятой обладают знаком.

Вот пример, демонстрирующий числа с плавающей запятой в действии:

Файл: src/main.rs

fn main() {
    let x = 2.0; // f64

    let y: f32 = 3.0; // f32
}

Числа с плавающей точкой представлены согласно стандарту IEEE-754. Тип f32 является числом с плавающей точкой одинарной точности, а f64 имеет двойную точность.

Числовые операции

Rust поддерживает основные математические операции, которые вы ожидаете иметь для всех типов чисел: сложение, вычитание, умножение, деление и остаток. Целочисленное деление округляется до ближайшего целого числа. В следующем коде показано, как использовать каждую числовую операцию в выражении let:

Файл: src/main.rs

fn main() {
    // addition
    let sum = 5 + 10;

    // subtraction
    let difference = 95.5 - 4.3;

    // multiplication
    let product = 4 * 30;

    // division
    let quotient = 56.7 / 32.2;
    let floored = 2 / 3; // Results in 0

    // remainder
    let remainder = 43 % 5;
}

Каждое из этих выражений использует математические операции и вычисляет значение, которое затем присваивается переменной. "Приложение Б" содержит список всех операторов, имеющихся в Rust.

Логический тип данных

Как и в большинстве языков программирования, логический тип в Rust может иметь два значения: true и false, и занимает в памяти один байт. Логический тип в Rust аннотируется при помощи bool. Например:

Файл: src/main.rs

fn main() {
    let t = true;

    let f: bool = false; // with explicit type annotation
}

Основной способ использования значений логического типа - это условные конструкции, такие как выражение if. Мы расскажем про работу выражения if в разделе "Условные конструкции".

Символьный тип данных

Тип char в Rust — самый примитивный алфавитный тип языка. Вот несколько примеров объявления значений char:

Файл: src/main.rs

fn main() {
    let c = 'z';
    let z: char = 'ℤ'; // with explicit type annotation
    let heart_eyed_cat = '😻';
}

Обратите внимание, что мы указываем литералы char в одинарных кавычках, в отличие от строковых литералов, которые используют двойные кавычки. Тип char в Rust имеет размер четыре байта и представляет собой Скалярное Значение Unicode, что означает, что он может представлять гораздо больше, чем просто ASCII. Буквы с ударением; китайские, японские и корейские иероглифы; эмодзи; и пробелы нулевой ширины являются допустимыми значениями char в Rust. Скалярные Значения Unicode находятся в диапазоне от U+0000 до U+D7FF и от U+E000 E000 до U+10FFFF включительно. Однако «символ» на самом деле не является концепцией в Unicode, поэтому интуитивно может не совпадать с тем, что такое char в Rust. Мы подробно обсудим эту тему в разделе «Сохранение текста в кодировке UTF-8 со строками». в Главе 8.

Сложные типы данных

Сложные типы могут группировать несколько значений в один тип. В Rust есть два примитивных сложных (комбинированных) типа: кортежи и массивы.

Кортежи

Кортеж является общим способом совместной группировки нескольких значений различного типа в единый комбинированный тип. Кортежи имеют фиксированную длину: после объявления они не могут расти или уменьшаться в размере.

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

Файл: src/main.rs

fn main() {
    let tup: (i32, f64, u8) = (500, 6.4, 1);
}

Переменной с именем tup привязывается весь кортеж, потому что кортеж является единым комбинированным элементом. Чтобы получить отдельные значения из кортежа, можно использовать сопоставление с образцом для деструктурирования значений кортежа, как в примере:

Файл: src/main.rs

fn main() {
    let tup = (500, 6.4, 1);

    let (x, y, z) = tup;

    println!("The value of y is: {y}");
}

Программа создаёт кортеж, привязывает его к переменной tup. Затем в let используется шаблон для превращения tup в три отдельных переменные: x, y и z. Такого рода операция называется деструктуризацией (destructuring), потому что она разрушает один кортеж на три части. В конце программа печатает значение y, которое равно 6.4.

Мы также можем напрямую обращаться к элементу кортежа, используя точку ( . ), за которой следует индекс значения, к которому мы хотим получить доступ. Например:

Файл: src/main.rs

fn main() {
    let x: (i32, f64, u8) = (500, 6.4, 1);

    let five_hundred = x.0;

    let six_point_four = x.1;

    let one = x.2;
}

Эта программа создаёт кортеж x, а затем обращается к каждому элементу кортежа, используя соответствующие индексы. Как и в большинстве языков программирования, первый индекс в кортеже равен 0.

Кортеж без каких-либо значений имеет специальное имя unit. Это значение и соответствующий ему тип записываются () и представляют собой пустое значение или пустой возвращаемый тип. Выражения неявно возвращают unit, если они не возвращают никакого другого значения.

Массивы

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

Мы записываем значения в массиве в виде списка, разделённого запятыми, внутри квадратных скобок:

Файл: src/main.rs

fn main() {
    let a = [1, 2, 3, 4, 5];
}

Массивы полезны, когда вы хотите, чтобы ваши данные размещались в стеке, а не в куче (мы более подробно обсудим стек и кучу в Главе 4).) или когда вы хотите, чтобы у вас всегда было фиксированное количество элементов. Однако массив не такой гибкий, как векторный тип. Вектор — это аналогичный тип коллекции, предоставляемый стандартной библиотекой, размер которого может увеличиваться или уменьшаться. Если вы не уверены, использовать ли массив или вектор, скорее всего, вам следует использовать вектор. Глава 8 раскрывает векторы более подробно.

Однако массивы более полезны, когда вы знаете, что количество элементов не нужно будет изменять. Например, если бы вы использовали названия месяцев в программе, вы, вероятно, использовали бы массив, а не вектор, потому что вы знаете, что он всегда будет содержать 12 элементов:


#![allow(unused)]
fn main() {
let months = ["January", "February", "March", "April", "May", "June", "July",
              "August", "September", "October", "November", "December"];
}

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


#![allow(unused)]
fn main() {
let a: [i32; 5] = [1, 2, 3, 4, 5];
}

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

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


#![allow(unused)]
fn main() {
let a = [3; 5];
}

Массив в переменной a будет включать 5 элементов, значение которых будет равно 3. Данная запись аналогична коду let a = [3, 3, 3, 3, 3];, но является более краткой.

Доступ к элементам массива

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

Файл: src/main.rs

fn main() {
    let a = [1, 2, 3, 4, 5];

    let first = a[0];
    let second = a[1];
}

В этом примере переменная с именем first получит значение 1, потому что это значение с индексом [0] в массиве. Переменная с именем second получит значение 2 из индекса [1] в массиве.

Некорректный доступ к элементу массива

Давайте посмотрим, что произойдёт, если вы попытаетесь получить доступ к элементу массива, находящемуся за его концом. Допустим, вы запускаете код, похожий на игру в угадывание из Главы 2, чтобы получить индекс массива от пользователя:

Файл: src/main.rs

use std::io;

fn main() {
    let a = [1, 2, 3, 4, 5];

    println!("Please enter an array index.");

    let mut index = String::new();

    io::stdin()
        .read_line(&mut index)
        .expect("Failed to read line");

    let index: usize = index
        .trim()
        .parse()
        .expect("Index entered was not a number");

    let element = a[index];

    println!("The value of the element at index {index} is: {element}");
}

Этот код успешно компилируется. Если вы запустите его, используя команду cargo run и введёте 0, 1, 2, 3 или 4, программа выведет соответствующее значение по этому индексу в массиве. Если вместо этого вы введёте число после конца массива, например 10, вы увидите следующий вывод:

thread 'main' panicked at 'index out of bounds: the len is 5 but the index is 10', src/main.rs:19:19
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

Программа привела к ошибке во время выполнения в момент использования недопустимого значения в операции индексирования. Программа завершилась с сообщением об ошибке и не выполнила утверждение println!. Когда вы пытаетесь получить доступ к элементу с помощью индексации, Rust проверит, что указанный вами индекс меньше длины массива. Если индекс больше или равен длине, Rust запаникует. Но в данном случае проверка также должна выполняться во время выполнения, потому что компилятор не может знать, какое значение введёт пользователь, когда позже запустит код.

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

Функции

Функции широко распространены в коде Rust. Вы уже познакомились с одной из самых важных функций в языке: функцией main, которая является точкой входа большинства программ. Вы также видели ключевое слово fn, позволяющее объявлять новые функции.

Код Rust использует змеиный регистр (snake case) как основной стиль для имён функций и переменных, в котором все буквы строчные, а символ подчёркивания разделяет слова. Вот программа, содержащая пример определения функции:

Имя файла: src/main.rs

fn main() {
    println!("Hello, world!");

    another_function();
}

fn another_function() {
    println!("Another function.");
}

Для определения функции в Rust необходимо указать fn, за которым следует имя функции и набор круглых скобок. Фигурные скобки указывают компилятору, где начинается и заканчивается тело функции.

Мы можем вызвать любую функцию, которую мы определили ранее, введя её имя и набор скобок следом. Поскольку в программе определена another_function, её можно вызвать из функции main. Обратите внимание, что another_function определена после функции main в исходном коде; мы могли бы определить её и раньше. Rust не важно, где вы определяете свои функции, главное, чтобы они были определены где-то в той области видимости, которую может видеть вызывающий их код.

Создадим новый бинарный проект с названием functions для дальнейшего изучения функций. Поместите пример another_function в файл src/main.rs и запустите его. Вы должны увидеть следующий вывод:

$ cargo run
   Compiling functions v0.1.0 (file:///projects/functions)
    Finished dev [unoptimized + debuginfo] target(s) in 0.28s
     Running `target/debug/functions`
Hello, world!
Another function.

Строки выполняются в том порядке, в котором они расположены в функции main. Сначала печатается сообщение "Hello, world!", а затем вызывается another_function, которая также печатает сообщение.

Параметры функции

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

В этой версии another_function мы добавляем параметр:

Имя файла: src/main.rs

fn main() {
    another_function(5);
}

fn another_function(x: i32) {
    println!("The value of x is: {x}");
}

Попробуйте запустить эту программу. Должны получить следующий результат:

$ cargo run
   Compiling functions v0.1.0 (file:///projects/functions)
    Finished dev [unoptimized + debuginfo] target(s) in 1.21s
     Running `target/debug/functions`
The value of x is: 5

Объявление another_function имеет один параметр с именем x. Тип x указан как i32. Когда мы передаём 5 в another_function, println! макрос помещает 5 в пару фигурных скобок формата строки.

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

При определении нескольких параметров, разделяйте объявления параметров запятыми, как показано ниже:

Имя файла: src/main.rs

fn main() {
    print_labeled_measurement(5, 'h');
}

fn print_labeled_measurement(value: i32, unit_label: char) {
    println!("The measurement is: {value}{unit_label}");
}

Этот пример создаёт функцию под именем print_labeled_measurement с двумя параметрами. Первый параметр называется value с типом i32. Второй называется unit_label и имеет тип char. Затем функция печатает текст, содержащий value и unit_label.

Попробуем запустить этот код. Замените текущую программу проекта functions в файле src/main.rs на предыдущий пример и запустите его с помощью cargo run:

$ cargo run
   Compiling functions v0.1.0 (file:///projects/functions)
    Finished dev [unoptimized + debuginfo] target(s) in 0.31s
     Running `target/debug/functions`
The measurement is: 5h

Поскольку мы вызвали функцию с 5 в качестве значения для value и 'h' в качестве значения для unit_label, вывод программы содержит эти значения.

Операторы и выражения

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

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

Фактически мы уже использовали операторы и выражения. Создание переменной и присвоение ей значения с помощью let - это оператор. В листинге 3-1 let y = 6; это оператор.

Имя файла: src/main.rs

fn main() {
    let y = 6;
}

Листинг 3-1: Объявление функции main, содержащей один оператор

Определения функций также являются операторами. Весь предыдущий пример сам по себе является оператором.

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

Имя файла: src/main.rs

fn main() {
    let x = (let y = 6);
}

Если вы запустите эту программу, то ошибка будет выглядеть так:

$ cargo run
   Compiling functions v0.1.0 (file:///projects/functions)
error: expected expression, found statement (`let`)
 --> src/main.rs:2:14
  |
2 |     let x = (let y = 6);
  |              ^^^^^^^^^
  |
  = note: variable declaration using `let` is a statement

error[E0658]: `let` expressions in this position are unstable
 --> src/main.rs:2:14
  |
2 |     let x = (let y = 6);
  |              ^^^^^^^^^
  |
  = note: see issue #53667 <https://github.com/rust-lang/rust/issues/53667> for more information

warning: unnecessary parentheses around assigned value
 --> src/main.rs:2:13
  |
2 |     let x = (let y = 6);
  |             ^         ^
  |
  = note: `#[warn(unused_parens)]` on by default
help: remove these parentheses
  |
2 -     let x = (let y = 6);
2 +     let x = let y = 6;
  | 

For more information about this error, try `rustc --explain E0658`.
warning: `functions` (bin "functions") generated 1 warning
error: could not compile `functions` due to 2 previous errors; 1 warning emitted

Оператор let y = 6 не возвращает значение, поэтому не с чем связать переменную x. Это отличается от поведения в других языках, таких как C и Ruby, где операция присваивания возвращает значение присваивания. В таких языках можно писать код x = y = 6 и обе переменные x и y будут иметь одинаковое значение 6. Но в Rust не так.

Выражения вычисляют значение и составляют большую часть остального кода, который вы напишете на Rust. Рассмотрим математическую операцию, к примеру 5 + 6, которая является выражением, вычисляющим значение 11. Выражения могут быть частью операторов: в листинге 3-1 6 в операторе let y = 6; является выражением, которое вычисляется в значение 6. Вызов функции - это выражение. Вызов макроса - это выражение. Новый блок области видимости, созданный с помощью фигурных скобок, представляет собой выражение, например:

Имя файла: src/main.rs

fn main() {
    let y = {
        let x = 3;
        x + 1
    };

    println!("The value of y is: {y}");
}

Это выражение:

{
    let x = 3;
    x + 1
}

является блоком, который в данном случае вычисляется как 4. Это значение затем привязывается к y как часть оператора let. Обратите внимание, что x + 1 не имеет точки с запятой в конце, в отличие от большинства строк, которые вы видели до сих пор. Выражения не включают точку с запятой в конце. Если добавить точку с запятой в конец выражения, то оно превратится в оператор, и не вернёт значение. Помните об этом, когда будете изучать возвращаемые функцией значения и выражения.

Функции с возвращаемыми значениями

Функции могут возвращать значения коду, который их вызывает. Мы не называем возвращаемые значения, но мы должны объявить их тип после стрелки ( -> ). В Rust возвращаемое значение функции является синонимом значения конечного выражения в блоке тела функции. Вы можете раньше выйти из функции и вернуть значение, используя ключевое слово return и указав значение, но большинство функций неявно возвращают последнее выражение. Вот пример такой функции:

Имя файла: src/main.rs

fn five() -> i32 {
    5
}

fn main() {
    let x = five();

    println!("The value of x is: {x}");
}

В коде функции five нет вызовов функций, макросов или даже операторов let - есть только одно число 5. Это является абсолютно корректной функцией в Rust. Заметьте, что возвращаемый тип у данной функции определён как -> i32. Попробуйте запустить этот код. Вывод будет таким:

$ cargo run
   Compiling functions v0.1.0 (file:///projects/functions)
    Finished dev [unoptimized + debuginfo] target(s) in 0.30s
     Running `target/debug/functions`
The value of x is: 5

Значение 5 в five является возвращаемым функцией значением, поэтому возвращаемый тип - i32. Рассмотрим пример более детально. Здесь есть два важных момента: во-первых, строка let x = five(); показывает использование возвращаемого функцией значения для инициализации переменной. Так как функция five возвращает 5, то эта строка эквивалентна следующей:


#![allow(unused)]
fn main() {
let x = 5;
}

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

Рассмотрим другой пример:

Имя файла: src/main.rs

fn main() {
    let x = plus_one(5);

    println!("The value of x is: {x}");
}

fn plus_one(x: i32) -> i32 {
    x + 1
}

Запуск кода напечатает The value of x is: 6. Но если поставить точку с запятой в конце строки, содержащей x + 1, превратив её из выражения в оператор, мы получим ошибку.

Имя файла: src/main.rs

fn main() {
    let x = plus_one(5);

    println!("The value of x is: {x}");
}

fn plus_one(x: i32) -> i32 {
    x + 1;
}

Компиляция данного кода вызывает следующую ошибку:

$ cargo run
   Compiling functions v0.1.0 (file:///projects/functions)
error[E0308]: mismatched types
 --> src/main.rs:7:24
  |
7 | fn plus_one(x: i32) -> i32 {
  |    --------            ^^^ expected `i32`, found `()`
  |    |
  |    implicitly returns `()` as its body has no tail or `return` expression
8 |     x + 1;
  |          - help: consider removing this semicolon

For more information about this error, try `rustc --explain E0308`.
error: could not compile `functions` due to previous error

Основное сообщение об ошибке "несовпадение типов" раскрывает ключевую проблему этого кода. Определение функции plus_one сообщает, что будет возвращено i32, но операторы не вычисляют значение, что и выражается единичным типом (). Следовательно, ничего не возвращается, что противоречит определению функции и приводит к ошибке. В этом выводе Rust выдаёт сообщение, которое, возможно, поможет исправить эту проблему: он предлагает удалить точку с запятой для устранения ошибки.

Комментарии

Все хорошие программисты, создавая программный код, стремятся сделать его простым для понимания. Бывают всё же случаи, когда дополнительное описание просто необходимо. В этих случаях программисты пишут заметки (или как их ещё называют, комментарии). Комментарии игнорируются компилятором, но для тех кто код читает - это очень важная часть документации.

Пример простого комментария:


#![allow(unused)]
fn main() {
// Hello, world.
}

В Rust комментарии должны начинаться двумя символами // и простираются до конца строки. Чтобы комментарии поместились на более чем одной строке, необходимо разместить их на каждой строке, как в примере:


#![allow(unused)]
fn main() {
// So we’re doing something complicated here, long enough that we need
// multiple lines of comments to do it! Whew! Hopefully, this comment will
// explain what’s going on.
}

Комментарии могут быть размещены в конце строки имеющей код:

Файл: src/main.rs

fn main() {
    let lucky_number = 7; // I’m feeling lucky today
}

Но чаще вы увидите их использованные в следующем формате, здесь комментарий размещён на отдельной строке над кодом, который комментируется:

Файл: src/main.rs

fn main() {
    // I’m feeling lucky today
    let lucky_number = 7;
}

Также в Rust есть другой тип комментариев - документирующие комментарии, они используются в документации, и их обсуждаются разделе "Публикация пакета на Crates.io" Главы 14.

Поток управления

Способность запускать некоторый код в зависимости от истинности условия или выполнять некоторый код многократно, пока условие истинно, является базовым элементом большинства языков программирования. Наиболее распространёнными конструкциями, позволяющими управлять потоком выполнения кода в Rust, являются выражения if и циклы.

Выражения if

Выражение if позволяет разветвлять код в зависимости от условий. Вы задаёте условие, а затем объявляете: "Если это условие соблюдено, то выполнить этот блок кода. Если условие не соблюдается, не выполнять этот блок кода".

Для изучения выражения if создайте новый проект под названием branches в каталоге projects. В файл src/main.rs поместите следующий код:

Имя файла: src/main.rs

fn main() {
    let number = 3;

    if number < 5 {
        println!("condition was true");
    } else {
        println!("condition was false");
    }
}

Все выражения if начинаются с ключевого слова if, за которым следует условие. В этом случае условие проверяет, имеет ли переменная number значение меньше 5. Если условие истинно, мы помещаем блок исполняемого кода сразу после условия внутри фигурных скобок. Блоки кода, связанные с условиями в выражениях if, иногда называются ответвлениями, точно так же, как ответвления в выражениях match, которые мы обсуждали в разделе «Сравнение догадки с секретным числом». Главы 2.

Опционально можно включить выражение else, которое мы используем в данном примере, чтобы предоставить программе альтернативный блок выполнения кода, выполняющийся при ложном условии. Если не указать выражение else и условие будет ложным, программа просто пропустит блок if и перейдёт к следующему фрагменту кода.

Попробуйте запустить этот код. Появится следующий результат:

$ cargo run
   Compiling branches v0.1.0 (file:///projects/branches)
    Finished dev [unoptimized + debuginfo] target(s) in 0.31s
     Running `target/debug/branches`
condition was true

Попробуйте изменить значение number на значение, которое делает условие false и посмотрите, что произойдёт:

fn main() {
    let number = 7;

    if number < 5 {
        println!("condition was true");
    } else {
        println!("condition was false");
    }
}

Запустите программу снова и посмотрите на вывод:

$ cargo run
   Compiling branches v0.1.0 (file:///projects/branches)
    Finished dev [unoptimized + debuginfo] target(s) in 0.31s
     Running `target/debug/branches`
condition was false

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

Имя файла: src/main.rs

fn main() {
    let number = 3;

    if number {
        println!("number was three");
    }
}

На этот раз условие if вычисляется в значение 3, и Rust бросает ошибку:

$ cargo run
   Compiling branches v0.1.0 (file:///projects/branches)
error[E0308]: mismatched types
 --> src/main.rs:4:8
  |
4 |     if number {
  |        ^^^^^^ expected `bool`, found integer

For more information about this error, try `rustc --explain E0308`.
error: could not compile `branches` due to previous error

Ошибка говорит, что Rust ожидал тип bool, но получил значение целочисленного типа. В отличии от других языков вроде Ruby и JavaScript, Rust не будет пытаться автоматически конвертировать нелогические типы в логические. Необходимо быть явным и всегда использовать if с логическим типом в качестве условия. Если нужно, чтобы блок кода if запускался только, когда число не равно 0, то, например, мы можем изменить выражение if на следующее:

Имя файла: src/main.rs

fn main() {
    let number = 3;

    if number != 0 {
        println!("number was something other than zero");
    }
}

Будет напечатана следующая строка number was something other than zero.

Обработка нескольких условий с помощью else if

Можно использовать несколько условий, комбинируя if и else в выражении else if. Например:

Имя файла: src/main.rs

fn main() {
    let number = 6;

    if number % 4 == 0 {
        println!("number is divisible by 4");
    } else if number % 3 == 0 {
        println!("number is divisible by 3");
    } else if number % 2 == 0 {
        println!("number is divisible by 2");
    } else {
        println!("number is not divisible by 4, 3, or 2");
    }
}

У этой программы есть четыре возможных пути выполнения. После её запуска вы должны увидеть следующий результат:

$ cargo run
   Compiling branches v0.1.0 (file:///projects/branches)
    Finished dev [unoptimized + debuginfo] target(s) in 0.31s
     Running `target/debug/branches`
number is divisible by 3

Во время выполнения этой программы по очереди проверяется каждое выражение if и выполняется первое тело, для которого условие истинно. Заметьте, что хотя 6 делится на 2, мы не видим ни вывода number is divisible by 2, ни текста number is not divisible by 4, 3, or 2 из блока else. Так происходит потому, что Rust выполняет блок только для первого истинного условия, а обнаружив его, даже не проверяет остальные.

Использование множества выражений else if приводит к загромождению кода, поэтому при наличии более чем одного выражения, возможно, стоит провести рефакторинг кода. В главе 6 описана мощная конструкция ветвления Rust для таких случаев, называемая match.

Использование if в let-операторах

Поскольку if является выражением, его можно использовать в правой части оператора let для присвоения результата переменной, как в листинге 3-2.

Имя файла: src/main.rs

fn main() {
    let condition = true;
    let number = if condition { 5 } else { 6 };

    println!("The value of number is: {number}");
}

Листинг 3-2: Присвоение результата выражения if переменной

Переменная number будет привязана к значению, которое является результатом выражения if. Запустим код и посмотрим, что происходит:

$ cargo run
   Compiling branches v0.1.0 (file:///projects/branches)
    Finished dev [unoptimized + debuginfo] target(s) in 0.30s
     Running `target/debug/branches`
The value of number is: 5

Вспомните, что блоки кода вычисляются последним выражением в них, а числа сами по себе также являются выражениями. В данном случае, значение всего выражения if зависит от того, какой блок выполняется. При этом значения, которые могут быть результатами каждого из ветвей if, должны быть одного типа. В Листинге 3-2, результатами обеих ветвей if и else являются целочисленный тип i32. Если типы не совпадают, как в следующем примере, мы получим ошибку:

Имя файла: src/main.rs

fn main() {
    let condition = true;

    let number = if condition { 5 } else { "six" };

    println!("The value of number is: {number}");
}

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

$ cargo run
   Compiling branches v0.1.0 (file:///projects/branches)
error[E0308]: `if` and `else` have incompatible types
 --> src/main.rs:4:44
  |
4 |     let number = if condition { 5 } else { "six" };
  |                                 -          ^^^^^ expected integer, found `&str`
  |                                 |
  |                                 expected because of this

For more information about this error, try `rustc --explain E0308`.
error: could not compile `branches` due to previous error

Выражение в блоке if вычисляется как целочисленное, а выражение в блоке else вычисляется как строка. Это не сработает, потому что переменные должны иметь один тип, а Rust должен знать во время компиляции, какого типа переменная number. Зная тип number, компилятор может убедиться, что тип действителен везде, где мы используем number. Rust не смог бы этого сделать, если бы тип number определялся только во время выполнения.. Компилятор усложнился бы и давал бы меньше гарантий в отношении кода, если бы ему приходилось отслеживать несколько гипотетических типов для любой переменной.

Повторение выполнения кода с помощью циклов

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

В Rust есть три вида циклов: loop, while и for. Давайте попробуем каждый из них.

Повторение выполнения кода с помощью loop

Ключевое слово loop говорит Rust выполнять блок кода снова и снова до бесконечности или пока не будет явно приказано остановиться.

В качестве примера, измените код файла src/main.rs в каталоге проекта loops на код ниже:

Имя файла: src/main.rs

fn main() {
    loop {
        println!("again!");
    }
}

Когда запустим эту программу, увидим, как again! печатается снова и снова, пока не остановить программу вручную. Большинство терминалов поддерживают комбинацию клавиш ctrl-c для прерывания программы, которая застряла в непрерывном цикле. Попробуйте:

$ cargo run
   Compiling loops v0.1.0 (file:///projects/loops)
    Finished dev [unoptimized + debuginfo] target(s) in 0.29s
     Running `target/debug/loops`
again!
again!
again!
again!
^Cagain!

Символ ^C обозначает место, где было нажато ctrl-c . В зависимости от того, где находился код в цикле в момент получения сигнала прерывания, вы можете увидеть или не увидеть слово again!, напечатанное после ^C.

К счастью, Rust также предоставляет способ выйти из цикла с помощью кода. Ключевое слово break нужно поместить в цикл, чтобы указать программе, когда следует прекратить выполнение цикла. Напоминаем, мы делали так в игре "Угадайка" в разделе "Выход после правильной догадки" главы 2, чтобы выйти из программы, когда пользователь выиграл игру, угадав правильное число.

Мы также использовали continue в игре "Угадайка", которая указывает программе в цикле пропустить весь оставшийся код в данной итерации цикла и перейти к следующей итерации.

Возвращение значений из циклов

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

fn main() {
    let mut counter = 0;

    let result = loop {
        counter += 1;

        if counter == 10 {
            break counter * 2;
        }
    };

    println!("The result is {result}");
}

Перед циклом мы объявляем переменную с именем counter и инициализируем её значением 0. Затем мы объявляем переменную с именем result для хранения значения, возвращаемого из цикла. На каждой итерации цикла мы добавляем 1 к переменной counter, а затем проверяем, равен ли счётчик 10. Когда это происходит, мы используем ключевое слово break со значением counter * 2. После цикла мы ставим точку с запятой для завершения инструкции, присваивающей значение result. Наконец, мы выводим значение в result , равное в данном случае 20.

Метки циклов для устранения неоднозначности между несколькими циклами

Если у вас есть циклы внутри циклов, break и continue применяются к самому внутреннему циклу в этой цепочке. При желании вы можете создать метку цикла, которую вы затем сможете использовать с break или continue для указания, что эти ключевые слова применяются к помеченному циклу, а не к самому внутреннему циклу. Метки цикла должны начинаться с одинарной кавычки. Вот пример с двумя вложенными циклами:

fn main() {
    let mut count = 0;
    'counting_up: loop {
        println!("count = {count}");
        let mut remaining = 10;

        loop {
            println!("remaining = {remaining}");
            if remaining == 9 {
                break;
            }
            if count == 2 {
                break 'counting_up;
            }
            remaining -= 1;
        }

        count += 1;
    }
    println!("End count = {count}");
}

Внешний цикл имеет метку 'counting_up, и он будет считать от 0 до 2. Внутренняя петля без метки ведёт обратный отсчёт от 10 до 9. Первый break, который не содержит метку, выйдет только из внутреннего цикла. Оператор break 'counting_up; завершит внешний цикл. Этот код напечатает:

$ cargo run
   Compiling loops v0.1.0 (file:///projects/loops)
    Finished dev [unoptimized + debuginfo] target(s) in 0.58s
     Running `target/debug/loops`
count = 0
remaining = 10
remaining = 9
count = 1
remaining = 10
remaining = 9
count = 2
remaining = 10
End count = 2

Циклы с условием while

В программе часто требуется проверить состояние условия в цикле. Пока условие истинно, цикл выполняется. Когда условие перестаёт быть истинным, программа вызывает break, останавливая цикл. Такое поведение можно реализовать с помощью комбинации loop, if, else и break. При желании попробуйте сделать это в программе. Это настолько распространённый паттерн, что в Rust реализована встроенная языковая конструкция для него, называемая цикл while. В листинге 3-3 мы используем while, чтобы выполнить три цикла программы, производя каждый раз обратный отсчёт, а затем, после завершения цикла, печатаем сообщение и выходим.

Имя файла: src/main.rs

fn main() {
    let mut number = 3;

    while number != 0 {
        println!("{number}!");

        number -= 1;
    }

    println!("LIFTOFF!!!");
}

Листинг 3-3: Использование цикла while для выполнения кода, пока условие истинно

Эта конструкция устраняет множество вложений, которые потребовались бы при использовании loop, if, else и break, и она более понятна. Пока условие истинно, код выполняется, в противном случае происходит выход из цикла.

Цикл по элементам коллекции с помощью for

Для перебора элементов коллекции, например, массива, можно использовать конструкцию while. Например, цикл в листинге 3-4 печатает каждый элемент массива a.

Имя файла: src/main.rs

fn main() {
    let a = [10, 20, 30, 40, 50];
    let mut index = 0;

    while index < 5 {
        println!("the value is: {}", a[index]);

        index += 1;
    }
}

Листинг 3-4: Перебор каждого элемента коллекции с помощью цикла while

Этот код выполняет перебор элементов массива. Он начинается с индекса 0, а затем циклически выполняется, пока не достигнет последнего индекса в массиве (то есть, когда index < 5 уже не является истиной). Выполнение этого кода напечатает каждый элемент массива:

$ cargo run
   Compiling loops v0.1.0 (file:///projects/loops)
    Finished dev [unoptimized + debuginfo] target(s) in 0.32s
     Running `target/debug/loops`
the value is: 10
the value is: 20
the value is: 30
the value is: 40
the value is: 50

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

Однако такой подход чреват ошибками. Можно вызвать панику в программе, если значение индекса или условие теста неверны. Например, если изменить определение массива a на четыре элемента, но забыть обновить условие на while index < 4, код вызовет панику. Также это медленно, поскольку компилятор добавляет код времени выполнения для обеспечения проверки нахождения индекса в границах массива на каждой итерации цикла.

В качестве более краткой альтернативы можно использовать цикл for и выполнять некоторый код для каждого элемента коллекции. Цикл for может выглядеть как код в листинге 3-5.

Имя файла: src/main.rs

fn main() {
    let a = [10, 20, 30, 40, 50];

    for element in a {
        println!("the value is: {element}");
    }
}

Листинг 3-5: Перебор каждого элемента коллекции с помощью цикла for

При выполнении этого кода мы увидим тот же результат, что и в листинге 3-4. Что важнее, теперь мы повысили безопасность кода и устранили вероятность ошибок, которые могут возникнуть в результате выхода за пределы массива или недостаточно далёкого перехода и пропуска некоторых элементов.

При использовании цикла for не нужно помнить о внесении изменений в другой код, в случае изменения количества значений в массиве, как это было бы с методом, использованным в листинге 3-4.

Безопасность и компактность циклов for делают их наиболее часто используемой конструкцией цикла в Rust. Даже в ситуациях необходимости выполнения некоторого кода определённое количество раз, как в примере обратного отсчёта, в котором использовался цикл while из Листинга 3-3, большинство Rustaceans использовали бы цикл for. Для этого можно использовать Range, предоставляемый стандартной библиотекой, который генерирует числа по порядку, начиная с одного числа и заканчивая другим числом.

Вот как будет выглядеть обратный отсчёт с использованием цикла for и другого метода, о котором мы ещё не говорили, rev, для разворота диапазона:

Имя файла: src/main.rs

fn main() {
    for number in (1..4).rev() {
        println!("{number}!");
    }
    println!("LIFTOFF!!!");
}

Данный код выглядит лучше, не так ли?

Итоги

Вы справились! Это была объёмная глава: вы узнали о переменных, скалярных и составных типах данных, функциях, комментариях, выражениях if и циклах! Для практики работы с концепциями, обсуждаемыми в этой главе, попробуйте создать программы для выполнения следующих действий:

  • Конвертация температур между значениями по Фаренгейту к Цельсия.
  • Генерирование n-го числа Фибоначчи.
  • Распечатайте текст рождественской песни "Двенадцать дней Рождества", воспользовавшись повторами в песне.

Когда вы будете готовы двигаться дальше, мы поговорим о концепции в Rust, которая не существует обычно в других языках программирования: владение.

Понимание Владения

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

Что же такое владение?

Владение — это набор правил, определяющих, как программа на Rust управляет памятью. Все программы должны управлять тем, как они используют память компьютера во время работы. Некоторые языки имеют сборку мусора, которая регулярно ищет неиспользуемую память во время работы программы; в других языках программист должен явно выделять и освобождать память. В Rust используется третий подход: управление памятью происходит через систему владения с набором правил, которые проверяет компилятор. При нарушении любого из правил программа не будет скомпилирована. Ни одна из особенностей владения не замедлит работу вашей программы.

Поскольку владение является новой концепцией для многих программистов, требуется некоторое время, чтобы привыкнуть к ней. Хорошая новость заключается в том, что чем более опытным вы станете с Rust и с правилами системы владения, тем легче вам будет естественным образом разрабатывать безопасный и эффективный код. Держитесь! Не сдавайтесь!

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

Стек и куча (heap)

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

И стек, и куча — это части памяти, доступные вашему коду для использования во время выполнения, но они структурированы по-разному. Стек хранит значения в порядке их получения и удаляет значения в обратном порядке. Что называется «последний пришёл, первый вышел». Подумайте о стопке тарелок: когда вы добавляете тарелки, вы кладёте их сверху стопки, а когда вам нужна тарелка, вы берёте одну сверху. Добавление или удаление тарелок посередине или снизу не сработает этим же образом! Добавление данных называется помещением в стек, а удаление данных называется извлечением из стека. Все данные, хранящиеся в стеке, должны иметь известный фиксированный размер. Вместо этого данные с неизвестным размером во время компиляции или размером, который может измениться, должны храниться в куче.

Куча менее организована: когда вы помещаете данные в кучу, вы запрашиваете определённое количество места. Распределитель памяти находит достаточно большое пустое место в куче, помечает его как используемое и возвращает указатель, являющийся адресом этого места. Этот процесс называется выделением в куче и иногда сокращённо просто выделением (помещение значений в стек не считается выделением). Поскольку указатель на кучу имеет известный фиксированный размер, вы можете хранить указатель в стеке, но когда вам нужны фактические данные, вы должны следовать за указателем. Представьте, что вы сидите в ресторане. Когда вы входите, вы называете количество человек в вашей группе, и персонал находит свободный стол, который подходит всем, и ведёт вас туда. Если кто-то из вашей группы опоздает, он может спросить, где вы сидели, чтобы найти вас.

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

Доступ к данным в куче медленнее, чем доступ к данным в стеке, потому что вам нужно следовать указателю, чтобы добраться туда. Современные процессоры быстрее, если они меньше прыгают в памяти. Продолжая аналогию, рассмотрим официанта в ресторане, принимающего заказы со многих столов. Наиболее эффективно получить все заказы за одним столом, прежде чем переходить к следующему столу. Получение заказа из таблицы А, затем заказа из таблицы В, затем снова одного из А, а затем снова одного из В было бы гораздо более медленным процессом. Точно так же процессор может выполнять свою работу лучше, если он работает с данными, которые находятся близко к другим данным (как в стеке), а не дальше (как это может быть в куче).

Когда ваш код вызывает функцию, значения, переданные в функцию (включая, потенциально, указатели на данные в куче), и локальные переменные функции помещаются в стек. Когда функция завершается, эти значения извлекаются из стека.

Отслеживание того, какие части кода используют какие данные в куче, минимизация количества дублирующихся данных в куче и очистка неиспользуемых данных в куче, чтобы не исчерпать пространство, — всё это проблемы, которые решает владение. Как только вы поймёте, что такое владение, вам не нужно будет слишком часто думать о стеке и куче, но знание того, что основная цель владения — управление данными кучи, может помочь объяснить, почему оно работает именно так.

Правила владения

Во-первых, давайте взглянем на правила владения. Помните об этих правилах, пока мы работаем с примерами, которые их иллюстрируют:

  • У каждого значения в Rust есть владелец.
  • у значения может быть только один владелец в один момент времени,
  • когда владелец покидает область видимости, значение удаляется.

Область видимости переменной

Теперь, когда мы прошли базовый синтаксис Rust, мы не будем включать весь код fn main() { в примеры, поэтому, если вы будете следовать этому курсу, убедитесь, что следующие примеры помещены в функцию main вручную. В результате наши примеры будут более лаконичными, что позволит нам сосредоточиться на реальных деталях, а не на шаблонном коде.

В качестве первого примера владения мы рассмотрим область действия некоторых переменных. Область действия — это диапазон внутри программы, для которого допустим элемент. Возьмём следующую переменную:


#![allow(unused)]
fn main() {
let s = "hello";
}

Переменная s относится к строковому литералу, где значение строки жёстко запрограммировано в текст нашей программы. Переменная действительна с момента её объявления до конца текущей области видимости. В Листинге 4-1 показана программа с комментариями, указывающими, где допустима переменная s .

fn main() {
    {                      // s is not valid here, it’s not yet declared
        let s = "hello";   // s is valid from this point forward

        // do stuff with s
    }                      // this scope is now over, and s is no longer valid
}

Листинг 4-1. Переменная и область действия, в которой она допустима

Другими словами, здесь есть два важных момента:

  • когда переменная s появляется в области видимости, она считается действительной,
  • она остаётся действительной до момента выхода за границы этой области.

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

Тип данных String

Чтобы проиллюстрировать правила владения, нам нужен тип данных, более сложный, чем те, которые мы рассмотрели в разделе «Типы данных». Главы 3. Все рассмотренные ранее типы имеют известный размер, могут храниться в стеке и извлекаться из стека, когда их область действия заканчивается, и могут быть быстро и легко скопированы для создания нового независимого экземпляра, если другая часть кода должна использовать то же значение в другой области. Но мы хотим посмотреть на данные, хранящиеся в куче, и выяснить, как Rust узнает, когда нужно очистить эти данные, и тип String — отличный пример.

Мы сконцентрируемся на тех частях String, которые связаны с владением. Эти аспекты также применимы к другим сложным типам данных, независимо от того, предоставлены ли они стандартной библиотекой или созданы вами. Мы обсудим String более подробно в Главе 8..

Мы уже видели строковые литералы, где строковое значение жёстко закодировано в нашей программе. Строковые литералы удобны, но они подходят не для каждой ситуации, в которой мы можем захотеть использовать текст. Одна из причин заключается в том, что они неизменны. Во-вторых, не каждое строковое значение может быть известно, когда мы пишем наш код: например, что, если мы хотим принять пользовательский ввод и сохранить его? Для таких ситуаций в Rust есть второй строковый тип — String. Этот тип управляет данными, выделенными в куче, и поэтому может хранить объём текста, который нам неизвестен во время компиляции. Вы можете создать String из строкового литерала, используя функцию from, например:


#![allow(unused)]
fn main() {
let s = String::from("hello");
}

Оператор двойное двоеточие :: позволяет нам использовать пространство имён этой конкретной функции from под типом String , а не использовать какое-то имя вроде string_from. Мы обсудим этот синтаксис более подробно в разделе «Синтаксис метода». Главы 5 и когда мы будем говорить о пространствах имён с модулями в «Пути для обращения к элементу в дереве модулей» в Главе 7.

Строка такого типа может быть изменяема:

fn main() {
    let mut s = String::from("hello");

    s.push_str(", world!"); // push_str() appends a literal to a String

    println!("{}", s); // This will print `hello, world!`
}

В чем здесь разница? Почему String можно менять, а литерал нельзя? Разница в том, как эти два типа работают с памятью.

Память и способы её выделения

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

Чтобы поддерживать изменяемый, увеличивающийся кусок текста типа String, ему необходимо выделять память в куче для всего содержимого (объем которого неизвестен во время компиляции). Это означает, что:

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

Эта первая часть выполняется нами: когда мы вызываем String::from, его реализация запрашивает необходимую память. Это довольно универсально в языках программирования.

Однако вторая часть отличается. В языках с сборщиком мусора (GC) он отслеживает и очищает память, которая больше не используется, и нам не нужно об этом думать. В большинстве языков без сборщика мусора мы обязаны определить, когда память больше не используется, и вызвать код для явного её освобождения, точно так же, как мы делали это для её запроса. Выполнение этого процесса правильно исторически было сложной проблемой программирования. Если мы забудем это сделать, мы потеряем память. Если мы сделаем это слишком рано, у нас будет недопустимая переменная. Если мы сделаем это дважды, это тоже ошибка. Нам нужно соединить ровно один allocate ровно с одним free.

Rust выбирает другой путь: память автоматически возвращается как только переменная владеющая памятью выходит из области видимости. Вот версия примера с областью видимости из листинга 4-1 использующего тип String вместо строкового литерала:

fn main() {
    {
        let s = String::from("hello"); // s is valid from this point forward

        // do stuff with s
    }                                  // this scope is now over, and s is no
                                       // longer valid
}

Существует естественный момент, когда мы можем вернуть память, необходимую нашей String, распределителю: когда s выходит за пределы области видимости. Когда переменная выходит за пределы области видимости, Rust вызывает для нас специальную функцию. Эта функция называется drop, и именно здесь автор String может поместить код для возврата памяти. Вызовы Rust автоматически drop на закрывающую фигурную скобку.

Заметьте: Данный шаблон освобождения ресурсов в конце цикла жизни переменной в C++ иногда называется Resource Acquisition Is Initialization (RAII). Функция drop в Rust будет вам знакома, если вы уже использовали шаблон RAII.

Этот шаблон оказывает глубокое влияние на способ написания кода в Rust. Сейчас это может казаться простым, но в более сложных ситуациях поведение кода может быть неожиданным, например, когда хочется иметь несколько переменных использующих данные выделенные в куче. Изучим несколько таких ситуаций.

Способы взаимодействия переменных и данных: перемещение

Несколько переменных могут по-разному взаимодействовать с одними и теми же данными в Rust. Давайте рассмотрим пример использования целого числа в Листинге 4-2.

fn main() {
    let x = 5;
    let y = x;
}

Листинг 4-2. Присвоение целочисленного значения переменной x к переменной y

Вероятно, мы можем догадаться, что это делает: «привязать значение 5 к x; затем сделать копию значения в x и привязать его к y». Теперь у нас есть две переменные, x и y, и обе равны 5. Это действительно то, что происходит, потому что целые числа — это простые значения с известным фиксированным размером, и эти два значения 5 помещаются в стек.

Теперь рассмотрим версию с типом String:

fn main() {
    let s1 = String::from("hello");
    let s2 = s1;
}

Это выглядит очень похожим, поэтому мы можем предположить, что способ работы будет таким же: то есть вторая строка сделает копию значения в s1 и привяжет его к s2. Но это не совсем то, что происходит.

Взгляните на Рисунок 4-1, чтобы увидеть, что происходит с String под капотом. String состоит из трёх частей, показанных слева: указатель на память, в которой хранится содержимое строки, длина и ёмкость. Эта группа данных хранится в стеке. Справа память в куче, которая хранит содержимое.

Строка в памяти

Рисунок 4-1: Представление в памяти String, содержащей значение "hello", привязанное к s1

Длина - это сколько байт памяти использует содержимое String в данный момент. Ёмкость - это общее количество байт памяти, которые String получила от операционной системы. Разница между длиной и ёмкостью имеет значение, но не в данном контексте, сейчас можно игнорировать ёмкость.

Когда мы присваиваем s1 значение s2, данные String копируются, то есть мы копируем указатель, длину и ёмкость, которые находятся в стеке. Мы не копируем данные в куче, на которую указывает указатель. Другими словами, представление данных в памяти выглядит так, как показано на Рисунке 4-2.

s1 и s2 указывают на одинаковое значение

Рисунок 4-2: Представление в памяти переменной s2, имеющей копию указателя, длины и ёмкости s1

Представление не похоже на Рисунок 4-3, как выглядела бы память, если бы вместо этого Rust также скопировал данные кучи. Если бы Rust сделал это, операция s2 = s1 могла бы быть очень дорогой с точки зрения производительности во время выполнения, если бы данные в куче были большими.

Строка в памяти

Рисунок 4-3: Другой вариант того, что может сделать s2 = s1, если Rust также скопирует данные кучи

Ранее мы сказали, что когда переменная выходит из области видимости, Rust автоматически вызывает функцию drop и очищает память кучи для данной переменной. Но картинка 4-2 показывает, что теперь оба указателя указывают на одно и тоже место. Это проблема: когда переменная s2 и переменная s1 выходят из области видимости они обе будут пытаться освободить одну и туже память в куче. Это известно как "ошибка двойного освобождения", double free, и является одной из ошибок безопасности памяти, упоминаемых ранее. Освобождение памяти дважды может привести к повреждению памяти, что потенциально может привести к уязвимостям безопасности.

Чтобы обеспечить безопасность памяти, после строки let s2 = s1 Rust считает s1 более недействительным. Следовательно, Rust не нужно ничего освобождать, когда s1 выходит за пределы области видимости. Посмотрите, что происходит, когда вы пытаетесь использовать s1 после создания s2; это не сработает:

fn main() {
    let s1 = String::from("hello");
    let s2 = s1;

    println!("{}, world!", s1);
}

Вы получите ошибку ниже, потому что Rust не даст использовать недействительную ссылку s1:

$ cargo run
   Compiling ownership v0.1.0 (file:///projects/ownership)
error[E0382]: borrow of moved value: `s1`
 --> src/main.rs:5:28
  |
2 |     let s1 = String::from("hello");
  |         -- move occurs because `s1` has type `String`, which does not implement the `Copy` trait
3 |     let s2 = s1;
  |              -- value moved here
4 | 
5 |     println!("{}, world!", s1);
  |                            ^^ value borrowed here after move
  |
  = note: this error originates in the macro `$crate::format_args_nl` (in Nightly builds, run with -Z macro-backtrace for more info)

For more information about this error, try `rustc --explain E0382`.
error: could not compile `ownership` due to previous error

Если вы слышали термины поверхностное копирование и глубокое копирование при работе с другими языками, концепция копирования указателя, длины и ёмкости без копирования данных, вероятно, звучит как создание поверхностной копии. Но поскольку Rust также аннулирует первую переменную, вместо того, чтобы называть её поверхностной копией, это называется перемещением. В этом примере мы бы сказали, что s1 был перемещён в s2. Что происходит на самом деле, показано на Рисунке 4-4.

s1 и s2 указывают на одинаковое значение

Рисунок 4-4: Представление в памяти после того, как s1 был признан недействительным

Это решает нашу проблему! Действительной остаётся только переменная s2, когда она выходит из области видимости, то она одна будет освобождать память в куче.

Дополнительно, присутствует выбор дизайна, который подразумевает следующее: Rust никогда не будет автоматически создавать "глубокие" копии ваших данных. Следовательно, любое такое автоматическое копирование, можно считать недорогим с точки зрения производительности во время выполнения.

Способы взаимодействия переменных и данных: клонирование

Если мы хотим глубоко скопировать данные кучи String, а не только данные стека, мы можем использовать общий метод, называемый clone. Мы обсудим синтаксис методов в Главе 5, но, поскольку методы являются общей чертой многих языков программирования, вы, вероятно, уже встречались с ними.

Вот пример метода clone в действии:

fn main() {
    let s1 = String::from("hello");
    let s2 = s1.clone();

    println!("s1 = {}, s2 = {}", s1, s2);
}

Код работает отлично и явно выполняет поведение, показанное на картинке 4-3, где данные в куче действительно скопированы.

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

Стековые данные: Копирование

Это ещё одна особенность о которой мы ещё не говорили. Этот код, часть которого была показа ранее в листинге 4-2, использует целые числа. Этот код работает и не имеет ошибок:

fn main() {
    let x = 5;
    let y = x;

    println!("x = {}, y = {}", x, y);
}

Но данный код, кажется противоречит тому, что мы только что изучили: тут не нужно вызывать clone, но x является все ещё действительной переменной и не перемещена в y.

Причина в том, что такие типы, как целые числа, размер которых известен во время компиляции, полностью хранятся в стеке, поэтому копии фактических значений создаются быстро. Это означает, что нет причин, по которым мы хотели бы предотвратить допустимость x после того, как мы создадим переменную y. Другими словами, здесь нет разницы между глубоким и поверхностным копированием, поэтому вызов clone ничем не отличается от обычного поверхностного копирования, и мы можем его опустить.

В Rust есть специальная аннотация, называемая типажом Copy, которую мы можем размещать на типах, хранящихся в стеке, как и целые числа (подробнее о типах мы поговорим в Главе 10). Если тип реализует типаж Copy, переменные, которые его используют, не перемещаются, а тривиально копируются, что делает их действительными после присвоения другой переменной.

Rust не позволит нам аннотировать тип с помощью Copy, если тип или любая из его частей реализует Drop. Если для типа нужно, чтобы произошло что-то особенное, когда значение выходит за пределы области видимости, и мы добавляем аннотацию Copy к этому типу, мы получим ошибку времени компиляции. Чтобы узнать, как добавить аннотацию Copy к вашему типу для реализации типажа, смотрите Раздел «Производные типажи». в Приложении С.

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

  • все целочисленные типы, такие как u32,
  • логический тип данных bool, возможные значения которого true и false,
  • все числа с плавающей запятой такие как f64,
  • символьный тип char,
  • кортежи, но только если они содержат типы, которые также реализуют Copy. Например, (i32, i32) будет с Copy, но кортеж (i32, String) уже нет.

Владение и функции

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

Файл: src/main.rs

fn main() {
    let s = String::from("hello");  // s comes into scope

    takes_ownership(s);             // s's value moves into the function...
                                    // ... and so is no longer valid here

    let x = 5;                      // x comes into scope

    makes_copy(x);                  // x would move into the function,
                                    // but i32 is Copy, so it's okay to still
                                    // use x afterward

} // Here, x goes out of scope, then s. But because s's value was moved, nothing
  // special happens.

fn takes_ownership(some_string: String) { // some_string comes into scope
    println!("{}", some_string);
} // Here, some_string goes out of scope and `drop` is called. The backing
  // memory is freed.

fn makes_copy(some_integer: i32) { // some_integer comes into scope
    println!("{}", some_integer);
} // Here, some_integer goes out of scope. Nothing special happens.

Листинг 4-3. Функции с аннотированными владением и областью видимости

Если бы мы попытались использовать s после вызова takes_ownership, Rust выдал бы ошибку во время компиляции. Эти статические проверки защищают нас от ошибок. Попробуйте добавить в main код, который использует s и x, чтобы увидеть, где вы можете их использовать, а где правила владения не позволяют вам это сделать.

Возвращение значений и область видимости

Возвращаемые значения также могут передавать право владения. В Листинге 4-4 показан пример функции, возвращающей некоторое значение, с такими же аннотациями, как в Листинге 4-3.

Файл: src/main.rs

fn main() {
    let s1 = gives_ownership();         // gives_ownership moves its return
                                        // value into s1

    let s2 = String::from("hello");     // s2 comes into scope

    let s3 = takes_and_gives_back(s2);  // s2 is moved into
                                        // takes_and_gives_back, which also
                                        // moves its return value into s3
} // Here, s3 goes out of scope and is dropped. s2 was moved, so nothing
  // happens. s1 goes out of scope and is dropped.

fn gives_ownership() -> String {             // gives_ownership will move its
                                             // return value into the function
                                             // that calls it

    let some_string = String::from("yours"); // some_string comes into scope

    some_string                              // some_string is returned and
                                             // moves out to the calling
                                             // function
}

// This function takes a String and returns one
fn takes_and_gives_back(a_string: String) -> String { // a_string comes into
                                                      // scope

    a_string  // a_string is returned and moves out to the calling function
}

Листинг 4-4: Передача права владения на возвращаемые значения

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

Хотя это работает, получение права владения, а затем возвращение владения каждой функцией немного утомительно. Что, если мы хотим, чтобы функция использовала значение, но не становилась владельцем? Очень раздражает, что всё, что мы передаём, также должно быть передано обратно, если мы хотим использовать это снова, в дополнение к любым данным, полученным из тела функции, которые мы также можем захотеть вернуть.

Rust позволяет нам возвращать несколько значений с помощью кортежа, как показано в Листинге 4-5.

Файл: src/main.rs

fn main() {
    let s1 = String::from("hello");

    let (s2, len) = calculate_length(s1);

    println!("The length of '{}' is {}.", s2, len);
}

fn calculate_length(s: String) -> (String, usize) {
    let length = s.len(); // len() returns the length of a String

    (s, length)
}

Листинг 4-5: Возврат права владения на параметры

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

Ссылочные переменные и заимствование

Проблема с кодом кортежа в Листинге 4-5 заключается в том, что мы должны вернуть String из вызванной функции, чтобы мы могли использовать String после вызова calculate_length, потому что String была перемещена в calculate_length. Вместо этого мы можем предоставить ссылку на значение String. Ссылка похожа на указатель в том смысле, что это адрес, по которому мы можем следовать, чтобы получить доступ к данным, хранящимся по этому адресу; эти данные принадлежат какой-то другой переменной. В отличие от указателя, ссылка гарантированно указывает на допустимое значение определённого типа в течение всего срока существования этой ссылки.

Вот как вы могли бы определить и использовать функцию calculate_length, имеющую ссылку на объект в качестве параметра вместо того, чтобы брать на себя ответственность за значение:

Файл: src/main.rs

fn main() {
    let s1 = String::from("hello");

    let len = calculate_length(&s1);

    println!("The length of '{}' is {}.", s1, len);
}

fn calculate_length(s: &String) -> usize {
    s.len()
}

Во-первых, обратите внимание, что весь код кортежа в объявлении переменной и возвращаемое значение функции исчезли. Во-вторых, обратите внимание, что мы передаём &s1 в calculate_length и в его определении мы берём &String а не String. Эти амперсанды представляют собой ссылки, и они позволяют вам ссылаться на некоторое значение, не принимая владение им. Рисунок 4-5 изображает эту концепцию.

&String s pointing at String s1

Картинка 4-5: Диаграмма для &String s указывающей на String s1

Примечание. Противоположностью ссылки с использованием & является разыменование, выполняемое с помощью оператора разыменования *. Мы увидим некоторые варианты использования оператора разыменования в Главе 8 и обсудим детали разыменования в Главе 15.

Давайте подробнее рассмотрим механизм вызова функции:

fn main() {
    let s1 = String::from("hello");

    let len = calculate_length(&s1);

    println!("The length of '{}' is {}.", s1, len);
}

fn calculate_length(s: &String) -> usize {
    s.len()
}

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

Точно так же сигнатура функции использует & для указания того, что тип параметра s является ссылкой. Добавим несколько поясняющих аннотаций:

fn main() {
    let s1 = String::from("hello");

    let len = calculate_length(&s1);

    println!("The length of '{}' is {}.", s1, len);
}

fn calculate_length(s: &String) -> usize { // s is a reference to a String
    s.len()
} // Here, s goes out of scope. But because it does not have ownership of what
  // it refers to, it is not dropped.

Область действия, в которой s такая же, как и область действия любого параметра функции, но значение, на которое указывает ссылка, не удаляется, когда s перестаёт использоваться, потому что s не является владельцем. Когда функции имеют ссылки в качестве параметров вместо фактических значений, нам не нужно возвращать значения, чтобы вернуть право владения, потому что мы никогда не владели ими.

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

Так что же произойдёт, если мы попытаемся изменить что-то, что мы заимствуем? Попробуйте запустить код из Листинга 4-6. Спойлер: это не сработает!

Файл: src/main.rs

fn main() {
    let s = String::from("hello");

    change(&s);
}

fn change(some_string: &String) {
    some_string.push_str(", world");
}

Listing 4-6: Попытка модификации заимствованной переменной

Вот ошибка:

$ cargo run
   Compiling ownership v0.1.0 (file:///projects/ownership)
error[E0596]: cannot borrow `*some_string` as mutable, as it is behind a `&` reference
 --> src/main.rs:8:5
  |
7 | fn change(some_string: &String) {
  |                        ------- help: consider changing this to be a mutable reference: `&mut String`
8 |     some_string.push_str(", world");
  |     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `some_string` is a `&` reference, so the data it refers to cannot be borrowed as mutable

For more information about this error, try `rustc --explain E0596`.
error: could not compile `ownership` due to previous error

Как переменные неизменяемы по умолчанию, так и ссылки. Нам не разрешено изменять что-то, на что у нас есть ссылка.

Изменяемые ссылочные переменные

Мы можем исправить код из листинга 4-6, чтобы позволить нам изменять заимствованное значение с помощью всего лишь нескольких небольших настроек, которые вместо этого используют изменяемую ссылку:

Файл: src/main.rs

fn main() {
    let mut s = String::from("hello");

    change(&mut s);
}

fn change(some_string: &mut String) {
    some_string.push_str(", world");
}

Сначала мы меняем s на mut. Затем мы создаём изменяемую ссылку с помощью &mut s у которой вызываем change и обновляем сигнатуру функции, чтобы принять изменяемую ссылку с помощью some_string: &mut String. Это даёт понять, что change изменит значение, которое она заимствует.

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

Файл: src/main.rs

fn main() {
    let mut s = String::from("hello");

    let r1 = &mut s;
    let r2 = &mut s;

    println!("{}, {}", r1, r2);
}

Описание ошибки:

$ cargo run
   Compiling ownership v0.1.0 (file:///projects/ownership)
error[E0499]: cannot borrow `s` as mutable more than once at a time
 --> src/main.rs:5:14
  |
4 |     let r1 = &mut s;
  |              ------ first mutable borrow occurs here
5 |     let r2 = &mut s;
  |              ^^^^^^ second mutable borrow occurs here
6 | 
7 |     println!("{}, {}", r1, r2);
  |                        -- first borrow later used here

For more information about this error, try `rustc --explain E0499`.
error: could not compile `ownership` due to previous error

Эта ошибка говорит о том, что этот код недействителен, потому что мы не можем заимствовать s как изменяемые более одного раза в один момент. Первое изменяемое заимствование находится в r1 и должно длиться до тех пор, пока оно не будет использовано в println!, но между созданием этой изменяемой ссылки и её использованием мы попытались создать другую изменяемую ссылку в r2, которая заимствует те же данные, что и r1.

Ограничение, предотвращающее одновременное использование нескольких изменяемых ссылок на одни и те же данные, допускает изменение, но очень контролируемым образом. Это то, с чем борются новые Rustaceans, потому что большинство языков позволяют изменять значение, когда захотите. Преимущество этого ограничения заключается в том, что Rust может предотвратить гонки данных во время компиляции. Гонка данных похожа на состояние гонки и происходит, когда возникают следующие три сценария:

  • два или больше указателей используют те же данные в одно и тоже время,
  • минимум один указатель используется для записи данных,
  • отсутствуют механизмы для синхронизации доступа к данным.

Гонки данных вызывают неопределённое поведение и их может быть сложно диагностировать и исправить, когда вы пытаетесь отследить их во время выполнения; Rust предотвращает эту проблему, отказываясь компилировать код с гонками данных!

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

fn main() {
    let mut s = String::from("hello");

    {
        let r1 = &mut s;
    } // r1 goes out of scope here, so we can make a new reference with no problems.

    let r2 = &mut s;
}

Rust применяет аналогичное правило для комбинирования изменяемых и неизменяемых ссылок. Этот код приводит к ошибке:

fn main() {
    let mut s = String::from("hello");

    let r1 = &s; // no problem
    let r2 = &s; // no problem
    let r3 = &mut s; // BIG PROBLEM

    println!("{}, {}, and {}", r1, r2, r3);
}

Ошибка:

$ cargo run
   Compiling ownership v0.1.0 (file:///projects/ownership)
error[E0502]: cannot borrow `s` as mutable because it is also borrowed as immutable
 --> src/main.rs:6:14
  |
4 |     let r1 = &s; // no problem
  |              -- immutable borrow occurs here
5 |     let r2 = &s; // no problem
6 |     let r3 = &mut s; // BIG PROBLEM
  |              ^^^^^^ mutable borrow occurs here
7 | 
8 |     println!("{}, {}, and {}", r1, r2, r3);
  |                                -- immutable borrow later used here

For more information about this error, try `rustc --explain E0502`.
error: could not compile `ownership` due to previous error

Вау! У нас также не может быть изменяемой ссылки, пока у нас есть неизменяемая ссылка на то же значение.

Пользователи неизменяемой ссылки не ожидают, что значение внезапно изменится из-под них! Однако разрешены множественные неизменяемые ссылки, потому что никто, кто просто читает данные, не может повлиять на чтение данных кем-либо ещё.

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

fn main() {
    let mut s = String::from("hello");

    let r1 = &s; // no problem
    let r2 = &s; // no problem
    println!("{} and {}", r1, r2);
    // variables r1 and r2 will not be used after this point

    let r3 = &mut s; // no problem
    println!("{}", r3);
}

Области неизменяемых ссылок r1 и r2 заканчиваются после println! где они использовались в последний раз, то есть до создания изменяемой ссылки r3. Эти области не пересекаются, поэтому этот код разрешён. Способность компилятора сообщить, что ссылка больше не используется в точке до конца области видимости, называется нелексическим временем жизни (сокращённо NLL), и вы можете прочитать об этом больше в The Edition Guide.

Несмотря на то, что ошибки заимствования могут иногда вызывать разочарование, помните, что это компилятор Rust заранее указывает на потенциальную ошибку (во время компиляции, а не во время выполнения) и точно показывает, в чем проблема. Тогда вам не придётся выяснять, почему ваши данные не такие, как вы думали.

Недействительные ссылки

В языках с указателями весьма легко ошибочно создать недействительную, висячую (dangling) ссылку. Ссылку указывающую на участок памяти, который мог быть передан кому-то другому, путём освобождения некоторой памяти при сохранении указателя на эту память. Rust компилятор гарантирует, что ссылки никогда не станут недействительными: если у вас есть ссылка на какие-то данные, компилятор обеспечит что эти данные не выйдут из области видимости прежде, чем из области видимости исчезнет ссылка.

Попытаемся смоделировать подобную, висячую ссылку, появление которой компилятор предотвратит:

Файл: src/main.rs

fn main() {
    let reference_to_nothing = dangle();
}

fn dangle() -> &String {
    let s = String::from("hello");

    &s
}

Здесь ошибка:

$ cargo run
   Compiling ownership v0.1.0 (file:///projects/ownership)
error[E0106]: missing lifetime specifier
 --> src/main.rs:5:16
  |
5 | fn dangle() -> &String {
  |                ^ expected named lifetime parameter
  |
  = help: this function's return type contains a borrowed value, but there is no value for it to be borrowed from
help: consider using the `'static` lifetime
  |
5 | fn dangle() -> &'static String {
  |                ~~~~~~~~

For more information about this error, try `rustc --explain E0106`.
error: could not compile `ownership` due to previous error

Это сообщение об ошибке относится к особенности языка, которую мы ещё не рассмотрели: времени жизни. Мы подробно обсудим времена жизни в Главе 10. Но если вы не будете обращать внимания на части, касающиеся времени жизни, сообщение действительно содержит ключ к тому, почему этот код является проблемой:

this function's return type contains a borrowed value, but there is no value
for it to be borrowed from

Давайте подробнее рассмотрим, что именно происходит на каждом этапе нашего кода dangle:

Файл: src/main.rs

fn main() {
    let reference_to_nothing = dangle();
}

fn dangle() -> &String { // dangle returns a reference to a String

    let s = String::from("hello"); // s is a new String

    &s // we return a reference to the String, s
} // Here, s goes out of scope, and is dropped. Its memory goes away.
  // Danger!

Поскольку s создаётся внутри dangle, когда код dangle будет завершён, s будет освобождён. Но мы попытались вернуть ссылку на него. Это означает, что эта ссылка будет указывать на недопустимую String. Это не хорошо! Rust не позволит нам сделать это.

Решением является вернуть непосредственно String:

fn main() {
    let string = no_dangle();
}

fn no_dangle() -> String {
    let s = String::from("hello");

    s
}

Это работает без проблем. Владение перемещено, и ничего не освобождено.

Правила работы с ссылками

Давайте повторим все, что мы обсудили про ссылки:

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

В следующей главе мы рассмотрим другой тип ссылочных переменных - срезы.

Срезы

Срезы позволяют ссылаться на непрерывную последовательность элементов в коллекции, а не на всю коллекцию. Срез является своего рода ссылкой, поэтому он не имеет права владения.

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

Давайте рассмотрим, как бы мы написали сигнатуру этой функции без использования срезов, чтобы понять проблему, которую решат срезы:

fn first_word(s: &String) -> ?

Функция first_word имеет &String в качестве параметра. Мы не хотим владения, так что всё в порядке. Но что мы должны вернуть? На самом деле у нас нет способа говорить о части строки. Однако мы могли бы вернуть индекс конца слова, обозначенного пробелом. Давайте попробуем, как показано в Листинге 4-7.

Файл: src/main.rs

fn first_word(s: &String) -> usize {
    let bytes = s.as_bytes();

    for (i, &item) in bytes.iter().enumerate() {
        if item == b' ' {
            return i;
        }
    }

    s.len()
}

fn main() {}

Листинг 4-7. Функция first_word, возвращающая значение индекса байта в параметр String

Для того, чтобы найти пробел в строке, мы превратим String в массив байт, используя метод as_bytes и пройдём по String элемент за элементом, проверяя является ли значение пробелом.

fn first_word(s: &String) -> usize {
    let bytes = s.as_bytes();

    for (i, &item) in bytes.iter().enumerate() {
        if item == b' ' {
            return i;
        }
    }

    s.len()
}

fn main() {}

Далее, мы создаём итератор по массиву байт используя метод iter:

fn first_word(s: &String) -> usize {
    let bytes = s.as_bytes();

    for (i, &item) in bytes.iter().enumerate() {
        if item == b' ' {
            return i;
        }
    }

    s.len()
}

fn main() {}

Мы обсудим итераторы более подробно в Главе 13. На данный момент знайте, что iter — это метод, который возвращает каждый элемент в коллекции, а enumerate оборачивает результат iter и вместо этого возвращает каждый элемент как часть кортежа. Первый элемент кортежа, возвращаемый из enumerate, является индексом, а второй элемент — ссылкой на элемент. Это немного удобнее, чем вычислять индекс самостоятельно.

Поскольку метод enumerate возвращает кортеж, мы можем использовать шаблоны для деструктурирования этого кортежа. Мы подробнее обсудим шаблоны в Главе 6.. В цикле for мы указываем шаблон, имеющий i для индекса в кортеже и &item для одного байта в кортеже. Поскольку мы получаем ссылку на элемент из .iter().enumerate(), мы используем & в шаблоне.

Внутри цикла for, ищем байт представляющий пробел используя синтаксис байт литерала. Если пробел найден, возвращается его позиция. Иначе, возвращается длина строки s.len():

fn first_word(s: &String) -> usize {
    let bytes = s.as_bytes();

    for (i, &item) in bytes.iter().enumerate() {
        if item == b' ' {
            return i;
        }
    }

    s.len()
}

fn main() {}

Теперь у нас есть способ узнать индекс байта указывающего на конец первого слова в строке, но есть проблема. Мы возвращаем сам usize, но это число имеет значение только в контексте &String. Другими словами, поскольку это значение отдельное от String, то нет гарантии, что оно все ещё будет действительным в будущем. Рассмотрим программу из листинга 4-8, которая использует функцию first_word листинга 4-7.

Файл: src/main.rs

fn first_word(s: &String) -> usize {
    let bytes = s.as_bytes();

    for (i, &item) in bytes.iter().enumerate() {
        if item == b' ' {
            return i;
        }
    }

    s.len()
}

fn main() {
    let mut s = String::from("hello world");

    let word = first_word(&s); // word will get the value 5

    s.clear(); // this empties the String, making it equal to ""

    // word still has the value 5 here, but there's no more string that
    // we could meaningfully use the value 5 with. word is now totally invalid!
}

Листинг 4-8. Сохранение результата вызова функции first_word и последующего изменения содержимого String

Данная программа компилируется без ошибок и будет успешно работать, даже после того как мы воспользуемся переменной word после вызова s.clear(). Так как значение word совсем не связано с состоянием переменной s, то word сохраняет своё значение 5 без изменений. Мы могли бы использовать 5 вместе с переменной s и попытаться извлечь первое слово из строки, но это приведёт к ошибке, потому что содержимое s изменилось после того как мы сохранили 5 в переменной word (стало пустой строкой в вызове s.clear()).

Необходимость беспокоиться о том, что индекс в переменной word не синхронизируется с данными в переменной s является утомительной и подверженной ошибкам! Управление этими индексами становится ещё более хрупким, если мы напишем функцию second_word. Её сигнатура могла бы выглядеть так:

fn second_word(s: &String) -> (usize, usize) {

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

К счастью в Rust есть решение данной проблемы: строковые срезы.

Строковые срезы

Строковый срез - это ссылка на часть строки String и он выглядит следующим образом:

fn main() {
    let s = String::from("hello world");

    let hello = &s[0..5];
    let world = &s[6..11];
}

Вместо ссылки на всю String hello является ссылкой на часть String, указанную в дополнительном бите [0..5]. Мы создаём срезы, используя диапазон в квадратных скобках, указав [starting_index..ending_index], где starting_index — это первая позиция в срезе, а ending_index — на единицу больше последней позиции в срезе. Внутри структура данных среза хранит начальную позицию и длину среза, что соответствует ending_index минус starting_index. Таким образом, в случае let world = &s[6..11];, world будет срезом, содержащим указатель на байт с индексом 6 s со значением длины 5.

Рисунок 4-6 отображает это на диаграмме.

world containing a pointer to the 6th byte of String s and a length 5

Рисунок 4-6: Фрагмент строки, относящийся к части String

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


#![allow(unused)]
fn main() {
let s = String::from("hello");

let slice = &s[0..2];
let slice = &s[..2];
}

Таким же образом, если срез включает последний байт строки String, можно убрать завершающее число. Это эквивалентно:


#![allow(unused)]
fn main() {
let s = String::from("hello");

let len = s.len();

let slice = &s[3..len];
let slice = &s[3..];
}

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


#![allow(unused)]
fn main() {
let s = String::from("hello");

let len = s.len();

let slice = &s[0..len];
let slice = &s[..];
}

Примечание. Индексы диапазона срезов строк должны располагаться на допустимых границах символов UTF-8. Если вы попытаетесь создать фрагмент строки в середине много байтового символа, ваша программа завершится с ошибкой. В целях изучения срезов строк мы предполагаем, что в этом разделе используется только ASCII; более подробное обсуждение обработки UTF-8 находится в разделе «Сохранение закодированного текста UTF-8 со строками» . Главы 8.

Давайте используем полученную информацию и перепишем метод first_word так, чтобы он возвращал срез. Для обозначения типа "срез строки" существует запись &str:

Файл: src/main.rs

fn first_word(s: &String) -> &str {
    let bytes = s.as_bytes();

    for (i, &item) in bytes.iter().enumerate() {
        if item == b' ' {
            return &s[0..i];
        }
    }

    &s[..]
}

fn main() {}

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

Теперь, вызвав метод first_word, мы получим одно единственное значение, которое привязано к нижележащим данным. Значение, которое составлено из ссылки на начальную точку среза и количества элементов в срезе.

Аналогичным образом можно переписать и второй метод second_word:

fn second_word(s: &String) -> &str {

Теперь есть простое API, работу которого гораздо сложнее испортить, потому что компилятор обеспечивает нам то, что ссылки на String останутся действительными. Помните ошибку в программе листинга 4-8, когда мы получили индекс конца первого слова, но затем очистили строку, так что она стала недействительной? Тот код был логически некорректным, хотя не показывал никаких ошибок. Проблемы возникли бы позже, если бы мы попытались использовать индекс первого слова для пустой строки. Срезы делают невозможной данную ошибку и позволяют понять о наличии проблемы гораздо раньше. Так, использование версии метода first_word со срезом вернёт ошибку компиляции:

Файл: src/main.rs

fn first_word(s: &String) -> &str {
    let bytes = s.as_bytes();

    for (i, &item) in bytes.iter().enumerate() {
        if item == b' ' {
            return &s[0..i];
        }
    }

    &s[..]
}

fn main() {
    let mut s = String::from("hello world");

    let word = first_word(&s);

    s.clear(); // error!

    println!("the first word is: {}", word);
}

Ошибка компиляции:

$ cargo run
   Compiling ownership v0.1.0 (file:///projects/ownership)
error[E0502]: cannot borrow `s` as mutable because it is also borrowed as immutable
  --> src/main.rs:18:5
   |
16 |     let word = first_word(&s);
   |                           -- immutable borrow occurs here
17 | 
18 |     s.clear(); // error!
   |     ^^^^^^^^^ mutable borrow occurs here
19 | 
20 |     println!("the first word is: {}", word);
   |                                       ---- immutable borrow later used here

For more information about this error, try `rustc --explain E0502`.
error: could not compile `ownership` due to previous error

Напомним из правил заимствования, что если у нас есть неизменяемая ссылка на что-то, мы не можем также взять изменяемую ссылку. Поскольку для clear необходимо обрезать String, необходимо получить изменяемую ссылку. println! после вызова clear использует ссылку в word, поэтому неизменяемая ссылка в этот момент всё ещё должна быть активной. Rust запрещает одновременное существование изменяемой ссылки в формате clear и неизменяемой ссылки в word, и компиляция завершается ошибкой. Rust не только упростил использование нашего API, но и устранил целый класс ошибок во время компиляции!

Строковые литералы это срезы

Напомним, что мы говорили о строковых литералах, хранящихся внутри бинарного файла. Теперь, когда мы знаем чем являются срезы, мы правильно понимаем что такое строковые литералы:


#![allow(unused)]
fn main() {
let s = "Hello, world!";
}

Тип s здесь является &str срезом, указывающим на конкретное место в бинарном файле программы. Это также объясняет, почему строковый литерал является неизменяемым, потому что тип &str это неизменяемая ссылка.

Строковые срезы как параметры

Знание о том, что можно брать срезы строковых литералов и String строк приводит к ещё одному улучшению метода first_word, улучшению его сигнатуры:

fn first_word(s: &String) -> &str {

Более опытные разработчики Rust написали бы сигнатуру из листинга 4-9, потому что она позволяет использовать одну функцию для значений обоих типов &String и &str.

fn first_word(s: &str) -> &str {
    let bytes = s.as_bytes();

    for (i, &item) in bytes.iter().enumerate() {
        if item == b' ' {
            return &s[0..i];
        }
    }

    &s[..]
}

fn main() {
    let my_string = String::from("hello world");

    // `first_word` works on slices of `String`s, whether partial or whole
    let word = first_word(&my_string[0..6]);
    let word = first_word(&my_string[..]);
    // `first_word` also works on references to `String`s, which are equivalent
    // to whole slices of `String`s
    let word = first_word(&my_string);

    let my_string_literal = "hello world";

    // `first_word` works on slices of string literals, whether partial or whole
    let word = first_word(&my_string_literal[0..6]);
    let word = first_word(&my_string_literal[..]);

    // Because string literals *are* string slices already,
    // this works too, without the slice syntax!
    let word = first_word(my_string_literal);
}

Листинг 4-9: Улучшение функции first_word с помощью среза строки для типа параметра s

Если у нас есть фрагмент строки, мы можем передать его напрямую. Если у нас есть String, мы можем передать часть String или ссылку на String. Эта гибкость использует преимущества разыменованного приведения, функции, которую мы рассмотрим в разделе «Неявные разыменованные приведения с функциями и методами». Главы 15. Определение функции, принимающей фрагмент строки вместо ссылки на String, делает наш API более общим и полезным без потери какой-либо функциональности:

Файл: src/main.rs

fn first_word(s: &str) -> &str {
    let bytes = s.as_bytes();

    for (i, &item) in bytes.iter().enumerate() {
        if item == b' ' {
            return &s[0..i];
        }
    }

    &s[..]
}

fn main() {
    let my_string = String::from("hello world");

    // `first_word` works on slices of `String`s, whether partial or whole
    let word = first_word(&my_string[0..6]);
    let word = first_word(&my_string[..]);
    // `first_word` also works on references to `String`s, which are equivalent
    // to whole slices of `String`s
    let word = first_word(&my_string);

    let my_string_literal = "hello world";

    // `first_word` works on slices of string literals, whether partial or whole
    let word = first_word(&my_string_literal[0..6]);
    let word = first_word(&my_string_literal[..]);

    // Because string literals *are* string slices already,
    // this works too, without the slice syntax!
    let word = first_word(my_string_literal);
}

Другие срезы

Как вы могли бы представить, строковые срезы относятся к строкам. Но также есть более общий тип среза. Рассмотрим массив:


#![allow(unused)]
fn main() {
let a = [1, 2, 3, 4, 5];
}

Подобно тому как мы хотели бы ссылаться на часть строки, мы можем захотеть ссылаться на часть массива. Мы можем делать это вот так:


#![allow(unused)]
fn main() {
let a = [1, 2, 3, 4, 5];

let slice = &a[1..3];

assert_eq!(slice, &[2, 3]);
}

Данный срез имеет тип &[i32]. Он работает таким же образом, как и строковый срез, сохраняя ссылку на первый элемент и длину. Вы будете использовать данную разновидность среза для всех видов коллекций. Мы обсудим коллекции детально, когда будем говорить про векторы в Главе 8.

Итоги

Концепции владения, заимствования и срезов обеспечивают защиту использования памяти в Rust. Rust даёт вам возможность контролировать использование памяти тем же способом, как другие языки системного программирования, но дополнительно предоставляет возможность автоматической очистки данных, когда их владелец покидает область видимости функции. Это означает, что не нужно писать и отлаживать дополнительный код, чтобы добиться такого контроля.

Владение влияет на множество других частей и концепций языка Rust. Мы будем говорить об этих концепциях на протяжении оставшихся частей книги. Давайте перейдём к Главе 5 и рассмотрим группировку частей данных в структуры struct.

Использование структур для объединения логически связанных данных

Структура, struct - от английского structure - это пользовательский тип данных, который позволяет назвать и упаковать вместе несколько связанных значений, которые составляют логическую группу. Если вы знакомы с объектно-ориентированными языками, то struct будет напоминать вам на атрибуты данных объекта. В этой главе мы сравним и сопоставим кортежи со структурами, продемонстрируем, как использовать структуры. Так же мы обсудим, как создавать ассоциированные с структурой функции и методы структуры определяющие поведение данных, связанных со структурой. Структуры и перечисления (обсуждаемые в Главе 6) - это строительные блоки для создания новых типов данных, диктуемых бизнес-областью вашей программы, которые позволяют в полной мере воспользоваться преимуществом проверки типов во время компиляции в Rust.

Определение и инициализация структур

Структуры похожи на кортежи, рассмотренные в разделе "Тип Кортеж", так как оба хранят несколько связанных значений. Как и кортежи, части структур могут быть разных типов. В отличие от кортежей, в структуре необходимо именовать каждую часть данных для понимания смысла значений. Добавление этих имён обеспечивает большую гибкость структур по сравнению с кортежами: не нужно полагаться на порядок данных для указания значений экземпляра или доступа к ним.

Для определения структуры указывается ключевое слово struct и её название. Название должно описывать значение частей данных, сгруппированных вместе. Далее, в фигурных скобках для каждой новой части данных поочерёдно определяются имя части данных и её тип. Каждая пара имя: тип называется полем. Листинг 5-1 описывает структуру для хранения информации об учётной записи пользователя:

struct User {
    active: bool,
    username: String,
    email: String,
    sign_in_count: u64,
}

fn main() {}

Листинг 5-1: определение структуры User

После определения структуры можно создавать её экземпляр, назначая определённое значение каждому полю с соответствующим типом данных. Чтобы создать экземпляр, мы указываем имя структуры, затем добавляем фигурные скобки и включаем в них пары ключ: значение (key: value), где ключами являются имена полей, а значениями являются данные, которые мы хотим сохранить в полях. Нет необходимости чётко следовать порядку объявления полей в описании структуры (но всё-таки желательно для удобства чтения). Другими словами, объявление структуры - это как шаблон нашего типа, в то время как экземпляр структуры использует этот шаблон, заполняя его определёнными данными, для создания значений нашего типа. Например, можно объявить пользователя как в листинге 5-2:

struct User {
    active: bool,
    username: String,
    email: String,
    sign_in_count: u64,
}

fn main() {
    let user1 = User {
        email: String::from("someone@example.com"),
        username: String::from("someusername123"),
        active: true,
        sign_in_count: 1,
    };
}

Листинг 5-2: Создание экземпляра структуры User

Чтобы получить конкретное значение из структуры, мы используем запись через точку. Например, чтобы получить доступ к адресу электронной почты этого пользователя, мы используем user1.email. Если экземпляр является изменяемым, мы можем поменять значение, используя точечную нотацию и присвоение к конкретному полю. В Листинге 5-3 показано, как изменить значение в поле email изменяемого экземпляра User.

struct User {
    active: bool,
    username: String,
    email: String,
    sign_in_count: u64,
}

fn main() {
    let mut user1 = User {
        email: String::from("someone@example.com"),
        username: String::from("someusername123"),
        active: true,
        sign_in_count: 1,
    };

    user1.email = String::from("anotheremail@example.com");
}

Листинг 5-3: Изменение значения в поле email экземпляра User

Заметим, что весь экземпляр структуры должен быть изменяемым; Rust не позволяет помечать изменяемыми отдельные поля. Как и для любого другого выражения, мы можем использовать выражение создания структуры в качестве последнего выражения тела функции для неявного возврата нового экземпляра.

На листинге 5-4 функция build_user возвращает экземпляр User с указанным адресом и именем. Поле active получает значение true, а поле sign_in_count получает значение 1.

struct User {
    active: bool,
    username: String,
    email: String,
    sign_in_count: u64,
}

fn build_user(email: String, username: String) -> User {
    User {
        email: email,
        username: username,
        active: true,
        sign_in_count: 1,
    }
}

fn main() {
    let user1 = build_user(
        String::from("someone@example.com"),
        String::from("someusername123"),
    );
}

Листинг 5-4: Функция build_user, которая принимает email и имя пользователя и возвращает экземпляр User

Имеет смысл называть параметры функции теми же именами, что и поля структуры, но необходимость повторять email и username для названий полей и переменных несколько утомительна. Если структура имеет много полей, повторение каждого имени станет ещё более раздражающим. К счастью, есть удобное сокращение!

Использование сокращённой инициализации поля

Так как имена входных параметров функции и полей структуры являются полностью идентичными в листинге 5-4, возможно использовать синтаксис сокращённой инициализации поля, чтобы переписать build_user так, чтобы он работал точно также, но не содержал повторений для email и username, как в листинге 5-5.

struct User {
    active: bool,
    username: String,
    email: String,
    sign_in_count: u64,
}

fn build_user(email: String, username: String) -> User {
    User {
        email,
        username,
        active: true,
        sign_in_count: 1,
    }
}

fn main() {
    let user1 = build_user(
        String::from("someone@example.com"),
        String::from("someusername123"),
    );
}

Листинг 5-5: Функция build_user, использующая сокращённую инициализацию поля, когда параметры email и username имеют те же имена, что и поля struct

Здесь происходит создание нового экземпляра структуры User, которая имеет поле с именем email. Мы хотим установить поле структуры email значением входного параметра email функции build_user. Так как поле email и входной параметр функции email имеют одинаковое название, можно писать просто email вместо кода email: email.

Создание экземпляра структуры из экземпляра другой структуры с помощью синтаксиса обновления структуры

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

Сначала в листинге 5-6 показано, как обычно создаётся новый экземпляр User в user2 без синтаксиса обновления. Мы задаём новое значение для email, но в остальном используем те же значения из user1, которые были заданы в листинге 5-2.

struct User {
    active: bool,
    username: String,
    email: String,
    sign_in_count: u64,
}

fn main() {
    // --snip--

    let user1 = User {
        email: String::from("someone@example.com"),
        username: String::from("someusername123"),
        active: true,
        sign_in_count: 1,
    };

    let user2 = User {
        active: user1.active,
        username: user1.username,
        email: String::from("another@example.com"),
        sign_in_count: user1.sign_in_count,
    };
}

Листинг 5-6: Создание нового экземпляра User с использованием одного из значений из экземпляра user1

Используя синтаксис обновления структуры, можно получить тот же эффект, используя меньше кода как показано в листинге 5-7. Синтаксис .. указывает, что оставшиеся поля устанавливаются неявно и должны иметь значения из указанного экземпляра.

struct User {
    active: bool,
    username: String,
    email: String,
    sign_in_count: u64,
}

fn main() {
    // --snip--

    let user1 = User {
        email: String::from("someone@example.com"),
        username: String::from("someusername123"),
        active: true,
        sign_in_count: 1,
    };

    let user2 = User {
        email: String::from("another@example.com"),
        ..user1
    };
}

Листинг 5-7: Использование синтаксиса обновления структуры для установки нового значения email для экземпляра User, но использование остальных значений из экземпляра user1

Код в листинге 5-7 также создаёт экземпляр в user2, который имеет другое значение для email, но с тем же значением для полей username, active и sign_in_count из user1. Оператор ..user1 должен стоять последним для указания на получение значений всех оставшихся полей из соответствующих полей в user1, но можно указать значения для любого количества полей в любом порядке, независимо от порядка полей в определении структуры.

Заметим, что синтаксис обновления структуры использует = как присваивание. Это связано с перемещением данных, как мы видели в разделе "Способы взаимодействия переменных и данных: перемещение". В этом примере мы больше не можем использовать user1 после создания user2, потому что String в поле username из user1 было перемещено в user2. Если бы мы задали user2 новые значения String для email и username, и при этом использовать только значения active и sign_in_count из user1, то user1 все ещё будет действительным после создания user2. Типы active и sign_in_count являются типами, реализующими типаж Copy, поэтому будет применяться поведение, о котором мы говорили в разделе "Stack-Only Data: Copy".

Кортежные структуры: структуры без именованных полей для создания разных типов

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

Чтобы определить кортежную структуру, начните с ключевого слова struct и имени структуры, за которым следуют типы в кортеже. Например, здесь мы определяем и используем две кортежные структуры с именами Color и Point:

struct Color(i32, i32, i32);
struct Point(i32, i32, i32);

fn main() {
    let black = Color(0, 0, 0);
    let origin = Point(0, 0, 0);
}

Обратите внимание, что значения black и origin — это разные типы, потому что они являются экземплярами разных кортежных структур. Каждая определяемая вами структура имеет собственный тип, даже если поля внутри структуры могут иметь одинаковые типы. Например, функция, принимающая параметр типа Color, не может принимать Point в качестве аргумента, даже если оба типа состоят из трёх значений i32. В остальном экземпляры кортежных структур похожи на кортежи в том смысле, что вы можете деструктурировать их на отдельные части и использовать ., за которой следует индекс для доступа к отдельному значению.

Единично-подобные структуры: структуры без полей

Также можно определять структуры, не имеющие полей! Они называются единично-подобными структурами, поскольку ведут себя аналогично (), единичному типу, о котором мы говорили в разделе "Тип кортежа". Единично-подобные структуры могут быть полезны, когда требуется реализовать типаж для некоторого типа, но у вас нет данных, которые нужно хранить в самом типе. Мы обсудим типажи в главе 10. Вот пример объявления и создание экземпляра единичной структуры с именем AlwaysEqual:

struct AlwaysEqual;

fn main() {
    let subject = AlwaysEqual;
}

Чтобы определить AlwaysEqual, мы используем ключевое слово struct, желаемое имя, а затем точку с запятой. Нет необходимости в фигурных или круглых скобках! Затем мы можем получить экземпляр AlwaysEqual в переменной subject аналогичным образом: используя имя, которое мы определили, без фигурных и круглых скобок. Представим, что в дальнейшем мы реализуем поведение для этого типа таким образом, что каждый экземпляр AlwaysEqual всегда будет равен каждому экземпляру любого другого типа, возможно, с целью получения ожидаемого результата для тестирования. Для реализации такого поведения нам не нужны никакие данные! В главе 10 вы увидите, как определять черты и реализовывать их для любого типа, включая единично-подобные структуры.

Владение данными структуры

В определении структуры User в листинге 5-1 мы использовали владеющий тип String вместо типа строковой срез &str. Это осознанный выбор, поскольку мы хотим, чтобы каждый экземпляр этой структуры владел всеми своими данными и чтобы эти данные были действительны до тех пор, пока действительна вся структура.

Структуры также могут хранить ссылки на данные, принадлежащие кому-то другому, но для этого необходимо использовать возможность Rust время жизни, которую мы обсудим в главе 10. Время жизни гарантирует, что данные, на которые ссылается структура, будут действительны до тех пор, пока существует структура. Допустим, если попытаться сохранить ссылку в структуре без указания времени жизни, как в следующем примере; это не сработает:

Имя файла: src/main.rs

struct User {
    active: bool,
    username: &str,
    email: &str,
    sign_in_count: u64,
}

fn main() {
    let user1 = User {
        email: "someone@example.com",
        username: "someusername123",
        active: true,
        sign_in_count: 1,
    };
}

Компилятор будет жаловаться на необходимость определения времени жизни ссылок:

$ cargo run
   Compiling structs v0.1.0 (file:///projects/structs)
error[E0106]: missing lifetime specifier
 --> src/main.rs:3:15
  |
3 |     username: &str,
  |               ^ expected named lifetime parameter
  |
help: consider introducing a named lifetime parameter
  |
1 ~ struct User<'a> {
2 |     active: bool,
3 ~     username: &'a str,
  |

error[E0106]: missing lifetime specifier
 --> src/main.rs:4:12
  |
4 |     email: &str,
  |            ^ expected named lifetime parameter
  |
help: consider introducing a named lifetime parameter
  |
1 ~ struct User<'a> {
2 |     active: bool,
3 |     username: &str,
4 ~     email: &'a str,
  |

For more information about this error, try `rustc --explain E0106`.
error: could not compile `structs` due to 2 previous errors

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

Пример использования структур

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

Давайте создадим новый проект программы при помощи Cargo и назовём его rectangles. Наша программа будет получать на вход длину и ширину прямоугольника в пикселях и затем рассчитывать площадь прямоугольника. Листинг 5-8 показывает один из коротких вариантов кода который позволит нам сделать именно то, что надо, код в файле проекта src/main.rs.

Файл: src/main.rs

fn main() {
    let width1 = 30;
    let height1 = 50;

    println!(
        "The area of the rectangle is {} square pixels.",
        area(width1, height1)
    );
}

fn area(width: u32, height: u32) -> u32 {
    width * height
}

Листинг 5-8: Вычисление площади прямоугольника, заданного отдельными переменными ширины и высоты

Теперь, проверим её работу cargo run:

$ cargo run
   Compiling rectangles v0.1.0 (file:///projects/rectangles)
    Finished dev [unoptimized + debuginfo] target(s) in 0.42s
     Running `target/debug/rectangles`
The area of the rectangle is 1500 square pixels.

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

Проблема данного метода очевидна из сигнатуры area:

fn main() {
    let width1 = 30;
    let height1 = 50;

    println!(
        "The area of the rectangle is {} square pixels.",
        area(width1, height1)
    );
}

fn area(width: u32, height: u32) -> u32 {
    width * height
}

Функция area должна вычислять площадь одного прямоугольника, но функция, которую мы написали, имеет два параметра, и нигде в нашей программе не ясно, что эти параметры взаимосвязаны. Было бы более читабельным и управляемым сгруппировать ширину и высоту вместе. Мы уже обсуждали один из способов сделать это в разделе "Тип кортеж" главы 3: использование кортежей.

Рефакторинг при помощи кортежей

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

Файл: src/main.rs

fn main() {
    let rect1 = (30, 50);

    println!(
        "The area of the rectangle is {} square pixels.",
        area(rect1)
    );
}

fn area(dimensions: (u32, u32)) -> u32 {
    dimensions.0 * dimensions.1
}

Листинг 5-9: Определение ширины и высоты прямоугольника с помощью кортежа

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

Если мы перепутаем местами ширину с высотой при расчёте площади, то это не имеет значения. Но если мы хотим нарисовать нарисовать прямоугольник на экране, то это уже будет иметь значение! Мы должны помнить, что ширина width находится в кортеже с индексом 0, а высота height с индексом 1. Если кто-то другой поработал бы с кодом, ему бы пришлось разобраться в этом и также помнить про порядок. Легко забыть и перепутать эти значения и это вызовет ошибки, потому что данный код не передаёт наши намерения.

Рефакторинг при помощи структур: добавим больше смысла

Мы используем структуры чтобы добавить смысл данным при помощи назначения им осмысленных имён . Мы можем переделать используемый кортеж в структуру с единым именем для сущности и частными названиями её частей, как показано в листинге 5-10.

Файл: src/main.rs

struct Rectangle {
    width: u32,
    height: u32,
}

fn main() {
    let rect1 = Rectangle {
        width: 30,
        height: 50,
    };

    println!(
        "The area of the rectangle is {} square pixels.",
        area(&rect1)
    );
}

fn area(rectangle: &Rectangle) -> u32 {
    rectangle.width * rectangle.height
}

Листинг 5-10: Определение структуры Rectangle

Здесь мы определили структуру и дали ей имя Rectangle. Внутри фигурных скобок определили поля как width и height, оба - типа u32. Затем в main создали конкретный экземпляр Rectangle с шириной в 30 и высотой в 50 единиц.

Наша функция area теперь определена с одним параметром названным rectangle, чей тип является неизменяемым заимствованием структуры Rectangle. Как упоминалось в Главе 4, необходимо заимствовать структуру, а не передавать её во владение. Таким образом функция main сохраняет rect1 в собственности и может её использовать дальше, по этой причине мы и используем & в сигнатуре и в месте вызова функции.

Функция area получает доступ к полям width и height экземпляра Rectangle (обратите внимание, что доступ к полям заимствованного экземпляра структуры не приводит к перемещению значений полей, поэтому вы часто видите заимствования структур). Наша сигнатура функции для area теперь говорит именно то, что мы имеем в виду: вычислить площадь Rectangle, используя его поля width и height. Это означает, что ширина и высота связаны друг с другом, и даёт описательные имена значениям, а не использует значения индекса кортежа 0 и 1. Это торжество ясности.

Добавление полезной функциональности при помощи Выводимых Типажей

Было бы полезно иметь возможность печатать экземпляр Rectangle во время отладки программы и видеть значения всех полей. Листинг 5-11 использует макрос println! macro, который мы уже использовали в предыдущих главах. Тем не менее, это не работает.

Файл: src/main.rs

struct Rectangle {
    width: u32,
    height: u32,
}

fn main() {
    let rect1 = Rectangle {
        width: 30,
        height: 50,
    };

    println!("rect1 is {}", rect1);
}

Листинг 5-11: Попытка распечатать экземпляр Rectangle

При компиляции этого кода мы получаем ошибку с сообщением:

error[E0277]: `Rectangle` doesn't implement `std::fmt::Display`

Макрос println! умеет выполнять множество видов форматирования, по умолчанию фигурные скобки в println! говорят использовать форматирование известное как типаж Display: вывод в таком варианте форматирования предназначен для непосредственного использования конечным пользователем. Примитивные типы изученные ранее, по умолчанию реализуют типаж Display, потому что есть только один способ отобразить число 1 или любой другой примитивный тип пользователю. Но для структур у которых println! должен форматировать способ вывода данных, это является менее очевидным, потому что есть гораздо больше возможностей для отображения: Вы хотите запятые или нет? Вы хотите печатать фигурные скобки? Должны ли отображаться все поля? Из-за этой неоднозначности Rust не пытается угадать, что нам нужно, а структуры не имеют встроенной реализации Display для использования в println! с заполнителем {}.

Продолжив чтение текста ошибки, мы найдём полезное замечание:

   = help: the trait `std::fmt::Display` is not implemented for `Rectangle`
   = note: in format strings you may be able to use `{:?}` (or {:#?} for pretty-print) instead

Давайте попробуем! Вызов макроса println! теперь будет выглядеть так println!("rect1 is {:?}", rect1);. Ввод спецификатора :? внутри фигурных скобок говорит макросу println!, что мы хотим использовать другой формат вывода известный как Debug. Типаж Debug позволяет печатать структуру способом, удобным для разработчиков, чтобы видеть значение во время отладки кода.

Скомпилируем код с этими изменениями. Упс! Мы всё ещё получаем ошибку:

error[E0277]: `Rectangle` doesn't implement `Debug`

Снова компилятор даёт нам полезное замечание:

   = help: the trait `Debug` is not implemented for `Rectangle`
   = note: add `#[derive(Debug)]` to `Rectangle` or manually `impl Debug for Rectangle`

Rust  реализует функциональность для печати отладочной информации, но не включает (не выводит) её по умолчанию , мы должны явно включить эту функциональность для нашей структуры. Чтобы это сделать, добавляем внешний атрибут #[derive(Debug)] сразу перед определением структуры как показано в листинге 5-12.

Файл: src/main.rs

#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}

fn main() {
    let rect1 = Rectangle {
        width: 30,
        height: 50,
    };

    println!("rect1 is {:?}", rect1);
}

Листинг 5-12: Добавление атрибута для вывода типажа Debug и печати экземпляра Rectangle с отладочным форматированием

Теперь при запуске программы мы не получим ошибок и увидим следующий вывод:

$ cargo run
   Compiling rectangles v0.1.0 (file:///projects/rectangles)
    Finished dev [unoptimized + debuginfo] target(s) in 0.48s
     Running `target/debug/rectangles`
rect1 is Rectangle { width: 30, height: 50 }

Отлично! Это не самый красивый вывод, но он показывает значения всех полей экземпляра, которые определённо помогут при отладке. Когда у нас более крупные структуры, то полезно иметь более простой для чтения вывод; в таких случаях можно использовать код {:#?} вместо {:?} в строке макроса println!. В этом примере использование стиля {:#?} приведёт к такому выводу:

$ cargo run
   Compiling rectangles v0.1.0 (file:///projects/rectangles)
    Finished dev [unoptimized + debuginfo] target(s) in 0.48s
     Running `target/debug/rectangles`
rect1 is Rectangle {
    width: 30,
    height: 50,
}

Другой способ распечатать значение в формате Debug — использовать dbg! макрос, который становится владельцем выражения (в отличие от println!, принимающим ссылку), печатает номер файла и строки, где происходит вызов макроса dbg!, вместе с результирующим значением этого выражения и возвращает право собственности на значение.

Примечание: вызов dbg! макрос печатает в стандартный поток консоли для ошибок ( stderr ), а не в println! который печатает в стандартный поток консоли вывода ( stdout ). Подробнее о stderr и stdout мы поговорим в разделе “Запись ошибок в поток вывод для ошибок вместо стандартного потока вывода” Главы 12.

Вот пример, когда нас интересует значение, которое присваивается полю width, а также значение всей структуры в rect1 :

#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}

fn main() {
    let scale = 2;
    let rect1 = Rectangle {
        width: dbg!(30 * scale),
        height: 50,
    };

    dbg!(&rect1);
}

Можем написать макрос dbg! вокруг выражения 30 * scale, потому что dbg! возвращает владение значения выражения, поле width получит то же значение, как если бы у нас не было вызова dbg!. Мы не хотим чтобы макрос dbg! становился владельцем rect1 , поэтому мы используем ссылку на rect1 в следующем вызове. Вот как выглядит вывод этого примера:

$ cargo run
   Compiling rectangles v0.1.0 (file:///projects/rectangles)
    Finished dev [unoptimized + debuginfo] target(s) in 0.61s
     Running `target/debug/rectangles`
[src/main.rs:10] 30 * scale = 60
[src/main.rs:14] &rect1 = Rectangle {
    width: 60,
    height: 50,
}

Мы можем увидеть, что первый бит вывода поступил из строки 10 src/main.rs, там где мы отлаживаем выражение 30 * scale и его результирующее значение равно 60 ( Debug форматирование, реализованное для целых чисел, заключается в печати только их значения). Вызов dbg! в строке 14 src/main.rs выводит значение &rect1 , которое является структурой Rectangle . В этом выводе используется красивое форматирование Debug типа Rectangle. Макрос dbg! может быть очень полезен, когда вы пытаетесь понять, что делает ваш код!

В дополнение к Debug , Rust предоставил нам ряд типажей, которые мы можем использовать с атрибутом derive для добавления полезного поведения к нашим пользовательским типам. Эти типажи и их поведение перечислены в Приложении C.. Мы расскажем, как реализовать эти трейты с пользовательским поведением, а также как создать свои собственные трейты в главе 10. Кроме того, есть много других атрибутов помимо derive ; для получения дополнительной информации смотри раздел “Атрибуты” справочника Rust.

Функция area является довольно специфичной: она считает только площадь прямоугольников. Было бы полезно привязать данное поведение как можно ближе к структуре Rectangle, потому что наш специфичный код не будет работать с любым другим типом. Давайте рассмотрим, как можно улучшить наш код, превращая функцию area в метод area, определённый для типа Rectangle.

Синтаксис метода

Методы похожи на функции: мы объявляем их с помощью ключевого слова fn и имени, они могут иметь параметры и возвращаемое значение, и они содержат код, запускающийся в случае вызова метода. В отличие от функций, методы определяются в контексте структуры (или перечисления, или типаж-объекта, которые мы рассмотрим в Главах 6 и 17 соответственно), и их первым параметром всегда является self, представляющий собой экземпляр структуры, на которой вызывается этот метод.

Определение методов

Давайте изменим функцию area так, чтобы она имела экземпляр Rectangle в качестве входного параметра и сделаем её методом area, определённым для структуры Rectangle, как показано в листинге 5-13:

Файл: src/main.rs

#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}

impl Rectangle {
    fn area(&self) -> u32 {
        self.width * self.height
    }
}

fn main() {
    let rect1 = Rectangle {
        width: 30,
        height: 50,
    };

    println!(
        "The area of the rectangle is {} square pixels.",
        rect1.area()
    );
}

Листинг 5-13: Определение метода area в структуре Rectangle

Чтобы определить функцию в контексте Rectangle, мы запускаем блок impl (implementation - реализация) для Rectangle. Все в impl будет связано с типом Rectangle. Затем мы перемещаем функцию area внутрь фигурных скобок impl и меняем первый (и в данном случае единственный) параметр на self в сигнатуре и в теле. В main, где мы вызвали функцию area и передали rect1 в качестве аргумента, теперь мы можем использовать синтаксис метода для вызова метода area нашего экземпляра Rectangle. Синтаксис метода идёт после экземпляра: мы добавляем точку, за которой следует имя метода, круглые скобки и любые аргументы.

В сигнатуре area мы используем &self вместо rectangle: &Rectangle. &self на самом деле является сокращением от self: &Self. Внутри блока impl тип Self является псевдонимом типа, для которого реализован блок impl. Методы обязаны иметь параметр с именем self типа Self, поэтому Rust позволяет вам сокращать его, используя только имя self на месте первого параметра. Обратите внимание, что нам по-прежнему нужно использовать & перед сокращением self, чтобы указать на то, что этот метод заимствует экземпляр Self, точно так же, как мы делали это в rectangle: &Rectangle. Как и любой другой параметр, методы могут брать во владение self, заимствовать неизменяемый self, как мы поступили в данном случае, или заимствовать изменяемый self.

Мы выбрали &self здесь по той же причине, по которой использовали &Rectangle в версии кода с функцией: мы не хотим брать структуру во владение, мы просто хотим прочитать данные в структуре, а не писать в неё. Если бы мы хотели изменить экземпляр, на котором мы вызывали метод силами самого метода, то мы бы использовали &mut self в качестве первого параметра. Наличие метода, который берёт экземпляр во владение, используя только self в качестве первого параметра, является редким; эта техника обычно используется, когда метод превращает self во что-то ещё, и вы хотите запретить вызывающей стороне использовать исходный экземпляр после превращения.

Основная причина использования методов вместо функций, помимо синтаксиса метода, где нет необходимости повторять тип self в сигнатуре каждого метода, заключается в организации кода. Мы поместили все, что мы можем сделать с экземпляром типа, в один impl вместо того, чтобы заставлять будущих пользователей нашего кода искать доступный функционал Rectangle в разных местах предоставляемой нами библиотеки.

Обратите внимание, что мы можем дать методу то же имя, что и одному из полей структуры. Например, для Rectangle мы можем определить метод, также названный width:

Файл: src/main.rs

#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}

impl Rectangle {
    fn width(&self) -> bool {
        self.width > 0
    }
}

fn main() {
    let rect1 = Rectangle {
        width: 30,
        height: 50,
    };

    if rect1.width() {
        println!("The rectangle has a nonzero width; it is {}", rect1.width);
    }
}

Здесь мы определили, чтобы метод width возвращал значение true, если значение в поле width экземпляра больше 0, и значение false, если значение равно 0, но мы можем использовать поле в методе с тем же именем для любых целей. В main, когда мы ставим после rect1.width круглые скобки, Rust знает, что мы имеем в виду метод width. Когда мы не используем круглые скобки, Rust понимает, что мы имеем в виду поле width.

Часто, но не всегда, когда мы создаём методы с тем же именем, что и у поля, мы хотим, чтобы он только возвращал значение одноимённого поля и больше ничего не делал. Подобные методы называются геттерами, и Rust не реализует их автоматически для полей структуры, как это делают некоторые другие языки. Геттеры полезны, потому что вы можете сделать поле приватным, а метод публичным и, таким образом, включить доступ к этому полю только на чтение как часть общедоступного API типа. Мы обсудим, что такое публичность и приватность и как обозначить поле или метод в качестве публичного или приватного, в Главе 7.

Где используется оператор ->?

В языках C и C++, используются два различных оператора для вызова методов: используется ., если вызывается метод непосредственно у экземпляра структуры и используется ->, если вызывается метод у ссылки на объект. Другими словами, если object является ссылкой, то вызовы метода object->something() и (*object).something() являются аналогичными.

Rust не имеет эквивалента оператора ->, наоборот, в Rust есть функциональность называемая автоматическое обращение по ссылке и разыменование (automatic referencing and dereferencing). Вызов методов является одним из немногих мест в Rust, в котором есть такое поведение.

Вот как это работает: когда вы вызываете метод object.something(), Rust автоматически добавляет &, &mut или *, таким образом, чтобы object соответствовал сигнатуре метода. Другими словами, это то же самое:


#![allow(unused)]
fn main() {
#[derive(Debug,Copy,Clone)]
struct Point {
    x: f64,
    y: f64,
}

impl Point {
   fn distance(&self, other: &Point) -> f64 {
       let x_squared = f64::powi(other.x - self.x, 2);
       let y_squared = f64::powi(other.y - self.y, 2);

       f64::sqrt(x_squared + y_squared)
   }
}
let p1 = Point { x: 0.0, y: 0.0 };
let p2 = Point { x: 5.0, y: 6.5 };
p1.distance(&p2);
(&p1).distance(&p2);
}

Первый пример выглядит намного понятнее. Автоматический вывод ссылки работает потому, что методы имеют понятного получателя - тип self. Учитывая получателя и имя метода, Rust может точно определить, что в данном случае делает код: читает ли метод (&self), делает ли изменение (&mut self) или поглощает (self). Тот факт, что Rust делает заимствование неявным для принимающего метода, в значительной степени способствует тому, чтобы сделать владение эргономичным на практике.

Методы с несколькими параметрами

Давайте попрактикуемся в использовании методов, реализовав второй метод в структуре Rectangle. На этот раз мы хотим, чтобы экземпляр Rectangle брал другой экземпляр Rectangle и возвращал true, если второй Rectangle может полностью поместиться внутри self (первый Rectangle); в противном случае он должен вернуть false. То есть, как только мы определим метод can_hold, мы хотим иметь возможность написать программу, показанную в Листинге 5-14.

Файл: src/main.rs

fn main() {
    let rect1 = Rectangle {
        width: 30,
        height: 50,
    };
    let rect2 = Rectangle {
        width: 10,
        height: 40,
    };
    let rect3 = Rectangle {
        width: 60,
        height: 45,
    };

    println!("Can rect1 hold rect2? {}", rect1.can_hold(&rect2));
    println!("Can rect1 hold rect3? {}", rect1.can_hold(&rect3));
}

Листинг 5-14: Использование ещё не написанного метода can_hold

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

Can rect1 hold rect2? true
Can rect1 hold rect3? false

Мы знаем, что хотим определить метод, поэтому он будет находится в impl Rectangle блоке. Имя метода будет can_hold, и оно будет принимать неизменяемое заимствование на другой Rectangle в качестве параметра. Мы можем сказать, какой это будет тип параметра, посмотрев на код вызывающего метода: метод rect1.can_hold(&rect2) передаёт в него &rect2 , который является неизменяемым заимствованием экземпляра rect2 типа Rectangle. В этом есть смысл, потому что нам нужно только читать rect2 (а не писать, что означало бы, что нужно изменяемое заимствование), и мы хотим, чтобы main сохранил право собственности на экземпляр rect2, чтобы мы могли использовать его снова после вызова метода can_hold. Возвращаемое значение can_hold имеет булевый тип, а реализация проверяет, являются ли ширина и высота self больше, чем ширина и высота другого Rectangle соответственно. Давайте добавим новый метод can_hold в impl блок из листинга 5-13, как показано в листинге 5-15.

Файл: src/main.rs

#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}

impl Rectangle {
    fn area(&self) -> u32 {
        self.width * self.height
    }

    fn can_hold(&self, other: &Rectangle) -> bool {
        self.width > other.width && self.height > other.height
    }
}

fn main() {
    let rect1 = Rectangle {
        width: 30,
        height: 50,
    };
    let rect2 = Rectangle {
        width: 10,
        height: 40,
    };
    let rect3 = Rectangle {
        width: 60,
        height: 45,
    };

    println!("Can rect1 hold rect2? {}", rect1.can_hold(&rect2));
    println!("Can rect1 hold rect3? {}", rect1.can_hold(&rect3));
}

Листинг 5-15: Реализация метода can_hold для Rectangle, принимающего другой экземпляр Rectangle в качестве параметра

Когда мы запустим код с функцией main листинга 5-14, мы получим желаемый вывод. Методы могут принимать несколько параметров, которые мы добавляем в сигнатуру после первого параметра self, и эти параметры работают так же, как параметры в функциях.

Ассоциированные функции

Все функции, определённые в блоке impl, называются ассоциированными функциями, потому что они ассоциированы с типом, указанным после ключевого слова impl. Мы можем определить ассоциированные функции, которые не имеют self в качестве первого параметра (и, следовательно, не являются методами), потому что им не нужен экземпляр типа для работы. Мы уже использовали одну подобную функцию: функцию String::from, определённую для типа String.

Ассоциированные функции, не являющиеся методами, часто используются для конструкторов, возвращающих новый экземпляр структуры. Их часто называют new, но new не является специальным именем и не встроена в язык. Например, мы можем предоставить ассоциированную функцию с именем square, которая будет иметь один параметр размера и использовать его как ширину и высоту, что упростит создание квадратного Rectangle, вместо того, чтобы указывать одно и то же значение дважды:

Файл: src/main.rs

#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}

impl Rectangle {
    fn square(size: u32) -> Self {
        Self {
            width: size,
            height: size,
        }
    }
}

fn main() {
    let sq = Rectangle::square(3);
}

Ключевые слова Self в возвращаемом типе и в теле функции являются псевдонимами для типа, указанного после ключевого слова impl, которым в данном случае является Rectangle.

Чтобы вызвать эту ассоциированную функцию, мы используем синтаксис :: с именем структуры; например, let sq = Rectangle::square(3);. Эта функция входит в пространство имён структуры: синтаксис :: используется как для ассоциированных функций, так и для пространств имён, созданных модулями. Мы обсудим модули в Главе 7.

Несколько блоков impl

Каждая структура может иметь несколько impl. Например, Листинг 5-15 эквивалентен коду, показанному в листинге 5-16, в котором каждый метод находится в своём собственном impl.

#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}

impl Rectangle {
    fn area(&self) -> u32 {
        self.width * self.height
    }
}

impl Rectangle {
    fn can_hold(&self, other: &Rectangle) -> bool {
        self.width > other.width && self.height > other.height
    }
}

fn main() {
    let rect1 = Rectangle {
        width: 30,
        height: 50,
    };
    let rect2 = Rectangle {
        width: 10,
        height: 40,
    };
    let rect3 = Rectangle {
        width: 60,
        height: 45,
    };

    println!("Can rect1 hold rect2? {}", rect1.can_hold(&rect2));
    println!("Can rect1 hold rect3? {}", rect1.can_hold(&rect3));
}

Листинг 5-16: Переписанный Листинга 5-15 с использованием нескольких impl

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

Итоги

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

Но структуры — не единственный способ создавать собственные типы: давайте обратимся к перечислениям в Rust, чтобы добавить ещё один инструмент в свой арсенал.

Перечисления и Сопоставление с образцом

В этой главе мы рассмотрим перечисления, также называемые enums. Перечисления позволяют определять тип, перечисляя его возможные варианты. Сначала, мы определим и воспользуемся перечислением, чтобы показать, как перечисление может закодировать значение вместе с данными. Далее мы рассмотрим особенно полезный enum, называемый Option, который выражает факт того, что значение может быть либо чем-то, либо ничем. Потом мы посмотрим на сопоставление с образцом в match выражении, позволяющем легко выполнять разный код для различных значений перечисления. Наконец, мы рассмотрим конструкцию if let - ещё одну удобную и лаконичную идиому, которая позволяет вам управлять перечислениями в коде.

Перечисления являются особенностью многих языков, но в каждом языке их возможности различаются. Перечисления в Rust наиболее похожи на алгебраические типы данных, Algebraic Data Types, представленные в таких функциональных языках как F#, OCaml и Haskell.

Определение перечисления

Давайте посмотрим на ситуацию, которую мы могли бы выразить в коде, и рассмотрим почему перечисления полезны и более уместны чем структуры в данном случае. Представим, что нам нужно работать с IP-адресами. В настоящее время используются два основных стандарта IP-адресов: версия четыре и версия шесть. Это единственные варианты IP адресов, с которым столкнётся наша программа: мы можем перечислить (enumerate) все возможные варианты, отсюда и появляется понятие перечисление (enumeration, enum).

Любой IP-адрес может быть либо адресом версии четыре, либо версии шесть - но не может быть одновременно и шестой и четвёртой версии. Это свойство IP-адресов делает перечисление подходящей структурой данных для их хранения, т.к. значения enum, как и версия IP-адреса, могут быть только одним из возможных в данном перечислении вариантом. Адреса как версии четыре, так и версии шесть по-прежнему являются IP-адресами, поэтому они должны рассматриваться как один и тот же тип, когда код обрабатывает ситуации применимые к любому виду IP-адреса.

Можно выразить эту концепцию в коде, определив перечисление IpAddrKind и составив список возможных видов IP-адресов, V4 и V6. Вот варианты перечислений:

enum IpAddrKind {
    V4,
    V6,
}

fn main() {
    let four = IpAddrKind::V4;
    let six = IpAddrKind::V6;

    route(IpAddrKind::V4);
    route(IpAddrKind::V6);
}

fn route(ip_kind: IpAddrKind) {}

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

Значения перечислений

Экземпляры каждого варианта перечисления IpAddrKind можно создать следующим образом:

enum IpAddrKind {
    V4,
    V6,
}

fn main() {
    let four = IpAddrKind::V4;
    let six = IpAddrKind::V6;

    route(IpAddrKind::V4);
    route(IpAddrKind::V6);
}

fn route(ip_kind: IpAddrKind) {}

Обратите внимание, что варианты перечисления находятся в пространстве имён его идентификатора, мы используем двойное двоеточие чтобы отделить вариант от пространства имён. Причина по которой это полезно в том, что сейчас оба значения IpAddrKind::V4 и IpAddrKind::V6 имеют одинаковый тип: IpAddrKind. Благодаря этому в дальнейшем мы имеем возможность определять функции, которые принимают любой вариант IpAddrKind:

enum IpAddrKind {
    V4,
    V6,
}

fn main() {
    let four = IpAddrKind::V4;
    let six = IpAddrKind::V6;

    route(IpAddrKind::V4);
    route(IpAddrKind::V6);
}

fn route(ip_kind: IpAddrKind) {}

Можно вызвать эту функцию с любым из вариантов:

enum IpAddrKind {
    V4,
    V6,
}

fn main() {
    let four = IpAddrKind::V4;
    let six = IpAddrKind::V6;

    route(IpAddrKind::V4);
    route(IpAddrKind::V6);
}

fn route(ip_kind: IpAddrKind) {}

Использование перечислений имеет даже больше преимуществ. Размышляя о нашем типе IP-адреса в данный момент, мы понимаем, что у нас нет способа сохранить фактические данные IP-адреса; мы только знаем, каким вариантом он является. Учитывая то, что вы недавно узнали о структурах в Главе 5, можно решить эту проблему как показано в листинге 6-1.

fn main() {
    enum IpAddrKind {
        V4,
        V6,
    }

    struct IpAddr {
        kind: IpAddrKind,
        address: String,
    }

    let home = IpAddr {
        kind: IpAddrKind::V4,
        address: String::from("127.0.0.1"),
    };

    let loopback = IpAddr {
        kind: IpAddrKind::V6,
        address: String::from("::1"),
    };
}

Листинг 6-1. Сохранение данных и IpAddrKind IP-адреса с использованием struct

Здесь мы определили структуру IpAddr, которая имеет два поля: поле kind имеет тип IpAddrKind (перечисление, которое мы определили ранее) и поле address типа String. У нас есть два экземпляра этой структуры. Первый, home, имеет значение kind равное IpAddrKind::V4 и связан с адресом 127.0.0.1. Второй экземпляр, loopback, имеет другой вариант IpAddrKind качестве значения kind - вариант V6 и имеет связанный с ним адрес ::1. Мы использовали структуру для объединения значений kind и address, теперь вариант связан со значением.

Мы можем представить ту же концепцию в более сжатой форме, используя только перечисление, вместо перечисления запакованного внутри структуры, и помещать данные непосредственно в каждый вариант перечисления. Это новое определение перечисления IpAddr говорит, что оба варианта V4 и V6 будут иметь связанные с ними значения типа String :

fn main() {
    enum IpAddr {
        V4(String),
        V6(String),
    }

    let home = IpAddr::V4(String::from("127.0.0.1"));

    let loopback = IpAddr::V6(String::from("::1"));
}

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

Ещё одно преимущество использования перечисления вместо структуры заключается в том, что каждый вариант перечисления может иметь разное количество ассоциированных данных представленных в разных типах. Версия 4 для типа IP адресов всегда будет содержать четыре цифровых компонента, которые будут иметь значения между 0 и 255. При необходимости сохранить адреса типа V4 как четыре значения типа u8, а также описать адреса типа V6 как единственное значение типа String, мы не смогли бы с помощью структуры. Перечисления решают эту задачу легко:

fn main() {
    enum IpAddr {
        V4(u8, u8, u8, u8),
        V6(String),
    }

    let home = IpAddr::V4(127, 0, 0, 1);

    let loopback = IpAddr::V6(String::from("::1"));
}

Мы показали несколько способов определения структур данных для хранения IP-адресов стандарта версии четыре и версии шесть. Однако, как выясняется, желание хранить IP-адреса и кодировать какого они типа, настолько распространено среди разработчиков, что в стандартной библиотеке уже есть готовое для нашей задачи определение, которое мы можем использовать! Давайте посмотрим, как стандартная библиотека определяет тип IpAddr: она так же как и у нас имеет аналогичное перечисление с аналогичными вариантами (подобными тем, которые мы определили и использовали ранее), но она представляет (а затем и встраивает в варианты) данные IP-адресов в форме двух разных структур, которые определяются по-разному для каждого варианта.


#![allow(unused)]
fn main() {
struct Ipv4Addr {
    // --snip--
}

struct Ipv6Addr {
    // --snip--
}

enum IpAddr {
    V4(Ipv4Addr),
    V6(Ipv6Addr),
}
}

Этот код иллюстрирует что мы можем добавлять любой тип данных в значение перечисления: строку, число, структуру и пр. Вы даже можете включить в перечисление другие перечисления! Стандартные типы данных не очень сложны, хотя, потенциально, могут быть очень сложными (вложенность данных может быть очень глубокой).

Обратите внимание, что хотя определение перечисления IpAddr есть в стандартной библиотеке, мы смогли объявлять и использовать свою собственную реализацию с аналогичным названием без каких-либо конфликтов, потому что мы не добавили определение стандартной библиотеки в область видимости кода. Подробнее об этом поговорим в Главе 7.

Рассмотрим другой пример перечисления в листинге 6-2: в этом примере каждый элемент перечисления имеет свой особый тип данных внутри:

enum Message {
    Quit,
    Move { x: i32, y: i32 },
    Write(String),
    ChangeColor(i32, i32, i32),
}

fn main() {}

Листинг 6-2. Перечисление Message, в каждом из вариантов которого хранятся разные количества и типы значений.

Это перечисление имеет 4 элемента:

  • Quit - пустой элемент без ассоциированных данных,
  • Move имеет именованные поля, как и структура.
  • Write - элемент с единственной строкой типа String,
  • ChangeColor - кортеж из трёх значений типа i32.

Определение перечисления с вариантами, такими как в листинге 6-2, похоже на определение значений различных типов внутри структур, за исключением того, что перечисление не использует ключевое слово struct и все варианты сгруппированы внутри типа Message. Следующие структуры могут содержать те же данные, что и предыдущие варианты перечислений:

struct QuitMessage; // unit struct
struct MoveMessage {
    x: i32,
    y: i32,
}
struct WriteMessage(String); // tuple struct
struct ChangeColorMessage(i32, i32, i32); // tuple struct

fn main() {}

Но когда мы использовали различные структуры, которые имеют свои собственные типы, мы не могли легко определять функции, которые принимают любые типы сообщений, как это можно сделать с помощью перечисления типа Message, объявленного в листинге 6-2, который является единым типом.

Есть ещё одно сходство между перечислениями и структурами: так же, как мы можем определять методы для структур с помощью impl блока, мы можем определять и методы для перечисления. Вот пример метода с именем call, который мы могли бы определить в нашем перечислении Message:

fn main() {
    enum Message {
        Quit,
        Move { x: i32, y: i32 },
        Write(String),
        ChangeColor(i32, i32, i32),
    }

    impl Message {
        fn call(&self) {
            // method body would be defined here
        }
    }

    let m = Message::Write(String::from("hello"));
    m.call();
}

Тело метода будет использовать self, чтобы получить значение из объекта на котором мы вызвали этот метод. В этом примере мы создали переменную m которой назначено значение из выражения Message::Write( String::from("hello")) и это то чем будет self в теле метода call при вызове m.call().

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

Перечисление Option и его преимущества перед Null-значениями

В предыдущем разделе мы рассмотрели, как перечисление IpAddr позволило нам использовать систему типов Rust для кодирования в программе большего количества информации, чем просто данные. В этом разделе рассматривается пример использования Option, ещё одного перечисления, определённого стандартной библиотекой. Тип Option используется во многих местах, потому что он кодирует очень распространённый сценарий, в котором значение может быть чем-то или может быть ничем. Выражение этой концепции в терминах системы типов означает, что компилятор может проверить, обработаны ли все случаи для данного типа, которые должны обрабатываться; такого рода проверка компилятора может предотвратить ошибки, которые чрезвычайно распространены в других языках программирования.

Дизайн языка программирования часто рассматривается с точки зрения того, какие функции вы включаете в него, но те функции, которые вы исключаете, также важны. Например в Rust нет такого функционала как null значения, однако он есть во многих других языках. Null значение - это значение, которое означает, что значения нет. В языках с null значением переменные всегда могут находиться в одном из двух состояний: нет значения (null) или есть значение (not-null).

В своей презентации 2009 года «Null ссылки: ошибка в миллиард долларов» Тони Хоар (Tony Hoare), изобретатель null, сказал следующее:

Я называю это своей ошибкой на миллиард долларов. В то время я разрабатывал первую комплексную систему типов для ссылок на объектно-ориентированном языке. Моя цель состояла в том, чтобы гарантировать, что любое использование ссылок должно быть абсолютно безопасным, с автоматической проверкой компилятором. Но я не мог устоять перед соблазном вставить пустую ссылку просто потому, что это было так легко реализовать. Это привело к бесчисленным ошибкам, уязвимостям и системным сбоям, которые, вероятно, причинили боль и ущерб на миллиард долларов за последние сорок лет.

Проблема с null значениям заключается в том, что если вы попытаетесь использовать его значение в качестве не-null значения, вы получите какую-то ошибку. Из-за того, что null или не-null свойство всеобъемлющее и может быть использовано повсеместно, очень легко использовать null и в дальнейшем получить такого рода ошибку.

Тем не менее, концепция, которую null пытается выразить, является полезной: null - это значение, которое в настоящее время по какой-то причине недействительно или отсутствует.

Проблема не в самой концепции, а в конкретной реализации. Таким образом, в Rust нет null-значений, но есть перечисление, которое может закодировать концепцию наличия или отсутствия значения. Это перечисление Option<T> и оно объявляется в стандартной библиотеке следующим образом:


#![allow(unused)]
fn main() {
enum Option<T> {
    None,
    Some(T),
}
}

Перечисление Option<T> настолько полезно, что даже подключено в авто-импорте; его не нужно явно подключать в область видимости. Дополнительно подключены также и его варианты: можно использовать Some и None напрямую, без префикса Option::. Перечисление Option<T> все ещё является обычным перечислением, а Some(T) и None являются вариантами типа Option<T>.

<T> - это особенность Rust, о которой мы ещё не говорили. Это параметр обобщённого типа, и мы рассмотрим его более подробно в главе 10. На данный момент всё, что вам нужно знать, это то, что <T> означает, что вариант Some Option может содержать один фрагмент данных любого типа, и что каждый конкретный тип, который используется вместо T делает общий Option<T> другим типом. Вот несколько примеров использования Option для хранения числовых и строковых типов:

fn main() {
    let some_number = Some(5);
    let some_char = Some('e');

    let absent_number: Option<i32> = None;
}

Тип some_number - Option<i32>. Тип some_string - Option<&str>, который другого типа. Rust может вывести эти типы, потому что мы указали значение внутри варианта Some. Для absent_number Rust требует, чтобы мы аннотировали общий тип дляOption: компилятор не может вывести тип, который в Some, глядя только на значение None. Здесь мы сообщаем Rust, что мы имеем в виду, что absent_number должен иметь тип Option<i32>.

Когда есть значение Some, мы знаем, что значение присутствует и содержится внутри Some. Когда есть значение None, это означает то же самое, что и null в некотором смысле: у нас нет действительного значения. Так почему наличие Option<T> лучше, чем null?

Вкратце, поскольку Option<T> и T (где T может быть любым типом) относятся к разным типам, компилятор не позволит нам использовать значение Option<T> даже если бы оно было определённо допустимым вариантом Some. Например, этот код не будет компилироваться, потому что он пытается добавить i8 к значению типа Option<i8> :

fn main() {
    let x: i8 = 5;
    let y: Option<i8> = Some(5);

    let sum = x + y;
}

Запуск данного кода даст ошибку ниже:

$ cargo run
   Compiling enums v0.1.0 (file:///projects/enums)
error[E0277]: cannot add `Option<i8>` to `i8`
 --> src/main.rs:5:17
  |
5 |     let sum = x + y;
  |                 ^ no implementation for `i8 + Option<i8>`
  |
  = help: the trait `Add<Option<i8>>` is not implemented for `i8`

For more information about this error, try `rustc --explain E0277`.
error: could not compile `enums` due to previous error

Сильно! Фактически, это сообщение об ошибке означает, что Rust не понимает, как сложить i8 и Option<i8>, потому что это разные типы. Когда у нас есть значение типа на подобие i8, компилятор гарантирует, что у нас всегда есть допустимое значение типа. Мы можем уверенно продолжать работу, не проверяя его на null перед использованием. Однако, когда у нас есть значение типа Option<T> (где T - это любое значение любого типа T, упакованное в Option, например значение типа i8 или String), мы должны беспокоиться о том, что значение типа T возможно не имеет значения (является вариантом None), и компилятор позаботится о том, чтобы мы обработали такой случай, прежде чем мы бы попытались использовать None значение.

Другими словами, вы должны преобразовать Option<T> в T прежде чем вы сможете выполнять операции с этим T. Как правило, это помогает выявить одну из наиболее распространённых проблем с null: когда мы предполагаем, что что-то не равно null, хотя на самом деле оно null.

С Rust не нужно беспокоиться о неправильном предположении касательно не-null значения, это помогает чувствовать себя более уверенно. Для того, чтобы иметь значение, которое может быть null, вы должны явно сказать об этом, указав тип T этого значения как Option<T> (обернуть его в Option). Затем, когда вы используете это значение, вы обязаны явно обрабатывать случай, когда значение равно null. Везде, где значение имеет тип, не являющий Option<T>, вы можете смело рассчитывать на то, что значение не равно null. Такой подход - продуманное проектное решение в Rust, ограничивающее распространение null и увеличивающее безопасность Rust кода.

Итак, как мы можем получить желанное значение типа T, упакованное в варианте Some типа Option, когда у нас на руках есть только значение типа Option<T>? Option<T> имеет большое количество методов, которые полезны в различных ситуациях; можно проверить их в документации. Знакомство с методами в Option<T> будет чрезвычайно полезным в вашем путешествии по языку Rust.

В общем случае, чтобы использовать значение Option<T>, нужен код, который будет обрабатывать все варианты перечисления Option<T>. Вам понадобится некоторый код, который будет работать только тогда, когда у вас есть значение Some(T), и этому коду разрешено использовать внутреннее T. Также вам понадобится другой код, который будет работать, если у вас есть значение None, и у этого кода не будет доступного значения T. Выражение match — это конструкция управления потоком выполнения программы, которая делает именно это при работе с перечислениями: она запускает разный код в зависимости от того, какой вариант перечисления имеется, и этот код может использовать данные, находящиеся внутри совпавшего варианта.

Оператор управления потоком выполнения match

В Rust есть невероятно мощная конструкция управления потоком выполнения программы под названием match, которая позволяет вам сравнивать значение с серией шаблонов и затем выполнять код, связанный с совпавшим шаблоном. Шаблонами могут выступать литералы, имена переменных, подстановочные значения и многое другое; в Главе 18 описаны все разновидности шаблонов и что они делают. Сила match проистекает из выразительности шаблонов и того факта, что компилятор подтверждает, что все возможные случаи обрабатываются.

Думайте о выражении match как о машине для сортировки монет: монеты скользят вниз по дорожке с отверстиями разного размера и каждая монета проваливается в первое отверстие, в которое она проходит. Таким же образом значения проходят через каждый шаблон в конструкции match и при первом же совпадении с шаблоном значение "проваливается" в соответствующий блок кода для дальнейшего использования.

Поскольку мы только что упомянули монеты, давайте использовать их в качестве примера, используя match! Можно написать функцию, которая возьмёт неизвестную монету Соединённых Штатов и, подобно счётной машине, определит какая это монета и вернёт её значение в центах, как показано в листинге 6-3.

enum Coin {
    Penny,
    Nickel,
    Dime,
    Quarter,
}

fn value_in_cents(coin: Coin) -> u8 {
    match coin {
        Coin::Penny => 1,
        Coin::Nickel => 5,
        Coin::Dime => 10,
        Coin::Quarter => 25,
    }
}

fn main() {}

Листинг 6-3: Перечисление и выражение match, которое использует варианты перечисления в качестве шаблонов

Давайте разберём match в функции value_in_cents. Сначала пишется ключевое слово match, затем следует выражение, которое в данном случае является значением coin. Это выглядит очень похоже на выражение if, но есть большая разница: с if выражение должно возвращать булево значение, а здесь это может быть любой тип. Тип coin в этом примере - перечисление типа Coin, объявленное в строке 1.

Далее идут ветки match. Ветки состоят из двух частей: шаблон и некоторый код. Здесь первая ветка имеет шаблон, который является значением Coin::Penny, затем идёт оператор =>, который разделяет шаблон и код для выполнения. Код в этом случае - это просто значение 1. Каждая ветка отделяется от последующей при помощи запятой.

Когда выполняется выражение match, оно сравнивает полученное значение с образцом каждой ветки по порядку. Если шаблон совпадает со значением, то выполняется код, связанный с этим шаблоном. Если этот шаблон не соответствует значению, то выполнение продолжается со следующей ветки, так же, как в автомате по сортировке монет. У нас может быть столько веток, сколько нужно: в листинге 6-3 наш match состоит из четырёх веток.

Код, связанный с каждой веткой, является выражением, а полученное значение выражения в соответствующей ветке — это значение, которое возвращается для всего выражения match.

Фигурные скобки обычно не используются, если код ветки короткий, как в листинге 6-3, где каждая ветка только возвращает значение. Если необходимо выполнить несколько строк кода в ветке, можно использовать фигурные скобки. Например, следующий код будет выводить «Lucky penny!» каждый раз, когда метод вызывается со значением Coin::Penny, но возвращаться при этом будет результат последнего выражения в блоке, то есть значение 1:

enum Coin {
    Penny,
    Nickel,
    Dime,
    Quarter,
}

fn value_in_cents(coin: Coin) -> u8 {
    match coin {
        Coin::Penny => {
            println!("Lucky penny!");
            1
        }
        Coin::Nickel => 5,
        Coin::Dime => 10,
        Coin::Quarter => 25,
    }
}

fn main() {}

Шаблоны, которые привязывают значения

Есть ещё одно полезное качество у веток в выражении match: они могут привязываться к частям тех значений, которые совпали с шаблоном. Благодаря этому можно извлекать значения из вариантов перечисления.

В качестве примера, давайте изменим один из вариантов перечисления так, чтобы он хранил в себе данные. С 1999 по 2008 год Соединённые Штаты чеканили 25 центов с различным дизайном на одной стороне для каждого из 50 штатов. Ни одна другая монета не получила дизайна штата, только четверть доллара имела эту дополнительную особенность. Мы можем добавить эту информацию в наш enum путём изменения варианта Quarter и включить в него значение UsState, как сделано в листинге 6-4.

#[derive(Debug)] // so we can inspect the state in a minute
enum UsState {
    Alabama,
    Alaska,
    // --snip--
}

enum Coin {
    Penny,
    Nickel,
    Dime,
    Quarter(UsState),
}

fn main() {}

Листинг 6-4: Перечисление Coin, где вариант Quarter содержит также значение UsState

Давайте представим, что наш друг пытается собрать четвертаки всех 50 штатов. Пока мы сортируем мелочь по типу монет, мы также будем печатать имя штата, связанное с каждым четвертаком. Таким образом, если у нашего друга ещё нет такой монеты, то её можно добавить в его коллекцию.

В выражении match для этого кода мы добавляем переменную с именем state в шаблон, который соответствует значениям варианта Coin::Quarter. Когда Coin::Quarter совпадёт с шаблоном, переменная state будет привязана к значению штата этого четвертака. Затем мы сможем использовать state в коде этой ветки, вот так:

#[derive(Debug)]
enum UsState {
    Alabama,
    Alaska,
    // --snip--
}

enum Coin {
    Penny,
    Nickel,
    Dime,
    Quarter(UsState),
}

fn value_in_cents(coin: Coin) -> u8 {
    match coin {
        Coin::Penny => 1,
        Coin::Nickel => 5,
        Coin::Dime => 10,
        Coin::Quarter(state) => {
            println!("State quarter from {:?}!", state);
            25
        }
    }
}

fn main() {
    value_in_cents(Coin::Quarter(UsState::Alaska));
}

Если мы сделаем вызов функции value_in_cents(Coin::Quarter(UsState::Alaska)), то coin будет иметь значение Coin::Quarter(UsState::Alaska). Когда мы будем сравнивать это значение с каждой из веток, ни одна из них не будет совпадать, пока мы не достигнем варианта Coin::Quarter(state). В этот момент state привяжется к значению UsState::Alaska. Затем мы сможем использовать эту привязку в выражении println!, получив таким образом внутреннее значение варианта Quarter перечисления Coin.

Сопоставление шаблона для Option<T>

В предыдущем разделе мы хотели получить внутреннее значение T для случая Some при использовании Option<T>; мы можем обработать тип Option<T> используя match, как уже делали с перечислением Coin! Вместо сравнения монет мы будем сравнивать варианты Option<T>, независимо от этого изменения механизм работы выражения match останется прежним.

Допустим, мы хотим написать функцию, которая принимает Option<i32> и если есть значение внутри, то добавляет 1 к существующему значению. Если значения нет, то функция должна возвращать значение None и не пытаться выполнить какие-либо операции.

Такую функцию довольно легко написать благодаря выражению match, код будет выглядеть как в листинге 6-5.

fn main() {
    fn plus_one(x: Option<i32>) -> Option<i32> {
        match x {
            None => None,
            Some(i) => Some(i + 1),
        }
    }

    let five = Some(5);
    let six = plus_one(five);
    let none = plus_one(None);
}

Листинг 6-5: Функция, которая использует выражение match с типом Option<i32>

Давайте рассмотрим процесс выполнения функции plus_one более подробно. Когда мы вызываем plus_one(five), то переменная x в теле plus_one будет иметь значение Some(5). Затем мы сравниваем это значение с каждой веткой выражения match.

fn main() {
    fn plus_one(x: Option<i32>) -> Option<i32> {
        match x {
            None => None,
            Some(i) => Some(i + 1),
        }
    }

    let five = Some(5);
    let six = plus_one(five);
    let none = plus_one(None);
}

Значение Some(5) не соответствует шаблону None, поэтому мы продолжаем со следующей ветки.

fn main() {
    fn plus_one(x: Option<i32>) -> Option<i32> {
        match x {
            None => None,
            Some(i) => Some(i + 1),
        }
    }

    let five = Some(5);
    let six = plus_one(five);
    let none = plus_one(None);
}

Совпадает ли Some(5) с шаблоном Some(i)? Да, это так! У нас такой же вариант. Тогда переменная i привязывается к значению, содержащемуся внутри Some, поэтому i получает значение 5. Затем выполняется код ассоциированный для данной ветки, поэтому мы добавляем 1 к значению i и создаём новое значение Some со значением 6 внутри.

Теперь давайте рассмотрим второй вызов plus_one в листинге 6-5, где x является None. Мы входим в выражение match и сравниваем значение с первой веткой.

fn main() {
    fn plus_one(x: Option<i32>) -> Option<i32> {
        match x {
            None => None,
            Some(i) => Some(i + 1),
        }
    }

    let five = Some(5);
    let six = plus_one(five);
    let none = plus_one(None);
}

Оно совпадает! Для данной ветки шаблон (None) не подразумевает наличие какого-то значения к которому можно было бы что-то добавить, поэтому программа останавливается и возвращает значение которое находится справа от => - т.е. None. Так как шаблон первой ветки совпал, то никакие другие шаблоны веток не сравниваются.

Комбинирование match и перечислений полезно во многих ситуациях. Вы много где сможете увидеть подобный шаблон в коде программ на Rust: сделать сопоставление значения используя один из шаблонов match, привязать данные входного значения к данным внутри ветки, выполнить код на основе привязанных данных. Сначала это может показаться немного сложным, но как только вы привыкнете, то захотите чтобы такая возможность была бы во всех языках. Это неизменно любимый пользователями приём.

Match объемлет все варианты значения

Есть ещё один аспект выражения match, который необходимо обсудить. Рассмотрим версию нашей функции plus_one, которая имеет ошибку и не будет компилироваться:

fn main() {
    fn plus_one(x: Option<i32>) -> Option<i32> {
        match x {
            Some(i) => Some(i + 1),
        }
    }

    let five = Some(5);
    let six = plus_one(five);
    let none = plus_one(None);
}

Мы не обработали вариант None, поэтому этот код вызовет дефект в программе. К счастью, Rust знает и умеет ловить такой случай. Если мы попытаемся скомпилировать такой код, мы получим ошибку компиляции:

$ cargo run
   Compiling enums v0.1.0 (file:///projects/enums)
error[E0004]: non-exhaustive patterns: `None` not covered
   --> src/main.rs:3:15
    |
3   |         match x {
    |               ^ pattern `None` not covered
    |
note: `Option<i32>` defined here
    = note: the matched value is of type `Option<i32>`
help: ensure that all possible cases are being handled by adding a match arm with a wildcard pattern or an explicit pattern as shown
    |
4   ~             Some(i) => Some(i + 1),
5   ~             None => todo!(),
    |

For more information about this error, try `rustc --explain E0004`.
error: could not compile `enums` due to previous error

Rust знает, что мы не обработали все возможные варианты входного значения, и даже знает какие ветки с какими шаблонами мы забыли добавить! Сравнение по шаблону в Rust является полными и исчерпывающими (exhaustive): мы должны обработать все возможные варианты до конца, чтобы код был корректным в понимании компилятора. Особенно в случае Option<T>, когда Rust не позволит нам забыть обработать случай None и защитит нас от ошибочного предположения, о том, что у нас всегда есть значение, хотя на самом деле мы могли бы получить null. Таким образом не дают допустить ошибку на миллиард долларов, рассмотренную ранее.

Универсальные шаблоны и заполнитель _

Используя перечисления, мы также можем выполнять специальные действия для нескольких определённых значений, а для всех остальных значений выполнять одно действие по умолчанию. Представьте, что мы реализуем игру, в которой при выпадении 3 игрок не двигается, а получает новую модную шляпу. Если выпадает 7, игрок теряет шляпу. При всех остальных значениях ваш игрок перемещается на столько-то мест на игровом поле. Вот match, реализующий эту логику, в котором результат броска костей жёстко закодирован, а не является случайным значением, а вся остальная логика представлена функциями без тел, поскольку их реализация не входит в рамки данного примера:

fn main() {
    let dice_roll = 9;
    match dice_roll {
        3 => add_fancy_hat(),
        7 => remove_fancy_hat(),
        other => move_player(other),
    }

    fn add_fancy_hat() {}
    fn remove_fancy_hat() {}
    fn move_player(num_spaces: u8) {}
}

Для первых двух веток шаблонами являются литеральные значения 3 и 7. Для последней ветки, которая охватывает все остальные возможные значения, шаблоном является переменная, которую мы решили назвать other. Код, выполняемый для другой ветки, использует эту переменную, передавая её в функцию move_player.

Этот код компилируется, даже если мы не перечислили все возможные значения u8, потому что последний паттерн будет соответствовать всем значениям, не указанным в конкретном списке. Этот универсальный шаблон удовлетворяет требованию, что соответствие должно быть исчерпывающим. Обратите внимание, что мы должны поместить ветку catch-all последней, потому что шаблоны оцениваются по порядку. Rust предупредит нас, если мы добавим ветки после catch-all, потому что эти последующие ветки никогда не будут совпадать!

В Rust также есть шаблон, который можно использовать, когда мы не хотим использовать значение в шаблоне catch-all: _, который является специальным шаблоном, который соответствует любому значению и не привязывается к этому значению. Это говорит Rust, что мы не собираемся использовать это значение, поэтому Rust не будет предупреждать нас о неиспользуемой переменной.

Давайте изменим правила игры так: если выпадает что-то, кроме 3 или 7, нужно бросить ещё раз. Нам не нужно использовать значение в этом случае, поэтому мы можем изменить наш код, чтобы использовать _ вместо переменной с именем other:

fn main() {
    let dice_roll = 9;
    match dice_roll {
        3 => add_fancy_hat(),
        7 => remove_fancy_hat(),
        _ => reroll(),
    }

    fn add_fancy_hat() {}
    fn remove_fancy_hat() {}
    fn reroll() {}
}

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

Если мы изменим правила игры ещё раз, чтобы в ваш ход не происходило ничего другого, если вы бросаете не 3 или 7, мы можем выразить это, используя единичное значение (пустой тип кортежа, о котором мы упоминали в разделе "Тип кортежа") в качестве кода, который идёт вместе с веткой _:

fn main() {
    let dice_roll = 9;
    match dice_roll {
        3 => add_fancy_hat(),
        7 => remove_fancy_hat(),
        _ => (),
    }

    fn add_fancy_hat() {}
    fn remove_fancy_hat() {}
}

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

Подробнее о шаблонах и совпадениях мы поговорим в Главе 18. Пока же мы перейдём к синтаксису if let, который может быть полезен в ситуациях, когда выражение соответствия слишком многословно.

Компактное управление потоком выполнения с if let

Синтаксис if let позволяет скомбинировать if и let в менее многословную конструкцию, и затем обработать значения соответствующе только одному шаблону, одновременно игнорируя все остальные. Рассмотрим программу, в которой мы делаем поиск по шаблону значения Option<u8>, чтобы выполнить код только когда значение равно 3:

fn main() {
    let config_max = Some(3u8);
    match config_max {
        Some(max) => println!("The maximum is configured to be {}", max),
        _ => (),
    }
}

Листинг 6-6. Выражение match которое выполнит код только при значении равном Some(3)

Мы хотим выполнить что-нибудь при совпадении значения с Some(3) и не хотим ничего делать с любым другим Some<u8> или значением None . Для удовлетворения match, после первой и единственной ветки, нам пришлось добавить дополнительный шаблонный код: ветку _ => ().

Вместо этого мы могли бы решить нашу задачу более коротким способом, используя if let. Следующий код ведёт себя так же, как выражение match в листинге 6-6:

fn main() {
    let config_max = Some(3u8);
    if let Some(max) = config_max {
        println!("The maximum is configured to be {}", max);
    }
}

Синтаксис if let принимает шаблон и выражение, разделённые знаком равенства. if let сработает так же, как match, когда в него на вход передадут выражение и подходящим шаблоном для этого выражения окажется первая ветка.

Используя if let мы меньше печатаем, меньше делаем отступов и меньше получаем шаблонного кода. Тем не менее, мы теряем полную проверку всех вариантов, предоставляемую выражением match. Выбор между match и if let зависит от того, что вы делаете в вашем конкретном случае и является ли получение краткости при потере полноты проверки подходящим компромиссом.

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

Можно добавлять else к if let. Блок кода, который находится внутри else аналогичен по смыслу блоку кода ветки связанной с шаблоном _ выражения match (которое эквивалентно сборной конструкции if let и else). Вспомним объявление перечисления Coin в листинге 6-4, где вариант Quarter также содержит внутри значение штата типа UsState. Если бы мы хотели посчитать все монеты не являющиеся четвертями, а для четвертей печатать название штата, то мы могли бы сделать это с помощью выражения match таким образом:

#[derive(Debug)]
enum UsState {
    Alabama,
    Alaska,
    // --snip--
}

enum Coin {
    Penny,
    Nickel,
    Dime,
    Quarter(UsState),
}

fn main() {
    let coin = Coin::Penny;
    let mut count = 0;
    match coin {
        Coin::Quarter(state) => println!("State quarter from {:?}!", state),
        _ => count += 1,
    }
}

Или мы могли бы использовать выражение if let и else так:

#[derive(Debug)]
enum UsState {
    Alabama,
    Alaska,
    // --snip--
}

enum Coin {
    Penny,
    Nickel,
    Dime,
    Quarter(UsState),
}

fn main() {
    let coin = Coin::Penny;
    let mut count = 0;
    if let Coin::Quarter(state) = coin {
        println!("State quarter from {:?}!", state);
    } else {
        count += 1;
    }
}

Если у вас есть ситуация в которой ваша программа имеет логику которая слишком многословна для того чтобы её выражать используя match, помните, о том, что также в вашем наборе инструментов Rust есть if let.

Итоги

Мы рассмотрели как использовать перечисления для создания пользовательских типов, которые могут быть одним из наборов перечисляемых значений. Мы показали, как тип Option<T> из стандартной библиотеки помогает использовать систему типов для предотвращения ошибок. А когда значения перечисления имеют данные внутри них, можно использовать match или if let, чтобы извлечь и пользоваться значением, в зависимости от того, сколько случаев нужно обработать.

Теперь ваши программы Rust могут выражать концепции вашей предметной области используя структуры и перечисления. Создание и использование пользовательских типов в API обеспечивает типобезопасность, type safety, вашего API: компилятор позаботится о том, чтобы функции получали значения только того типа, который они ожидают.

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

Управление растущими проектами с помощью пакетов, крейтов и модулей

По мере роста кодовой базы ваших программ, организация проекта будет иметь большое значение, ведь отслеживание всей программы в голове будет становиться всё более сложным. Группируя связанные функции и разделяя код по основным функциональностям, (фичам, feature), вы делаете более прозрачным понимание о том, где искать код реализующий определённую функцию и где стоит вносить изменения для того чтобы изменить её поведение.

Программы, которые мы писали до сих пор, были в одном файле одного модуля. По мере роста проекта, мы можем организовывать код иначе, разделив его на несколько модулей и несколько файлов. Пакет может содержать несколько бинарных крейтов и опционально один крейт библиотеки. По мере роста пакета мы также можем извлекать части нашей программы в отдельные крейты, которые затем станут внешними зависимостями для основного кода нашей программы. Эта глава охватывает все эти техники. В свою очередь для очень крупных проектов, состоящих из набора взаимосвязанных пакетов развивающихся вместе, Cargo предоставляет рабочие пространства, workspaces, их мы рассмотрим за пределами данной главы, в разделе "Рабочие пространства Cargo" Главы 14.

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

Связанное понятие - это область видимости: вложенный контекст в котором написан код имеющий набор имён, которые определены «в текущей области видимости». При чтении, письме и компиляции кода, программистам и компиляторам необходимо знать, относится ли конкретное имя в определённом месте к переменной, к функции, к структуре, к перечислению, к модулю, к константе или другому элементу и что означает этот элемент. Можно создавать области видимости и изменять какие имена входят или выходят за их рамки. Нельзя иметь два элемента с тем же именем в одной области; есть доступные инструменты для разрешения конфликтов имён.

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

  • Пакеты, Packages: Функционал Cargo позволяющий собирать, тестировать и делиться крейтами
  • Крейты, Crates: Дерево модулей, которое создаёт библиотечный или исполняемый файл
  • Модули, Modules и use: Позволяют вместе контролировать организацию, область видимости и конфиденциальность путей
  • Пути, Paths: способ именования элемента, такого как структура, функция или модуль

В этой главе мы рассмотрим все эти функции, обсудим как они взаимодействуют и объясним, как использовать их для управления областью видимости. К концу у вас должно появиться солидное понимание модульной системы и умение работать с областями видимости на уровне профессионала!

Пакеты и крейты

Первые части модульной системы, которые мы рассмотрим - это пакеты и крейты. Крейт - это исполняемый файл или библиотека. Выделяют два типа крейтов: библиотечный и исполняемый. Библиотечные крейты можно подключать в другие крейты, но нельзя исполнять. Исполняемые же крейты - полная противоположность библиотечным - могут исполняться, но их нельзя подключить в другие крейты. Корень крейта - это исходный файл, на котором запускается и, исходя из которого, составляет корневой модуль вашего крейта Rust компилятор (мы расскажем о корневых модулях в разделе "Определение модулей для управления областью видимости и конфиденциальностью"). Пакет состоит из одного или нескольких крейтов, которые предоставляют набор функций. Пакет содержит файл Cargo.toml описывающий, как собрать крейты пакета.

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

Давайте пройдёмся по тому, что происходит, когда мы создаём пакет. Сначала введём команду cargo new:

$ cargo new my-project
     Created binary (application) `my-project` package
$ ls my-project
Cargo.toml
src
$ ls my-project/src
main.rs

После ввода команды Cargo создал файл Cargo.toml, предоставив пакет. Если мы просмотрим содержимое Cargo.toml, то не увидим упоминания о src/main.rs потому что Cargo следует соглашению, что src/main.rs является корнем исполняемого крейта с тем же именем, что и пакет. Аналогично, Cargo знает, что если каталог пакета содержит src/lib.rs, то пакет содержит библиотечный крейт с тем же именем, что и пакет, а src/lib.rs является корнем библиотечного крейта. Cargo передаёт корневой файл крейта в rustc и тот уже создаст библиотеку или бинарный исполняемый файл в зависимости от типа крейта.

Здесь мы имеем пакет, который содержит только src/main.rs, то есть содержит только бинарный крейт с именем my-project. Если пакет содержит src/main.rs и src/lib.rs, то он имеет два крейта: библиотечный и исполняемый, оба с одинаковыми именами в качестве пакета. Пакет может иметь несколько исполняемых крейтов, размещая их файлы в каталоге src/bin: каждый файл будет отдельным исполняемым крейтом.

Крейт группирует в области видимости связанные вместе функциональности, поэтому функциональности легко распространить между несколькими проектами. Например, крейт rand, который мы использовали в Главе 2 обеспечивает функциональность генерации случайных чисел. Можно использовать эту функциональность в наших собственных проектах, привнося крейт rand в область видимости проекта. Вся функциональность предоставляемая крейтом rand станет доступна через имя крейта rand.

Сохранение функциональности крейта в его собственной области видимости проясняет, является ли конкретная функциональность определённой в нашем крейте или в крейте rand, таким образом предотвращая потенциальные конфликты. Например, крейт rand предоставляет типаж с именем Rng. Мы также можем определить struct с именем Rng в нашем собственном крейте. Так как функциональность крейта находится в пространстве имён собственной области видимости, то когда мы добавляем rand как зависимость, компилятор не смущён ссылкой на имя Rng. В нашем крейте ссылка относится к объявленной у нас struct Rng. А доступ к типажу Rng из крейта rand мы бы получили как rand::Rng.

Давайте будем двигаться дальше и поговорим о модульной системе!

Определение модулей для контроля видимости и конфиденциальности

В этом разделе мы поговорим о модулях и других частях системы модулей, а именно: путях(paths), которые позволяют именовать элементы; ключевом слове use, которое приносит путь в область видимости; ключевом слове pub, которое делает элементы общедоступными. Мы также обсудим ключевое слово as, внешние пакеты и оператор glob. А пока давайте сосредоточимся на модулях!

Модули позволяют организовывать код внутри крейта по группам для удобства чтения и простого повторного использования. Модули также контролируют конфиденциальность (privacy) элементов: определяют может элемент использоваться внешним кодом, быть публичным (public) или является деталями внутренней реализации и недоступен для внешнего использования, т.е. является приватным (private).

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

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

Чтобы структурировать крейт аналогично тому, как работает настоящий ресторан, можно организовать размещение функций во вложенных модулях. Создадим новую библиотеку (библиотечный крейт) с именем restaurant выполнив команду cargo new --lib restaurant; затем вставим код из листинга 7-1 в файл src/lib.rs для определения некоторых модулей и сигнатур функций.

Файл: src/lib.rs

mod front_of_house {
    mod hosting {
        fn add_to_waitlist() {}

        fn seat_at_table() {}
    }

    mod serving {
        fn take_order() {}

        fn serve_order() {}

        fn take_payment() {}
    }
}

Листинг 7-1. Модуль front_of_house содержащий другие модули, которые затем содержат функции

Мы определяем модуль, начиная с ключевого слова mod, затем определяем название модуля (в данном случае front_of_house) и размещаем фигурные скобки вокруг тела модуля. Внутри модулей, можно иметь другие модули, как в случае с модулями hosting и serving. Модули также могут содержать определения для других элементов, таких как структуры, перечисления, константы, типажи или - как в листинге 7-1 - функции.

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

Как мы упоминали ранее, файлы src/main.rs и src/lib.rs называются корнями крейтов. Причина такого именования в том, что любой из этих двух файлов определяет содержимое и размещает в корне структуры модуля, известной как дерево модулей, корневой модуль известный как crate.

В листинге 7-2 показано дерево модулей для структуры модулей приведённой в коде листинга 7-1.

crate
 └── front_of_house
     ├── hosting
     │   ├── add_to_waitlist
     │   └── seat_at_table
     └── serving
         ├── take_order
         ├── serve_order
         └── take_payment

Листинг 7-2: Дерево модулей для для структуры модулей приведённой в коде в листинге 7-1

Это дерево показывает, как некоторые из модулей вкладываются друг в друга (например, hosting находится внутри front_of_house). Дерево также показывает, что некоторые модули являются братьями (siblings) друг для друга, то есть они определены в одном модуле (hosting и serving - братья которые определены внутри front_of_house). Продолжая метафору с семьёй: если модуль A содержится внутри модуля B, мы говорим, что модуль A является потомком (child) модуля B, а модуль B является родителем (parent) модуля A. Обратите внимание, что корнем (отцом, главным предком) всего дерева модулей является неявный модуль с именем crate.

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

Ссылаемся на элементы дерева модулей при помощи путей

Чтобы показать Rust, где найти элемент в дереве модулей, мы используем путь так же, как мы используем путь при навигации по файловой системе. Например, если мы хотим вызвать функцию, то нам нужно знать её путь.

Пути бывают двух видов:

  • абсолютный путь берет своё начало с корня крейта: названия крейта или ключевого слова crate,
  • относительный путь начинается с текущего модуля и использует ключевые слова self, super или идентификатор в текущем модуле.

Как абсолютные, так и относительные, пути сопровождаются одним или несколькими идентификаторами разделёнными двойными двоеточиями (::).

Давайте вернёмся к примеру в листинге 7-1. Как бы мы вызывали функцию add_to_waitlist? Наш вызов был бы похож на путь к функции add_to_waitlist? В листинге 7-3 мы немного упростили код листинга 7-1, удалив ненужные модули и функции. Мы покажем два способа вызова функции add_to_waitlist из новой функции eat_at_restaurant определённой в корне крейта. Функция eat_at_restaurant является частью нашей библиотеки публичного API, поэтому мы помечаем её ключевым словом pub. В разделе "Раскрытие путей с помощью ключевого слова pub", мы рассмотрим более подробно pub. Обратите внимание, что этот пример ещё не компилируется; мы скоро объясним почему.

Файл: src/lib.rs

mod front_of_house {
    mod hosting {
        fn add_to_waitlist() {}
    }
}

pub fn eat_at_restaurant() {
    // Absolute path
    crate::front_of_house::hosting::add_to_waitlist();

    // Relative path
    front_of_house::hosting::add_to_waitlist();
}

Листинг 7-3. Вызов функции add_to_waitlist с использованием абсолютного и относительного пути

Первый раз, когда мы вызываем функцию add_to_waitlist в функции eat_at_restaurant мы используем абсолютный путь. Функция add_to_waitlist определена в том же крейте что и eat_at_restaurant и это означает, что мы можем использовать ключевое слово crate в начале абсолютного пути.

После ключевого слова crate мы включаем каждый из последующих дочерних модулей, пока не составим путь до add_to_waitlist. Вы можете представить файловую систему с такой же структурой, где мы указали бы путь /front_of_house/hosting/add_to_waitlist для запуска программы add_to_waitlist; мы используем слово crate, чтобы начать путь из корня крейта, подобно тому как используется / для указания корневой директории файловой системы.

Второй раз, когда мы вызываем add_to_waitlist внутри eat_at_restaurant, мы используем относительный путь. Путь начинается с имени модуля front_of_house, определённого на том же уровне дерева модулей, что и модуль eat_at_restaurant. Для относительного пути эквивалентный путь в вымышленной файловой системе выглядел бы так: front_of_house/hosting/add_to_waitlist. Начало пути совпадает с именем модуля, что указывает на то, что перед нами относительный путь.

Выбор, использовать относительный или абсолютный путь, является решением, которое вы примете на основании вашего проекта. Решение должно зависеть от того, с какой вероятностью вы переместите объявление элемента отдельно от или вместе с кодом использующим этот элемент. Например, в случае перемещения модуля front_of_house и его функции eat_at_restaurant в другой модуль с именем customer_experience, будет необходимо обновить абсолютный путь до add_to_waitlist, но относительный путь все равно будет действителен. Однако, если мы переместим отдельно функцию eat_at_restaurant в модуль с именем dining, то абсолютный путь вызова add_to_waitlist останется прежним, а относительный путь нужно будет обновить. Мы предпочитаем указывать абсолютные пути, потому что это позволяет проще перемещать определения кода и вызовы элементов независимо друг от друга.

Давайте попробуем скомпилировать листинг 7-3 и выяснить, почему он ещё не компилируется. Ошибка, которую мы получаем, показана в листинге 7-4.

$ cargo build
   Compiling restaurant v0.1.0 (file:///projects/restaurant)
error[E0603]: module `hosting` is private
 --> src/lib.rs:9:28
  |
9 |     crate::front_of_house::hosting::add_to_waitlist();
  |                            ^^^^^^^ private module
  |
note: the module `hosting` is defined here
 --> src/lib.rs:2:5
  |
2 |     mod hosting {
  |     ^^^^^^^^^^^

error[E0603]: module `hosting` is private
  --> src/lib.rs:12:21
   |
12 |     front_of_house::hosting::add_to_waitlist();
   |                     ^^^^^^^ private module
   |
note: the module `hosting` is defined here
  --> src/lib.rs:2:5
   |
2  |     mod hosting {
   |     ^^^^^^^^^^^

For more information about this error, try `rustc --explain E0603`.
error: could not compile `restaurant` due to 2 previous errors

Листинг 7-4. Ошибки компиляции при сборке кода из листинга 7-3

Сообщения об ошибках говорят о том, что модуль hosting является приватным. Таким образом, у нас есть правильные пути к модулю hosting и функции add_to_waitlist, но Rust не позволяет использовать их, потому что он не имеет доступа к разделам которые являются приватными, как в нашем случае.

Модули не только полезны для организации кода. Они также определяют границы конфиденциальности (privacy boundary) в Rust: граница, которая инкапсулирует детали реализации, которые внешний код не может знать, вызывать или полагаться на них. Итак, если вы хотите сделать элемент приватным, например функцию или структуру, то разместите его в модуль.

В Rust конфиденциальность (privacy) работает так, что все элементы (функции, методы, структуры, перечисления, модули и константы) являются по умолчанию приватными. Элементы в родительском модуле не могут использовать приватные элементы внутри дочерних модулей, но элементы в дочерних модулях могут использовать элементы у своих родительских модулей. Причина в том, что дочерние модули оборачивают и скрывают детали своей реализации, но модули могут видеть контекст, в котором они определены. Продолжая метафору с рестораном, думайте о правилах конфиденциальности как о задней части ресторана: то что там происходит скрыто от клиентов ресторана, но открыто для менеджеров ресторана: они могут видеть и управлять рестораном в котором они все работают.

В Rust решили, что система модулей должна функционировать таким образом, чтобы по умолчанию скрывать детали реализации. Таким образом, вы знаете, какие части внутреннего кода вы можете изменять не нарушая работы внешнего кода. Но у нас всё же остаётся возможность раскрывать внутренние части кода дочерних модулей для внешних модулей - предков. Чтобы сделать элемент публичным мы можем использовать ключевое слово pub.

Раскрываем приватные пути с помощью ключевого слова pub

Давайте вернёмся к ошибке в листинге 7-4, которая говорит что модуль hosting является приватным. Мы хотим, чтобы функция eat_at_restaurant представленная в родительском модуле eat_at_restaurant имела доступ к функции add_to_waitlist в дочернем модуле, поэтому мы помечаем модуль hosting с ключевым словом pub, как показано в листинге 7-5.

Файл: src/lib.rs

mod front_of_house {
    pub mod hosting {
        fn add_to_waitlist() {}
    }
}

pub fn eat_at_restaurant() {
    // Absolute path
    crate::front_of_house::hosting::add_to_waitlist();

    // Relative path
    front_of_house::hosting::add_to_waitlist();
}

Листинг 7-5. Объявление модуля hosting как pub для его использования из eat_at_restaurant

К сожалению, код в листинге 7-5 всё ещё приводит к ошибке, как показано в листинге 7-6.

$ cargo build
   Compiling restaurant v0.1.0 (file:///projects/restaurant)
error[E0603]: function `add_to_waitlist` is private
 --> src/lib.rs:9:37
  |
9 |     crate::front_of_house::hosting::add_to_waitlist();
  |                                     ^^^^^^^^^^^^^^^ private function
  |
note: the function `add_to_waitlist` is defined here
 --> src/lib.rs:3:9
  |
3 |         fn add_to_waitlist() {}
  |         ^^^^^^^^^^^^^^^^^^^^

error[E0603]: function `add_to_waitlist` is private
  --> src/lib.rs:12:30
   |
12 |     front_of_house::hosting::add_to_waitlist();
   |                              ^^^^^^^^^^^^^^^ private function
   |
note: the function `add_to_waitlist` is defined here
  --> src/lib.rs:3:9
   |
3  |         fn add_to_waitlist() {}
   |         ^^^^^^^^^^^^^^^^^^^^

For more information about this error, try `rustc --explain E0603`.
error: could not compile `restaurant` due to 2 previous errors

Листинг 7-6: Ошибки компиляции при сборке кода в листинге 7-5

Что произошло? Добавление ключевого слова pub перед mod hosting сделало модуль публичным. После этого изменения, если мы можем получить доступ к модулю front_of_house, то мы можем доступ к модулю hosting. Но содержимое модуля hosting всё ещё является приватным: превращение модуля в публичный не делает его содержимое публичным. Ключевое слово pub позволяет внешнему коду в модулях предках обращаться только к модулю.

Ошибки в листинге 7-6 говорят, что функция add_to_waitlist является закрытой. Правила конфиденциальности применяются к структурам, перечислениям, функциям и методам, также как и к модулям.

Давайте также сделаем функцию add_to_waitlist общедоступной, добавив ключевое слово pub перед её определением, как показано в листинге 7-7.

Файл: src/lib.rs

mod front_of_house {
    pub mod hosting {
        pub fn add_to_waitlist() {}
    }
}

pub fn eat_at_restaurant() {
    // Absolute path
    crate::front_of_house::hosting::add_to_waitlist();

    // Relative path
    front_of_house::hosting::add_to_waitlist();
}

Листинг 7-7. Добавление ключевого слова pub у модуля mod hosting и функции fn add_to_waitlist позволяет вызывать ранее скрытую функцию из функции eat_at_restaurant

Теперь код компилируется! Давайте посмотрим на абсолютный и относительный путь и перепроверим, почему добавление ключевого слова pub позволяет использовать эти пути в add_to_waitlist с учётом правила конфиденциальности.

В случае абсолютного пути мы начинаем с crate, корня дерева модуля нашего крейта. Затем в корне крейта определён модуль front_of_house. Модуль front_of_house приватный, потому что функция eat_at_restaurant определена в том же модуле, что и front_of_house (то есть eat_at_restaurant и front_of_house являются родственными), мы можем сослаться на front_of_house из eat_at_restaurant. Затем идёт модуль hosting, он также помечен с помощью pub. Мы можем получить доступ к родительскому модулю hosting, по этому hosting также доступен. Наконец функция add_to_waitlist тоже помечена как pub, и в то же время можно получить доступ к её родительскому модулю, значит вызов функции работает!

В случае относительного пути логика совпадает со случаем абсолютного пути, за исключением первого шага: вместо того, чтобы начинать с корня крейта, путь начинается с front_of_house. Модуль front_of_house определён в том же модуле, что и eat_at_restaurant, поэтому относительный путь, начинающийся с модуля в котором определён eat_at_restaurant тоже работает. Тогда, по причине того, что hosting и add_to_waitlist помечены как pub, остальная часть пути работает и вызов функции действителен!

Начинаем относительный путь с помощью super

Также можно построить относительные пути, которые начинаются в родительском модуле, используя ключевое слово super в начале пути. Это похоже на синтаксис начала пути файловой системы ... Зачем нам так делать?

Рассмотрим код в листинге 7-8, который моделирует ситуацию в которой повар исправляет неправильный заказ и лично выдаёт его клиенту. Функция fix_incorrect_order вызывает функцию deliver_order, указывая путь к deliver_order начинающийся со super:

Файл: src/lib.rs

fn deliver_order() {}

mod back_of_house {
    fn fix_incorrect_order() {
        cook_order();
        super::deliver_order();
    }

    fn cook_order() {}
}

Листинг 7-8: Вызов функции с использованием относительного пути, начинающегося со super

Функция fix_incorrect_order находится в модуле back_of_house, поэтому мы можем использовать super для перехода к родительскому модулю back_of_house, который в этом случае является корнем crate. Из корня мы пытаемся найти deliver_order и находим его. Успех! Мы считаем, что модуль back_of_house и функция deliver_order остаются в одинаковых отношениях друг с другом и должны быть перемещены вместе, если мы решим реорганизовать дерево модулей крейта. Поэтому мы использовали super. В итоге, в будущем нам не понадобится обновлять путь до модуля, при перемещении кода в другой модуль.

Делаем публичными структуры и перечисления

Мы также можем использовать pub, чтобы сделать структуры и перечисления публичными, но есть несколько дополнительных деталей. Если используется pub перед определением структуры, то структура становится публичной, но поля структуры все ещё остаются приватными. Делать ли каждое поле публичным или нет решается в каждом конкретном случае. В листинге 7-9 мы определили публичную структуру back_of_house::Breakfast с открытым полем toast, но оставили приватным поле seasonal_fruit. Это моделирует случай в ресторане, когда клиент может выбрать тип хлеба к блюду, но повар решает, какие фрукты сопровождают блюдо на основании того, какой сейчас сезон и что есть на складе. Доступные фрукты быстро меняются, поэтому покупатели не могут выбирать фрукты или даже посмотреть, какие фрукты они получат.

Файл: src/lib.rs

mod back_of_house {
    pub struct Breakfast {
        pub toast: String,
        seasonal_fruit: String,
    }

    impl Breakfast {
        pub fn summer(toast: &str) -> Breakfast {
            Breakfast {
                toast: String::from(toast),
                seasonal_fruit: String::from("peaches"),
            }
        }
    }
}

pub fn eat_at_restaurant() {
    // Order a breakfast in the summer with Rye toast
    let mut meal = back_of_house::Breakfast::summer("Rye");
    // Change our mind about what bread we'd like
    meal.toast = String::from("Wheat");
    println!("I'd like {} toast please", meal.toast);

    // The next line won't compile if we uncomment it; we're not allowed
    // to see or modify the seasonal fruit that comes with the meal
    // meal.seasonal_fruit = String::from("blueberries");
}

Листинг 7-9: Структура с публичными и приватными полями

Поскольку поле toast в структуре back_of_house::Breakfast является открытым, то в функции eat_at_restaurant можно писать и читать поле toast, используя точечную нотацию. Обратите внимание, что мы не можем использовать поле seasonal_fruit в eat_at_restaurant, потому что seasonal_fruit является приватным. Попробуйте убрать комментирование с последней строки для значения поля seasonal_fruit, чтобы увидеть какую ошибку вы получите!

Также обратите внимание, что поскольку back_of_house::Breakfast имеет приватное поле, то структура должна предоставить публичную ассоциированную функцию, которая создаёт экземпляр Breakfast (мы назвали её summer). Если Breakfast не имел бы такой функции, мы бы не могли создать экземпляр Breakfast внутри eat_at_restaurant, потому что мы не смогли бы установить значение приватного поля seasonal_fruit в функции eat_at_restaurant.

В отличии от структуры, если мы сделаем публичным перечисление, то все его варианты будут публичными. Нужно только указать pub перед ключевым словом enum, как в листинге 7-10.

Файл: src/lib.rs

mod back_of_house {
    pub enum Appetizer {
        Soup,
        Salad,
    }
}

pub fn eat_at_restaurant() {
    let order1 = back_of_house::Appetizer::Soup;
    let order2 = back_of_house::Appetizer::Salad;
}

Листинг 7-10. Определяя перечисление публичным мы делаем все его варианты публичными

Поскольку мы сделали публичным список Appetizer, то можно использовать варианты Soup и Salad в функции eat_at_restaurant. Перечисления не очень полезны, если их варианты являются приватными: было бы досадно каждый раз аннотировать все перечисленные варианты как pub. По этой причине по умолчанию варианты перечислений являются публичными. Структуры часто полезны, если их поля не являются открытыми, поэтому поля структуры следуют общему правилу, согласно которому всё по умолчанию является приватными, если не указано pub.

Есть ещё одна ситуация с pub которую мы не освещали, и это последняя особенность модульной системы: ключевое слово use. Мы сначала опишем use само по себе, а затем покажем как сочетать pub и use вместе.

Подключение путей в область видимости с помощью ключевого слова use

Необходимость записывать пути к функциям вызова может показаться неудобной и повторяющейся. В листинге 7-7 независимо от того, выбирали ли мы абсолютный или относительный путь к функции add_to_waitlist , каждый раз, когда мы хотели вызвать add_to_waitlist , нам приходилось также указывать front_of_house и hosting . К счастью, есть способ упростить этот процесс: мы можем один раз создать ярлык пути с помощью ключевого слова use, а затем использовать более короткое имя везде в области видимости.

В листинге 7-11 мы подключили модуль crate::front_of_house::hosting в область действия функции eat_at_restaurant, поэтому нам достаточно только указать hosting::add_to_waitlist для вызова функции add_to_waitlist внутри eat_at_restaurant.

Файл: src/lib.rs

mod front_of_house {
    pub mod hosting {
        pub fn add_to_waitlist() {}
    }
}

use crate::front_of_house::hosting;

pub fn eat_at_restaurant() {
    hosting::add_to_waitlist();
}

Листинг 7-11. Добавление модуля в область видимости с use

Добавление use и пути в область видимости аналогично созданию символической ссылки в файловой системе. Добавляя use crate::front_of_house::hosting в корень крейта, hosting теперь является допустимым именем в этой области, как если бы hosting модуль был определён в корне крейта. Пути подключённые в область видимости с помощью use также проверяют конфиденциальность как и любые другие пути.

Обратите внимание, что объявление use действительно только для той конкретной области, в которой это объявление use и находится. В листинге 7-12 функция eat_at_restaurant перемещается в новый дочерний модуль с именем customer , область действия которого отличается от области действия оператора use , поэтому тело функции не будет компилироваться:

Файл: src/lib.rs

mod front_of_house {
    pub mod hosting {
        pub fn add_to_waitlist() {}
    }
}

use crate::front_of_house::hosting;

mod customer {
    pub fn eat_at_restaurant() {
        hosting::add_to_waitlist();
    }
}

Листинг 7-12. Объявление use действительно только для той области, в которой оно находится.

Ошибка компилятора показывает, что указанное объявление более не действительно для области видимости модуля customer:

$ cargo build
   Compiling restaurant v0.1.0 (file:///projects/restaurant)
error[E0433]: failed to resolve: use of undeclared crate or module `hosting`
  --> src/lib.rs:11:9
   |
11 |         hosting::add_to_waitlist();
   |         ^^^^^^^ use of undeclared crate or module `hosting`

warning: unused import: `crate::front_of_house::hosting`
 --> src/lib.rs:7:5
  |
7 | use crate::front_of_house::hosting;
  |     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  |
  = note: `#[warn(unused_imports)]` on by default

For more information about this error, try `rustc --explain E0433`.
warning: `restaurant` (lib) generated 1 warning
error: could not compile `restaurant` due to previous error; 1 warning emitted

Обратите внимание, что есть также предупреждение о том, что use больше не используется в своей области! Чтобы решить эту проблему, переместите use также в модуль customer или же можно сослаться на родительский модуль с помощью super::hosting в дочернем модуле customer .

Создание идиоматических путей с use

В листинге 7-11 вы могли бы задаться вопросом, почему мы указали use crate::front_of_house::hosting, а затем вызвали hosting::add_to_waitlist внутри eat_at_restaurant вместо указания в use полного пути прямо до функции add_to_waitlist для получения того же результата, что в листинге 7-13.

Файл: src/lib.rs

mod front_of_house {
    pub mod hosting {
        pub fn add_to_waitlist() {}
    }
}

use crate::front_of_house::hosting::add_to_waitlist;

pub fn eat_at_restaurant() {
    add_to_waitlist();
}

Листинг 7-13: Добавление функции add_to_waitlist в область видимости с use неидиоматическим способом

Хотя листинги 7-11 и 7-13 выполняют одну и ту же задачу, листинг 7-11 является идиоматическим способом подключения функции в область видимости с помощью use. Подключение родительского модуля функции в область видимости при помощи use, и последующее указание родительского модуля в строке вызова его функций, даёт ясное понимание того, что эта функция определена не локально, и в то же время всё ещё минимизирует повторение полного пути. В коде листинга 7-13 не ясно, где именно определена add_to_waitlist.

С другой стороны, при подключении структур, перечислений и других элементов используя use, идиоматически правильным будет указывать полный путь. Листинг 7-14 показывает идиоматический способ подключения структуры стандартной библиотеки HashMap в область видимости исполняемого крейта.

Файл: src/main.rs

use std::collections::HashMap;

fn main() {
    let mut map = HashMap::new();
    map.insert(1, 2);
}

Листинг 7-14. Включение HashMap в область видимости идиоматическим способом

За этой идиомой нет веской причины: это просто соглашение, которое появилось само собой. Люди привыкли читать и писать код Rust таким образом.

Исключением из этой идиомы является случай, когда мы подключаем два элемента с одинаковыми именами в область видимости используя оператор use - Rust просто не позволяет этого сделать. Листинг 7-15 показывает, как подключить в область действия два типа с одинаковыми именами Result, но из разных родительских модулей и как на них ссылаться.

Файл: src/lib.rs

use std::fmt;
use std::io;

fn function1() -> fmt::Result {
    // --snip--
    Ok(())
}

fn function2() -> io::Result<()> {
    // --snip--
    Ok(())
}

Листинг 7-15. Для включения двух типов с одинаковыми именами в одну область видимости необходимо использовать их родительские модули.

Как видите, использование имени родительских модулей позволяет различать два типа Result. Если бы вместо этого мы указали use std::fmt::Result и use std::io::Result, мы бы имели два типа Result в одной области видимости, и Rust не смог бы понять какой из двух Result мы имели в виду когда нашёл бы их употребление в коде.

Предоставление новых имён с помощью ключевого слова as

Есть ещё одно решение проблемы объединения двух типов с одинаковыми именами в одной области видимости используя use: после пути можно указать as и новое локальное имя (псевдоним) для типа. Листинг 7-16 показывает другой способ написать код в листинге 7-15 путём переименования одного из двух типов Result используя as.

Файл: src/lib.rs

use std::fmt::Result;
use std::io::Result as IoResult;

fn function1() -> Result {
    // --snip--
    Ok(())
}

fn function2() -> IoResult<()> {
    // --snip--
    Ok(())
}

Листинг 7-16: Переименование типа, когда он включён в область видимости с помощью ключевого слова as

Во втором операторе use мы выбрали новое имя IoResult для типа std::io::Result, которое теперь не будет конфликтовать с типом Result из std::fmt, который также подключён в область видимости. Листинги 7-15 и 7-16 считаются идиоматичными, поэтому выбор за вами!

Реэкспорт имён с pub use

Когда мы подключаем имя в область видимости используя ключевое слово use, то имя доступное в новой области видимости является приватным. Чтобы позволить коду, который вызывает наш код, ссылаться на это имя, как если бы оно было определено в области видимости данного кода, можно объединить pub и use. Этот метод называется реэкспортом (re-exporting), потому что мы подключаем элемент в область видимости, но также делаем этот элемент доступным для подключения в других областях видимости.

Листинг 7-17 показывает код как в листинге 7-11 (где используется use в корневом модуле), но с изменениями: теперь применяется pub use.

Файл: src/lib.rs

mod front_of_house {
    pub mod hosting {
        pub fn add_to_waitlist() {}
    }
}

pub use crate::front_of_house::hosting;

pub fn eat_at_restaurant() {
    hosting::add_to_waitlist();
}

Листинг 7-17. Предоставление имени для использования любым кодом из новой области с pub use

До этого изменения внешний код должен был вызывать функцию add_to_waitlist , используя путь restaurant::front_of_house::hosting::add_to_waitlist() . Теперь, когда это объявление pub use повторно экспортировало модуль hosting из корневого модуля, внешний код теперь может использовать вместо него путь restaurant::hosting::add_to_waitlist() .

Реэкспорт полезен, когда внутренняя структура вашего кода отличается от того, как программисты, вызывающие ваш код, думают о предметной области. Например, по аналогии с рестораном люди, управляющие им, думают о «передней части дома» и «задней части дома». Но клиенты, посещающие ресторан, вероятно, не будут думать о частях ресторана в таких терминах. Используя pub use , мы можем написать наш код с одной структурой, но выставить другую структуру. Благодаря этому наша библиотека хорошо организована для программистов, работающих над библиотекой, и для программистов, вызывающих библиотеку. Мы рассмотрим ещё один пример pub use и его влияние на документацию вашего крейта в разделе «Экспорт удобного общедоступного API с pub use » . главы 14.

Использование внешних пакетов

В Главе 2 мы запрограммировали игру угадывания числа, где использовался внешний пакет для получения случайного числа, называемый rand. Чтобы использовать в нашем проекте пакет rand, мы добавили строку в Cargo.toml:

Файл: Cargo.toml

rand = "0.8.3"

Добавление rand в качестве зависимости в Cargo.toml указывает Cargo загрузить пакет rand и любые требующиеся для работы этого пакета зависимости из crates.io и сделать rand доступным для нашего проекта.

Затем, чтобы подключить определения rand в область видимости нашего пакета, мы добавили строку use начинающуюся с названия пакета rand и списка элементов, которые мы хотим подключить в область видимости. Напомним, что в разделе "Генерация случайного числа" Главы 2, мы подключили типаж Rng в область видимости и вызвали функцию rand::thread_rng:

use std::io;
use rand::Rng;

fn main() {
    println!("Guess the number!");

    let secret_number = rand::thread_rng().gen_range(1..=100);

    println!("The secret number is: {secret_number}");

    println!("Please input your guess.");

    let mut guess = String::new();

    io::stdin()
        .read_line(&mut guess)
        .expect("Failed to read line");

    println!("You guessed: {guess}");
}

Члены сообщества Rust сделали много пакетов доступными на ресурсе crates.io, и добавление любого из них в свой пакет включает в себя одни и те же шаги: добавить пакет в файл Cargo.toml вашего пакета, использовать use для подключения элементов этого пакета в область видимости.

Обратите внимание, что стандартная библиотека std также является крейтом, внешним по отношению к нашему пакету. Поскольку стандартная библиотека поставляется с языком Rust, нам не нужно изменять Cargo.toml для включения std . Но нам нужно ссылаться на него с use , чтобы перенести элементы оттуда в область действия нашего пакета. Например, с HashMap мы использовали бы эту строку:


#![allow(unused)]
fn main() {
use std::collections::HashMap;
}

Это абсолютный путь, начинающийся с std, имени крейта стандартной библиотеки.

Использование вложенных путей для уменьшения длинных списков use

Если мы используем несколько элементов определённых в одном пакете или в том же модуле, то перечисление каждого элемента в отдельной строке может занимать много вертикального пространства в файле. Например, эти два объявления use используются в программе угадывания числа (листинг 2-4) для подключения элементов из std в область видимости:

Файл: src/main.rs

use rand::Rng;
// --snip--
use std::cmp::Ordering;
use std::io;
// --snip--

fn main() {
    println!("Guess the number!");

    let secret_number = rand::thread_rng().gen_range(1..=100);

    println!("The secret number is: {secret_number}");

    println!("Please input your guess.");

    let mut guess = String::new();

    io::stdin()
        .read_line(&mut guess)
        .expect("Failed to read line");

    println!("You guessed: {guess}");

    match guess.cmp(&secret_number) {
        Ordering::Less => println!("Too small!"),
        Ordering::Greater => println!("Too big!"),
        Ordering::Equal => println!("You win!"),
    }
}

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

Файл: src/main.rs

use rand::Rng;
// --snip--
use std::{cmp::Ordering, io};
// --snip--

fn main() {
    println!("Guess the number!");

    let secret_number = rand::thread_rng().gen_range(1..=100);

    println!("The secret number is: {secret_number}");

    println!("Please input your guess.");

    let mut guess = String::new();

    io::stdin()
        .read_line(&mut guess)
        .expect("Failed to read line");

    let guess: u32 = guess.trim().parse().expect("Please type a number!");

    println!("You guessed: {guess}");

    match guess.cmp(&secret_number) {
        Ordering::Less => println!("Too small!"),
        Ordering::Greater => println!("Too big!"),
        Ordering::Equal => println!("You win!"),
    }
}

Листинг 7-18. Указание вложенного пути для переноса нескольких элементов с одинаковым префиксом в область видимости

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

Можно использовать вложенный путь на любом уровне, что полезно при объединении двух операторов use, которые имеют общую часть пути. Например, в листинге 7-19 показаны два оператора use: один подключает std::io, другой подключает std::io::Write в область видимости.

Файл: src/lib.rs

use std::io;
use std::io::Write;

Листинг 7-19: Два оператора use , где один является частью другого

Общей частью этих двух путей является std::io, и это полный первый путь. Чтобы объединить эти два пути в одно выражение use, мы можем использовать ключевое слово self во вложенном пути, как показано в листинге 7-20.

Файл: src/lib.rs

use std::io::{self, Write};

Листинг 7-20: Объединение путей из Листинга 7-19 в один оператор use

Эта строка подключает std::io и std::io::Write в область видимости.

Оператор * (Glob)

Если мы хотим включить в область видимости все общедоступные элементы, определённые в пути, мы можем указать этот путь, за которым следует оператор * glob:


#![allow(unused)]
fn main() {
use std::collections::*;
}

Этот оператор use подключает все открытые элементы из модуля std::collections в текущую область видимости. Будьте осторожны при использовании оператора *! Он может усложнить понимание, какие имена находятся в области видимости и где были определены имена, используемые в вашей программе.

Оператор * часто используется при тестировании для подключения всего что есть в модуле tests; мы поговорим об этом в разделе "Как писать тесты" Главы 11. Оператор * также иногда используется как часть шаблона автоматического импорта (prelude): смотрите документацию по стандартной библиотеке для получения дополнительной информации об этом шаблоне.

Разделение модулей на разные файлы

Пока что все примеры в этой главе определяли множество модулей в одном файле. Когда модули становятся большими, можно переместить их определения в отдельный файл, чтобы сделать код проще.

Например, давайте начнём с кода листинга 7-17 и первым шагом переместим модуль front_of_house в свой собственный файл src/front_of_house.rs, изменив корневой файл крейта так, чтобы он содержал код показанный в листинге 7-21. В этом случае, корневым файлом крейта является src/lib.rs, но эта процедура также работает с исполняемыми крейтами у которых корневой файл крейта src/main.rs.

Файл: src/lib.rs

mod front_of_house;

pub use crate::front_of_house::hosting;

pub fn eat_at_restaurant() {
    hosting::add_to_waitlist();
}

Листинг 7-21. Добавление в корневой файл крейта тела модуля front_of_house (которое далее будет вынесено в src/front_of_house.rs)

И на втором шаге в содержимом src/front_of_house.rs определим тело модуля front_of_house (которое мы изъяли из src/lib.rs), как показано в листинге 7-22.

Файл: src/front_of_house.rs

pub mod hosting {
    pub fn add_to_waitlist() {}
}

Листинг 7-22. Определения тела модуля front_of_house в файле src/front_of_house.rs

Использование точки с запятой после mod front_of_house, вместо объявления начала блока, говорит Rust загрузить содержимое модуля из другого файла имеющего такое же название как и имя модуля. Продолжим наш пример и выделим модуль hosting в отдельный файл, а затем поменяем содержимое файла src/front_of_house.rs так, чтобы он содержал только объявление модуля hosting:

Файл: src/front_of_house.rs

pub mod hosting;

Затем мы создаём каталог src/front_of_house и файл src/front_of_house/hosting.rs в данной директории. Чтобы вынести модуль мы, так же как и ранее, должны выделить содержимое модуля hosting из прежнего места и перенести его в свой файл модуля hosting.rs:

Файл: src/front_of_house/hosting.rs


#![allow(unused)]
fn main() {
pub fn add_to_waitlist() {}
}

Дерево модулей остаётся прежним, а вызовы функций в eat_at_restaurant будут работать без каких-либо изменений, даже если определения будут в разных файлах. Этот метод позволяет перемещать модули в новые файлы по мере их разрастания.

Обратите внимание на то, что выражение pub use crate::front_of_house::hosting в файле src/lib.rs не претерпело каких-либо изменений после переноса модулей в отдельные файлы. В то же время благодаря этому use не добавило какого-либо влияния на то какие файлы будут скомпилированы как часть крейта. Ключевое слово mod объявляет модули, а Rust просматривает файл с тем же именем, что и модуль: так он определяет код, который входит в этот модуль.

Итог

Rust позволяет разбить пакет на несколько крейтов и крейт на модули, так что вы можете ссылаться на элементы определённые в одном модуле из другого модуля. Это можно делать при помощи указания абсолютных или относительных путей. Пути можно подключить в область видимости оператором use, поэтому вы можете пользоваться более короткими путями для многократного использования элементов в области видимости. Код модуля по умолчанию является приватным, но можно сделать определения публичными, добавив ключевое слово pub.

В следующей главе мы познакомим вас с некоторыми коллекциями (особыми структурами данных) представленными в стандартной библиотеке. Завершив их изучение вы сможете использовать их в своём аккуратно организованном коде.

Коллекции стандартной библиотеки

Стандартная библиотека содержит несколько полезных структур данных, которые называются коллекциями. Большая часть других типов данных представляют собой хранение конкретного значения, но особенностью коллекций является хранение множества однотипных значений. В отличии от массива или кортежа данные коллекций хранятся в куче, а это значит, что размер коллекции может быть неизвестен в момент компиляции программы. Он может изменяться (увеличиваться, уменьшаться) во время работы программы. Каждый вид коллекций имеет свои возможности и отличается по производительности, так что выбор конкретной коллекции зависит от ситуации и является умением разработчика, вырабатываемым со временем. В этой главе будет рассмотрено несколько коллекций:

  • Вектор (vector) - позволяет нам сохранять различное количество последовательно хранящихся значений,
  • Строка (string) - это последовательность символов. Мы же упоминали тип String ранее, но в данной главе мы поговорим о нем подробнее.
  • Хеш таблица (hash map) - коллекция которая позволяет хранить перечень ассоциаций значения с ключом (перечень пар ключ:значение). Является конкретной реализацией более общей структуры данных называемой map.

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

Мы обсудим как создавать и обновлять вектора, строки и хеш таблицы, а также объясним что делает каждую из них особенной.

Хранение списков значений в векторах

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

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

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

fn main() {
    let v: Vec<i32> = Vec::new();
}

Листинг 8-1: Создание нового пустого вектора для хранения значений типа i32

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

Чаще всего вы будете создавать Vec<T> с начальными значениями и Rust может определить тип сохраняемых вами значений, но иногда вам всё же придётся указывать аннотацию типа. Для удобства Rust предоставляет макрос vec!, который создаст новый вектор, содержащий заданные вами значения. В листинге 8-2 создаётся новый Vec<i32>, который будет хранить значения 1, 2 и 3. Целочисленный тип — i32, потому что это целочисленный тип по умолчанию, как мы обсуждали в разделе «Типы данных» главы 3. Числовым типом является i32, потому что это тип по умолчанию для целочисленных значений, о чём упоминалось в разделе “Типы данных” Главы 3.

fn main() {
    let v = vec![1, 2, 3];
}

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

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

Изменение вектора

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

fn main() {
    let mut v = Vec::new();

    v.push(5);
    v.push(6);
    v.push(7);
    v.push(8);
}

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

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

Чтение данных вектора

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

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

fn main() {
    let v = vec![1, 2, 3, 4, 5];

    let third: &i32 = &v[2];
    println!("The third element is {}", third);

    let third: Option<&i32> = v.get(2);
    match third {
        Some(third) => println!("The third element is {}", third),
        None => println!("There is no third element."),
    }
}

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

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

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

fn main() {
    let v = vec![1, 2, 3, 4, 5];

    let does_not_exist = &v[100];
    let does_not_exist = v.get(100);
}

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

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

Когда методу get передаётся индекс, который находится за пределами вектора, он без паники возвращает None. Вы могли бы использовать такой подход, если доступ к элементу за пределами диапазона вектора происходит время от времени при нормальных обстоятельствах. Тогда ваш код будет иметь логику для обработки наличия Some(&element) или None, как обсуждалось в Главе 6. Например, индекс может исходить от человека, вводящего число. Если пользователь случайно введёт слишком большое число, то программа получит значение None и у вас будет возможность сообщить пользователю, сколько элементов находится в текущем векторе, и дать ему возможность ввести допустимое значение. Такое поведение было бы более дружелюбным для пользователя, чем внезапный сбой программы из-за опечатки!

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

fn main() {
    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` due to previous error

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

Примечание: Дополнительные сведения о реализации типа Vec<T> смотрите в разделе "The Rustonomicon".

Перебор значений в векторе

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

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

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

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

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

Листинг 8-8. Итерирование и изменение элементов вектора по изменяемым ссылкам

Чтобы изменить значение на которое ссылается изменяемая ссылка, мы должны использовать оператор разыменования ссылки * для получения значения по ссылке в переменной i прежде чем использовать оператор +=. Мы поговорим подробнее об операторе разыменования в разделе “Следование по указателю к значению с помощью оператора разыменования” Главы 15.

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

Использование перечислений для хранения множества разных типов

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

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

fn main() {
    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: Определение enum для хранения значений разных типов в одном векторе

Rust должен знать, какие типы будут в векторе во время компиляции, чтобы точно знать сколько памяти в куче потребуется для хранения каждого элемента. Мы также должны чётко указать, какие типы разрешены в этом векторе. Если бы Rust позволял вектору содержать любой тип, то был бы шанс что один или несколько типов вызовут ошибки при выполнении операций над элементами вектора. Использование перечисления вместе с выражением match означает, что во время компиляции Rust гарантирует, что все возможные случаи будут обработаны, как обсуждалось в главе 6.

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

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

Удаление элементов из вектора

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

fn main() {
    {
        let v = vec![1, 2, 3, 4];

        // do stuff with v
    } // <- v goes out of scope and is freed here
}

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

Когда вектор удаляется, всё его содержимое также удаляется: удаление вектора означает и удаление значений, которые он содержит. Средство проверки заимствования гарантирует, что любые ссылки на содержимое вектора используются только тогда, когда сам вектор действителен.

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

Сохранение текста с 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`

For more information about this error, try `rustc --explain E0277`.
error: could not compile `collections` due to 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 байта. Каждая из этих букв занимает 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 `Здравствуйте`', library/core/src/str/mod.rs:127:5
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!

Хранение ключей со связанными значениями в HashMap

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

Хеш-карты полезны, когда нужно искать данные не используя индекс, как это например делается в векторах, а с помощью ключа, который может быть любого типа. Например, в игре вы можете отслеживать счёт каждой команды в хеш-карте, в которой каждый ключ - это название команды, а значение - счёт команды. Имея имя команды, вы можете получить её счёт из хеш-карты.

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

Создание новой хеш-карты

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

fn main() {
    use std::collections::HashMap;

    let mut scores = HashMap::new();

    scores.insert(String::from("Blue"), 10);
    scores.insert(String::from("Yellow"), 50);
}

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

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

Подобно векторам, хеш-карты хранят свои данные в куче. Здесь тип HashMap имеет в качестве типа ключей String, а в качестве типа значений тип i32. Как и векторы, HashMap однородны: все ключи должны иметь одинаковый тип и все значения должны иметь тоже одинаковый тип.

Ещё один способ построения хеш-карты - использование метода collect на векторе кортежей, где каждый кортеж состоит из двух значений (первое может быть представлено как ключ, а второе как значение хеш-карты). Метод collect собирает данные в несколько типов коллекций, включая HashMap . Например, если бы у нас были названия команд и начальные результаты в двух отдельных векторах, то мы могли бы использовать метод zip для создания вектора кортежей, где имя "Blue" спарено с числом 10, и так далее. Тогда мы могли бы использовать метод collect, чтобы превратить этот вектор кортежей в HashMap, как показано в листинге 8-21.

fn main() {
    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);
}

Листинг 8-21. Создание HashMap из списка команд и списка результатов

Здесь нужна аннотация типа HashMap<_, _> , поскольку с помощью метода collect данные можно собрать во множество различных структур данных и Rust не знает, в какую именно вы хотите собрать, пока вы не укажете это явно. Для параметров типа ключа и значения, мы используем подчёркивания и Rust может вывести типы, которые хеш содержит на основе типов данных из двух векторов. В листинге 8-21, тип ключа будет String, а тип значения будет i32, так же как в листинге 8-20.

Хеш-карты и владение

Для типов, которые реализуют типаж Copy, например i32, значения копируются в HashMap. Для значений со владением, таких как String, значения будут перемещены в хеш-карту и она станет владельцем этих значений, как показано в листинге 8-22.

fn main() {
    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 and field_value are invalid at this point, try using them and
    // see what compiler error you get!
}

Листинг 8-22. Показывает, что ключи и значения находятся во владении HashMap, как только они были вставлены

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

Если мы вставим в HashMap ссылки на значения, то они не будут перемещены в HashMap. Значения, на которые указывают ссылки, должны быть действительными хотя бы до тех пор, пока хеш-карта действительна. Мы поговорим об этих вопросах подробнее в разделе "Проверка ссылок с помощью времени жизни" главы 10.

Доступ к данным в HashMap

Мы можем получить значение из HashMap по ключу, с помощью метода get, как показано в листинге 8-23:

fn main() {
    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" сохранённой в HashMap

Здесь score будет иметь количество очков, связанное с командой "Blue", результат будет Some(&10). Результат обёрнут в вариант перечисления Some потому что get возвращает Option<&V>; если для этого ключа нет значения в HashMap, get вернёт None. Из-за такого подхода программе следует обрабатывать Option, например одним из способов, которые мы рассмотрели в Главе 6.

Мы можем перебирать каждую пару ключ/значение в HashMap таким же образом, как мы делали с векторами, используя цикл for:

fn main() {
    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

Обновление данных

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

Перезапись старых значений

Если мы вставим ключ и значение в HashMap, а затем вставим тот же ключ с новым значением, то старое значение связанное с этим ключом, будет заменено на новое. Даже несмотря на то, что код в листинге 8-24 вызывает insert дважды, хеш-карта будет содержать только одну пару ключ/значение, потому что мы вставляем значения для одного и того же ключа - ключа команды "Blue".

fn main() {
    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. Замена значения, хранимого в конкретном ключе

Код напечатает {"Blue": 25}. Начальное значение 10 было перезаписано.

Вставка значения только в том случае, когда ключ не имеет значения

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

fn main() {
    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. Использование метода entry для вставки значения только в том случае, когда ключ не имеет значения

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

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

Создание нового значения на основе старого значения

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


#![allow(unused)]
fn main() {
{{#rustdoc_include ../listings/ch08-common-collections/listing-08-26/src/main.rs:here}}
}

Листинг 8-26. Подсчёт вхождений слов с использованием хеш-карты, которая хранит слова и количество их упоминаний в тексте

Будет напечатано {"world": 2, "hello": 1, "wonderful": 1}. Метод or_insert возвращает изменяемую ссылку (&mut V) на значение ключа. Мы сохраняем изменяемую ссылку в переменной count. Для того, чтобы присвоить переменной значение, необходимо произвести разыменование с помощью звёздочки (*). Изменяемая ссылка удаляется сразу же после выхода из области видимости цикла for. Все эти изменения безопасны и согласуются с правилами заимствования.

Функция хэширования

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

Итоги

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

  • Есть список целых чисел. Создайте функцию, используйте вектор и верните из списка: среднее значение; медиану (значение элемента из середины списка после его сортировки); моду списка (mode of list, то значение которое встречается в списке наибольшее количество раз; HashMap будет полезна в данном случае)
  • Преобразуйте строку в кодировку "поросячьей латыни" (Pig Latin), где первая согласная каждого слова перемещается в конец и к ней добавляется окончание "ay". Например "first" в поросячьей латыни станет "irst-fay". Если слово начинается на гласную, то в конец слова добавляется суффикс "hay" ("apple" становится "apple-hay"). Помните о деталях работы с кодировкой UTF-8!
  • Используя хеш-карту и векторы, создайте текстовый интерфейс позволяющий пользователю добавлять имена сотрудников к названию отдела компании. Например, "Add Sally to Engineering" или "Add Amir to Sales". Затем позвольте пользователю получить список всех людей из отдела или всех людей в компании отсортированным в алфавитном порядке по отделам.

Документация API стандартной библиотеки описывает методы у векторов, строк и HashMap. Рекомендуем воспользоваться ей при решении упражнений.

Потихоньку мы переходим к более сложным программам, в которых операции могут потерпеть неудачу. Наступило идеальное время для обсуждения обработки ошибок.

Обработка ошибок

Возникновение ошибок в ходе выполнения программ - это суровая реальность в жизни программного обеспечения, поэтому Rust имеет ряд функций для обработки ситуаций в которых что-то идёт не так. Во многих случаях Rust требует, чтобы вы признали возможность ошибки и предприняли некоторые действия, прежде чем ваш код будет скомпилирован. Это требование делает вашу программу более надёжной, гарантируя, что вы обнаружите ошибки и обработаете их надлежащим образом, прежде чем развернёте свой код в производственной среде!

В Rust ошибки группируются на две основные категории исправимые (recoverable) и неисправимые (unrecoverable). В случае исправимой ошибки, такой как файл не найден, мы, скорее всего, просто хотим сообщить о проблеме пользователю и повторить операцию. Неисправимые ошибки всегда являются симптомами дефектов в коде, например, попытка доступа к ячейке за пределами границ массива, и поэтому мы хотим немедленно остановить программу.

Большинство языков не различают эти два вида ошибок и обрабатывают оба вида одинаково, используя такие механизмы, как исключения. В Rust нет исключений. Вместо этого он имеет тип Result<T, E> для обрабатываемых (исправимых) ошибок и макрос panic!, который останавливает выполнение, когда программа встречает необрабатываемую (неисправимую) ошибку. Сначала эта глава расскажет про вызов panic!, а потом расскажет о возврате значений Result<T, E>. Кроме того, мы рассмотрим, что нужно учитывать при принятии решения о том, следует ли попытаться исправить ошибку или остановить выполнение.

Неустранимые ошибки с макросом panic!

Иногда в коде происходят плохие вещи, и вы ничего не можете с этим поделать. В этих случаях у Rust есть макрос panic!. Когда выполнится макрос panic!, ваша программа напечатает сообщение об ошибке, раскрутит и очистит стек вызовов, а затем завершится. Мы обычно вызываем панику, когда обнаруживается какая-либо ошибка, и неясно, как справиться с проблемой во время написания нашей программы.

Раскручивать стек или прерывать выполнение программы в ответ на панику?

По умолчанию, когда происходит паника, программа начинает процесс раскрутки стека, означающий в Rust проход обратно по стеку вызовов и очистку данных для каждой обнаруженной функции. Тем не менее, этот обратный проход по стеку и очистка генерируют много работы. Альтернативой является немедленное прерывание выполнения, которое завершает программу без очистки. Память, которую использовала программа, должна быть очищена операционной системой. Если в вашем проекте нужно сделать маленьким исполняемый файл, насколько это возможно, вы можете переключиться с варианта раскрутки стека на вариант прерывания при панике, добавьте panic = 'abort' в раздел [profile] вашего Cargo.toml файла. Например, если вы хотите прерывать выполнение программы по панике в релизной версии программы добавьте следующее:

[profile.release]
panic = 'abort'

Давайте попробуем вызвать panic! в простой программе:

Файл: src/main.rs

fn main() {
    panic!("crash and burn");
}

При запуске программы, вы увидите что-то вроде этого:

$ cargo run
   Compiling panic v0.1.0 (file:///projects/panic)
    Finished dev [unoptimized + debuginfo] target(s) in 0.25s
     Running `target/debug/panic`
thread 'main' panicked at 'crash and burn', src/main.rs:2:5
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

Выполнение макроса panic! вызывает сообщение об ошибке, содержащееся в двух последних строках. Первая строка показывает сообщение паники и место в исходном коде, где возникла паника: src/main.rs: 2:5 указывает, что это вторая строка, пятый символ внутри нашего файла src/main.rs

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

Использование обратной трассировки panic!

Давайте посмотрим на другой пример, где, вызов panic! происходит в сторонней библиотеке из-за ошибки в нашем коде (а не как в примере ранее, из-за вызова макроса нашим кодом напрямую). В листинге 9-1 приведён код, который пытается получить доступ по индексу в векторе за пределами допустимого диапазона значений индекса.

Файл: src/main.rs

fn main() {
    let v = vec![1, 2, 3];

    v[99];
}

Листинг 9-1. Попытка доступа к элементу за пределами вектора, которая вызовет panic!

Здесь мы пытаемся получить доступ к 100-му элементу вектора (который находится по индексу 99, потому что индексирование начинается с нуля), но вектор имеет только 3 элемента. В этой ситуации, Rust будет вызывать панику. Использование [] должно возвращать элемент, но вы передаёте неверный индекс: не существует элемента, который Rust мог бы вернуть.

В языке C, например, попытка прочесть за пределами конца структуры данных (в нашем случае векторе) приведёт к неопределённому поведению, undefined behavior, UB. Вы всё равно получите значение, которое находится в том месте памяти компьютера, которое соответствовало бы этому элементу в векторе, несмотря на то, что память по тому адресу совсем не принадлежит вектору (всё просто: C рассчитал бы место хранения элемента с индексом 99 и считал бы то, что там хранится, упс). Это называется чтением за пределом буфера, buffer overread, и может привести к уязвимостям безопасности. Если злоумышленник может манипулировать индексом таким образом, то у него появляется возможность читать данные, которые он не должен иметь возможности читать.

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

$ cargo run
   Compiling panic v0.1.0 (file:///projects/panic)
    Finished dev [unoptimized + debuginfo] target(s) in 0.27s
     Running `target/debug/panic`
thread 'main' panicked at 'index out of bounds: the len is 3 but the index is 99', src/main.rs:4:5
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

Следующая строка говорит, что мы можем установить переменную среды RUST_BACKTRACE, чтобы получить обратную трассировку того, что именно стало причиной ошибки. Обратная трассировка создаёт список всех функций, которые были вызваны до какой-то определённой точки выполнения программы. Обратная трассировка в Rust работает так же, как и в других языках. По этому предлагаем вам читать данные обратной трассировки как и везде - читать сверху вниз, пока не увидите информацию о файлах написанных вами. Это место, где возникла проблема. Другие строки, которые выше над строками с упоминанием наших файлов, - это код, который вызывается нашим кодом; строки ниже являются кодом, который вызывает наш код. Эти строки могут включать основной код Rust, код стандартной библиотеки или используемые крейты. Давайте попробуем получить обратную трассировку с помощью установки переменной среды RUST_BACKTRACE в любое значение, кроме 0. Листинг 9-2 показывает вывод, подобный тому, что вы увидите.

$ RUST_BACKTRACE=1 cargo run
thread 'main' panicked at 'index out of bounds: the len is 3 but the index is 99', src/main.rs:4:5
stack backtrace:
   0: rust_begin_unwind
             at /rustc/7eac88abb2e57e752f3302f02be5f3ce3d7adfb4/library/std/src/panicking.rs:483
   1: core::panicking::panic_fmt
             at /rustc/7eac88abb2e57e752f3302f02be5f3ce3d7adfb4/library/core/src/panicking.rs:85
   2: core::panicking::panic_bounds_check
             at /rustc/7eac88abb2e57e752f3302f02be5f3ce3d7adfb4/library/core/src/panicking.rs:62
   3: <usize as core::slice::index::SliceIndex<[T]>>::index
             at /rustc/7eac88abb2e57e752f3302f02be5f3ce3d7adfb4/library/core/src/slice/index.rs:255
   4: core::slice::index::<impl core::ops::index::Index<I> for [T]>::index
             at /rustc/7eac88abb2e57e752f3302f02be5f3ce3d7adfb4/library/core/src/slice/index.rs:15
   5: <alloc::vec::Vec<T> as core::ops::index::Index<I>>::index
             at /rustc/7eac88abb2e57e752f3302f02be5f3ce3d7adfb4/library/alloc/src/vec.rs:1982
   6: panic::main
             at ./src/main.rs:4
   7: core::ops::function::FnOnce::call_once
             at /rustc/7eac88abb2e57e752f3302f02be5f3ce3d7adfb4/library/core/src/ops/function.rs:227
note: Some details are omitted, run with `RUST_BACKTRACE=full` for a verbose backtrace.

Листинг 9-2. Обратная трассировка, сгенерированная вызовом panic!, когда установлена переменная окружения RUST_BACKTRACE

Тут много вывода! Вывод, который вы увидите, может отличаться от представленного, в зависимости от вашей операционной системы и версии Rust. Для того, чтобы получить обратную трассировку с этой информацией, должны быть включены символы отладки, debug symbols. Символы отладки включены по умолчанию при использовании cargo build или cargo run без флага --release, как у нас в примере.

В выводе обратной трассировки листинга 9-2, строка #6 указывает на строку в нашем проекте, которая вызывала проблему: строка 4 из файла src/main.rs. Если мы не хотим, чтобы наша программа запаниковала, мы должны начать исследование с места, на которое указывает первая строка с упоминанием нашего файла. В листинге 9-1, где мы для демонстрации обратной трассировки сознательно написали код, который паникует, способ исправления паники состоит в том, чтобы не запрашивать элемент за пределами диапазона значений индексов вектора. Когда ваш код запаникует в будущем, вам нужно будет выяснить, какое выполняющееся кодом действие, с какими значениями вызывает панику и что этот код должен делать вместо этого.

Мы вернёмся к обсуждению макроса panic!, и того когда нам следует и не следует использовать panic! для обработки ошибок в разделе "panic! или НЕ panic!" этой главы. Далее мы рассмотрим, как восстановить выполнение программы после исправляемых ошибок, использующих тип Result.

Исправимые ошибки с Result

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

Вспомните раздел "Обработка потенциального сбоя с помощью типа Result" главы 2: мы использовали там перечисление Result, имеющее два варианта, Ok и Err для обработки сбоев. Само перечисление определено следующим образом:


#![allow(unused)]
fn main() {
enum Result<T, E> {
    Ok(T),
    Err(E),
}
}

Типы T и E являются параметрами обобщённого типа: мы обсудим обобщённые типы более подробно в Главе 10. Все что вам нужно знать прямо сейчас - это то, что T представляет тип значения, которое будет возвращено в случае успеха внутри варианта Ok, а E представляет тип ошибки, которая будет возвращена при сбое внутри варианта Err. Так как тип Result имеет эти обобщённые параметры (generic type parameters), мы можем использовать тип Result и функции, которые определены для него, в разных ситуациях, когда тип успешного значение и значения ошибки, которые мы хотим вернуть, отличаются.

Давайте вызовем функцию, которая возвращает значение Result, потому что может потерпеть неудачу. В листинге 9-3 мы пытаемся открыть файл.

Файл: src/main.rs

use std::fs::File;

fn main() {
    let greeting_file_result = File::open("hello.txt");
}

Листинг 9-3: Открытие файла

Откуда мы знаем, что File::open возвращает Result? Мы могли бы посмотреть документацию по API стандартной библиотеки или мы могли бы спросить компилятор! Если мы припишем переменной f тип, отличный от возвращаемого типа функции, а затем попытаемся скомпилировать код, компилятор скажет нам, что типы не совпадают. Сообщение об ошибке подскажет нам, каким должен быть тип f. Давайте попробуем! Мы знаем, что возвращаемый тип File::open не является типом u32, поэтому давайте изменим выражение let f на следующее:

{{#rustdoc_include ../listings/ch09-error-handling/no-listing-02-ask-compiler-for-type/src/main.rs:here}}

Попытка компиляции выводит сообщение:

{{#include ../listings/ch09-error-handling/no-listing-02-ask-compiler-for-type/output.txt}}

Ошибка говорит нам о том, что возвращаемым типом функции File::open является Result<T, E>. Типовой параметр T здесь равен типу успешного выполнения, std::fs::File, то есть дескриптору файла. Тип E, используемый в значении ошибки, равен std::io::Error.

Этот возвращаемый тип означает, что вызов File::open может завершиться успешно и вернуть дескриптор файла, с помощью которого можно читать из файла или писать в него. Вызов функции также может завершиться ошибкой: например, файла может не существовать или у нас может не быть прав на доступ к нему. Функция File::open должна иметь способ сообщить нам, был ли её вызов успешен или потерпел неудачу и одновременно возвратить либо дескриптор файла либо информацию об ошибке. Эта информация - именно то, что возвращает перечисление Result.

Когда вызов File::open успешен, значение в переменной f будет экземпляром Ok, внутри которого содержится дескриптор файла. Если вызов не успешный, значением переменной f будет экземпляр Err, который содержит больше информации о том, какая ошибка произошла.

Необходимо дописать в код листинга 9-3 выполнение разных действий в зависимости от значения, которое вернёт вызов File::open. Листинг 9-4 показывает один из способов обработки Result - пользуясь базовым инструментом языка, таким как выражение match, рассмотренным в Главе 6.

Файл: src/main.rs

use std::fs::File;

fn main() {
    let greeting_file_result = File::open("hello.txt");

    let greeting_file = match greeting_file_result {
        Ok(file) => file,
        Err(error) => panic!("Problem opening the file: {:?}", error),
    };
}

Листинг 9-4: Использование выражения match для обработки возвращаемых вариантов типа Result

Обратите внимание, что также как перечисление Option, перечисление Result и его варианты, входят в область видимости благодаря авто-импорту (prelude), поэтому не нужно указывать Result:: перед использованием вариантов Ok и Err в ветках выражения match.

Когда результат - это Ok, то надо вернуть внутреннее значение file из варианта Ok, и затем мы присваиваем это значение дескриптора файла переменной f. После match мы можем использовать дескриптор файла для чтения или записи.

Другая ветвь match обрабатывает случай, где мы получаем значение Err после вызова File::open. В этом примере мы решили вызвать макрос panic!. Если в нашей текущей директории нет файла с именем hello.txt и мы выполним этот код, то мы увидим следующее сообщение от макроса panic!:

$ cargo run
   Compiling error-handling v0.1.0 (file:///projects/error-handling)
    Finished dev [unoptimized + debuginfo] target(s) in 0.73s
     Running `target/debug/error-handling`
thread 'main' panicked at 'Problem opening the file: Os { code: 2, kind: NotFound, message: "No such file or directory" }', src/main.rs:8:23
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

Как обычно, данное сообщение точно говорит, что пошло не так.

Обработка различных ошибок с помощью match

Код в листинге 9-4 будет вызывать panic! независимо от того, почему вызов File::open не удался. Однако мы хотим предпринять различные действия для разных причин сбоя. Если открытие File::open не удалось из-за отсутствия файла, мы хотим создать файл и вернуть его дескриптор. Если вызов File::open не удался по любой другой причине - например, потому что у нас не было прав на открытие файла, то все равно мы хотим вызвать panic! как у нас сделано в листинге 9-4. Для этого мы добавляем выражение внутреннего match, показанное в листинге 9-5.

Файл: src/main.rs

use std::fs::File;
use std::io::ErrorKind;

fn main() {
    let greeting_file_result = File::open("hello.txt");

    let greeting_file = match greeting_file_result {
        Ok(file) => file,
        Err(error) => match error.kind() {
            ErrorKind::NotFound => match File::create("hello.txt") {
                Ok(fc) => fc,
                Err(e) => panic!("Problem creating the file: {:?}", e),
            },
            other_error => {
                panic!("Problem opening the file: {:?}", other_error);
            }
        },
    };
}

Листинг 9-5: Обработка различных ошибок разными способами

Типом значения возвращаемого функцией File::open внутри Err варианта является io::Error, структура из стандартной библиотеки. Данная структура имеет метод kind, который можно вызвать для получения значения io::ErrorKind. Перечисление io::ErrorKind из стандартной библиотеки имеет варианты, представляющие различные типы ошибок, которые могут появиться при выполнении операций в io (крейте который занимается проблемами ввода/вывода данных). Вариант, который мы хотим использовать, это ErrorKind::NotFound. Он даёт информацию, о том, что файл который мы пытаемся открыть ещё не существует. Итак, во второй строке мы вызываем сопоставление шаблона с переменной f и попадаем в ветку с обработкой ошибки, но также у нас есть внутренняя проверка для сопоставления error.kind() ошибки.

Условие, которое мы хотим проверить во внутреннем match, заключается в том, является ли значение, возвращаемое error.kind(), вариантом NotFound перечисления ErrorKind. Если это так, мы пытаемся создать файл с помощью функции File::create. Однако, поскольку вызов File::create тоже может завершиться ошибкой, нам нужна обработка ещё одной ошибки, теперь уже во внутреннем выражении match. Заметьте: если файл не может быть создан, выводится другое, специализированное сообщение об ошибке. Вторая же ветка внешнего match (который обрабатывает вызов error.kind()), остаётся той же самой - в итоге программа паникует при любой ошибке, кроме ошибки отсутствия файла.

Альтернативы использованию match с Result<T, E>

Как много match! Выражение match является очень полезным, но в то же время довольно примитивным. В главе 13 вы узнаете о замыканиях (closures), которые используются во многих методах типа Result<T, E>. Эти методы помогают быть более лаконичным, чем использование match при работе со значениями Result<T, E> в вашем коде.

Например, вот другой способ написать ту же логику, что показана в Листинге 9-5, но с использованием замыканий и метода unwrap_or_else:

use std::fs::File;
use std::io::ErrorKind;

fn main() {
    let f = File::open("hello.txt").unwrap_or_else(|error| {
        if error.kind() == ErrorKind::NotFound {
            File::create("hello.txt").unwrap_or_else(|error| {
                panic!("Problem creating the file: {:?}", error);
            })
        } else {
            panic!("Problem opening the file: {:?}", error);
        }
    });
}

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

после прочтения главы 13 и поищите метод unwrap_or_else в документации по стандартной библиотеке. Множество других подобных методов могут очистить огромные вложенные выражения match, когда вы имеете дело с ошибками.

Лаконичные способы обработки ошибок - unwrap и expect

Использование match работает достаточно хорошо, но может быть довольно многословным и не всегда хорошо передаёт смысл. Тип Result<T, E> имеет множество вспомогательных методов для выполнения различных, более специфических задач. Метод unwrap - это метод быстрого доступа к значениям, реализованный так же, как и выражение match, которое мы написали в Листинге 9-4. Если значение Result является вариантом Ok, unwrap возвращает значение внутри Ok. Если Result - вариант Err, то unwrap вызовет для нас макрос panic!. Вот пример unwrap в действии:

Файл: src/main.rs

use std::fs::File;

fn main() {
    let greeting_file = File::open("hello.txt").unwrap();
}

Если мы запустим этот код при отсутствии файла hello.txt , то увидим сообщение об ошибке из вызова panic! метода unwrap :

thread 'main' panicked at 'called `Result::unwrap()` on an `Err` value: Error {
repr: Os { code: 2, message: "No such file or directory" } }',
src/libcore/result.rs:906:4

Другой метод, похожий на unwrap, это expect, позволяющий указать сообщение об ошибке для макроса panic!. Использование expect вместо unwrap с предоставлением хорошего сообщения об ошибке выражает ваше намерение и делает более простым отслеживание источника паники. Синтаксис метода expect выглядит так:

Файл: src/main.rs

use std::fs::File;

fn main() {
    let greeting_file = File::open("hello.txt")
        .expect("hello.txt should be included in this project");
}

Мы используем expect таким же образом, как и unwrap: чтобы вернуть дескриптор файла или вызвать макрос panic!. Сообщением об ошибке, которое expect передаст в panic!, будет параметр функции expect, а не значение по умолчанию, используемое unwrap. Вот как оно выглядит:

thread 'main' panicked at 'Failed to open hello.txt: Error { repr: Os { code:
2, message: "No such file or directory" } }', src/libcore/result.rs:906:4

Так как сообщение об ошибке начинается с нашего пользовательского текста: Failed to open hello.txt, то потом будет проще найти из какого места в коде данное сообщение приходит. Если использовать unwrap во множестве мест, то придётся потратить время для выяснения какой именно вызов unwrap вызывает "панику", так как все вызовы unwrap генерируют одинаковое сообщение.

Проброс ошибок

Когда вы пишете функцию, реализация которой вызывает что-то, что может завершиться ошибкой, вместо обработки ошибки в этой функции, вы можете вернуть ошибку в вызывающий код, чтобы он мог решить, что с ней делать. Такой приём известен как распространение ошибки (propagating the error). Благодаря нему мы даём больше контроля вызывающему коду, где может быть больше информации или логики, которая диктует, как ошибка должна обрабатываться, чем было бы в месте появления этой ошибки.

Например, код программы 9-6 читает имя пользователя из файла. Если файл не существует или не может быть прочтён, то функция возвращает ошибку в код, который вызвал данную функцию:

Файл: src/main.rs


#![allow(unused)]
fn main() {
use std::fs::File;
use std::io::{self, Read};

fn read_username_from_file() -> Result<String, io::Error> {
    let username_file_result = File::open("hello.txt");

    let mut username_file = match username_file_result {
        Ok(file) => file,
        Err(e) => return Err(e),
    };

    let mut username = String::new();

    match username_file.read_to_string(&mut username) {
        Ok(_) => Ok(username),
        Err(e) => Err(e),
    }
}
}

Листинг 9-6: Функция, которая возвращает ошибки в вызывающий код, используя оператор match

Данную функцию можно записать гораздо короче. Чтобы больше проникнуться обработкой ошибок, мы сначала сделаем многое самостоятельно, а в конце покажем более короткий способ. Давайте сначала рассмотрим тип возвращаемого значения: Result<String, io::Error>. Здесь есть возвращаемое значение функции типа Result<T, E> где шаблонный параметр T был заполнен конкретным типом String и шаблонный параметр E был заполнен конкретным типом io::Error. Если эта функция выполнится успешно, будет возвращено Ok, содержащее значение типа String - имя пользователя прочитанное функцией из файла. Если же при чтении файла будут какие-либо проблемы, то вызываемый код получит значение Err с экземпляром io::Error, в котором содержится больше информации об ошибке. Мы выбрали io::Error в качестве возвращаемого значения функции, потому что обе операции, которые мы вызываем внутри этой функции, возвращают этот тип ошибки: функция File::open и метод read_to_string.

Тело функции начинается с вызова File::open. Затем мы обрабатываем значение Result извлечённое с помощью match аналогично коду match листинга 9-4, но вместо вызова panic! для случая Err делаем ранний возврат из данной функции и передаём ошибку из File::open обратно в вызывающий код, как ошибку уже текущей функции. Если File::open выполнится успешно, мы сохраняем дескриптор файла в переменной f и выполнение продолжается далее.

Так если мы создаём новую String в переменной s и вызываем метод read_to_string у дескриптора файла в переменной f, чтобы считать содержимое файла в переменную s. Метод read_to_string также возвращает Result, потому что он может потерпеть неудачу, даже если File::open пройдёт успешно. Таким образом, нам нужно ещё одно выражение match, чтобы справиться с этим Result: если read_to_string выполнится успешно, то наша функция завершится успешно и мы вернём имя пользователя из файла, которое сейчас находится в s, завёрнутым в Ok. Если вызов read_to_string не успешен, мы возвращаем значение ошибки так же, как мы вернули значение ошибки в match, обработавшем возвращаемое значение File::open. Тем не менее, нам не нужно явно писать return, потому что это последнее выражение в функции.

Затем код, вызывающий этот, будет обрабатывать получение либо значения Ok, содержащего имя пользователя, либо значения Err, содержащего io::Error. Вызывающий код должен решить, что делать с этими значениями. Если вызывающий код получает значение Err, он может вызвать panic! и завершить работу программы, использовать имя пользователя по умолчанию или найти имя пользователя, например, не в файле. У нас недостаточно информации о том, что на самом деле пытается сделать вызывающий код, поэтому мы распространяем всю информацию об успехах или ошибках вверх, чтобы она могла обрабатываться соответствующим образом.

Такая схема распространения ошибок настолько распространена в Rust, что Rust предоставляет оператор вопросительный знак ? для простоты.

Сокращение для проброса ошибок: оператор ?

Код программы 9-6 показывает реализацию функции read_username_from_file, функционал которой аналогичен коду программы 9-5, но реализация использует оператор ?:

Файл: src/main.rs


#![allow(unused)]
fn main() {
use std::fs::File;
use std::io;
use std::io::Read;

fn read_username_from_file() -> Result<String, io::Error> {
    let mut username_file = File::open("hello.txt")?;
    let mut username = String::new();
    username_file.read_to_string(&mut username)?;
    Ok(username)
}
}

Листинг 9-7: Функция, которая возвращает ошибки в вызывающий код, используя оператор ?

Оператор ?, помещённый следом после значения типа Result, работает практически таким же образом, как выражение match, которое мы определили для обработки значений типа Result в листинге 9-6. Если значение Result равно Ok, значение внутри Ok будет возвращено из этого выражения и программа продолжит выполнение. Если значение является Err, то Err будет возвращено из всей функции, как если бы мы использовали ключевое слово return, таким образом ошибка передаётся в вызывающий код.

Имеется разница между тем, что делает выражение match листинга 9-6 и оператором ?. Ошибочные значения при выполнении методов с оператором ? возвращаются через функцию from, определённую в типаже From стандартной библиотеки. Данный типаж используется для конвертирования ошибок одного типа в ошибки другого типа. Когда оператор ? вызывает функцию from, то полученный тип ошибки конвертируется в тип ошибки, который определён для возврата в текущей функции. Удобно, когда функция возвращает один тип ошибки для представления всех возможных вариантов, из-за которых она может не завершиться успешно, если части кода этой функции могут не выполниться по разным причинам. Если каждый тип ошибки реализует функцию from определяя, как конвертировать себя в возвращаемый тип ошибки, то оператор ? позаботится об этой конвертации автоматически.

В коде примера 9-7 оператор ? в конце вызова функции File::open возвращает значения содержимого Ok в переменную f. Если же в при работе этой функции произошла ошибка, оператор ? произведёт ранний возврат из функции со значением Err. То же касается ? в конце вызова read_to_string.

Использование оператора ? позволят уменьшить количество строк кода и сделать реализацию проще. Написанный в предыдущем примере код можно сделать ещё короче с помощью сокращения промежуточных переменных и конвейерного вызова нескольких методов подряд, как показано в листинге 9-8:

Файл: src/main.rs


#![allow(unused)]
fn main() {
use std::fs::File;
use std::io;
use std::io::Read;

fn read_username_from_file() -> Result<String, io::Error> {
    let mut username = String::new();

    File::open("hello.txt")?.read_to_string(&mut username)?;

    Ok(username)
}
}

Листинг 9-8: Цепочка вызовов методов после оператора ?

Мы перенесли в начало функции создание новой переменной s типа String; эта часть не изменилась. Вместо создания переменной f мы добавили вызов read_to_string непосредственно к результату File::open("hello.txt")?, У нас ещё есть ? в конце вызова read_to_string, и мы по-прежнему возвращаем значение Ok, содержащее имя пользователя в s когда оба метода: File::open и read_to_string успешны, а не возвращают ошибки. Функциональность снова такая же, как в листинге 9-6 и листинге 9-7; это просто другой, более эргономичный способ решения той же задачи.

Продолжая рассматривать разные способы записи данной функции, листинг 9-9 демонстрирует способ сделать её ещё короче.

Файл: src/main.rs


#![allow(unused)]
fn main() {
use std::fs;
use std::io;

fn read_username_from_file() -> Result<String, io::Error> {
    fs::read_to_string("hello.txt")
}
}

Листинг 9-9: Использование fs::read_to_string вместо открытия и чтения файла

Чтение файла в строку довольно распространённая операция, так что стандартная библиотека предоставляет удобную функцию fs::read_to_string, которая открывает файл, создаёт новую String, читает содержимое файла, размещает его в String и возвращает её. Конечно, использование функции fs::read_to_string не даёт возможности объяснить обработку всех ошибок, поэтому мы сначала изучили длинный способ.

Где можно использовать оператор ?

? может использоваться только в функциях, тип возвращаемого значения которых совместим со значением ? используется на. Это потому, что ? оператор определён для выполнения раннего возврата значения из функции таким же образом, как и выражение match, которое мы определили в листинге 9-6. В листинге 9-6 match использовало значение Result, а ответвление с ранним возвратом вернуло значение Err(e). Тип возвращаемого значения функции должен быть Result, чтобы он был совместим с этим return.

В листинге 9-10 давайте посмотрим на ошибку, которую мы получим, если воспользуемся ? оператор в main функции с типом возвращаемого значения, несовместимым с типом используемого нами значения ? на:

use std::fs::File;

fn main() {
    let greeting_file = File::open("hello.txt")?;
}

Листинг 9-10: При попытке использовать ? в функции main, которая возвращает (), код не компилируется

Этот код открывает файл, что может привести к ошибке. Оператор ? обрабатывает Result, возвращаемый File::open, но у самой функции main возвращаемый тип (), а не Result. Когда мы попробуем скомпилировать этот код, то получим следующее сообщение об ошибке:

$ cargo run
   Compiling error-handling v0.1.0 (file:///projects/error-handling)
error[E0277]: the `?` operator can only be used in a function that returns `Result` or `Option` (or another type that implements `FromResidual`)
 --> src/main.rs:4:48
  |
3 | / fn main() {
4 | |     let greeting_file = File::open("hello.txt")?;
  | |                                                ^ cannot use the `?` operator in a function that returns `()`
5 | | }
  | |_- this function should return `Result` or `Option` to accept `?`
  |
  = help: the trait `FromResidual<Result<Infallible, std::io::Error>>` is not implemented for `()`

For more information about this error, try `rustc --explain E0277`.
error: could not compile `error-handling` due to previous error

Эта ошибка указывает на то, что оператор ? разрешено использовать только в функции, которая возвращает Result, Option или другой тип, который реализует FromResidual. Чтобы исправить ошибку, есть два варианта. Первый - изменить тип возвращаемый из функции, чтобы он был совместим со значением, для которого вы используете оператор ?, если у вас нет ограничений, препятствующих этому. Второй заключается в использовании match или одного из методов Result<T, E> для обработки Result<T, E> любым подходящим способом.

В сообщении об ошибке также упоминалось, что ? также может использоваться со значениями Option<T> . Как с использованием ? в Result вы можете использовать только ? on Option в функции, которая возвращает Option . Поведение ? оператор при вызове для Option<T> подобен его поведению при вызове для Result<T, E> : если значение равно None , None будет возвращено раньше из функции в этой точке. Если значение равно Some , значение внутри Some является результирующим значением выражения, и функция продолжает работу. В листинге 9-11 приведён пример функции, которая находит последний символ первой строки заданного текста:

fn last_char_of_first_line(text: &str) -> Option<char> {
    text.lines().next()?.chars().last()
}

fn main() {
    assert_eq!(
        last_char_of_first_line("Hello, world\nHow are you today?"),
        Some('d')
    );

    assert_eq!(last_char_of_first_line(""), None);
    assert_eq!(last_char_of_first_line("\nhi"), None);
}

Листинг 9-11: Использование оператора ? с Option<T>

Эта функция возвращает Option<char> , потому что возможно, что там есть символ, но также возможно, что его нет. Этот код принимает аргумент среза text строки и вызывает для него метод lines , который возвращает итератор для строк в строке. Поскольку эта функция хочет проверить первую строку, она вызывает next у итератора, чтобы получить первое значение от итератора. Если text является пустой строкой, этот вызов next вернёт None , и в этом случае мы используем ? чтобы остановить и вернуть None из last_char_of_first_line . Если text не является пустой строкой, next вернёт значение Some , содержащее фрагмент строки первой строки в text .

Символ ? извлекает фрагмент строки, и мы можем вызвать chars для этого фрагмента строки. чтобы получить итератор символов. Нас интересует последний символ в первой строке, поэтому мы вызываем last, чтобы вернуть последний элемент в итераторе. Вернётся Option, потому что возможно, что первая строка пустая - например, если text начинается с пустой строки, но имеет символы в других строках, как в "\nhi". Однако, если в первой строке есть последний символ, он будет возвращён в варианте Some. Оператор ? в середине даёт нам лаконичный способ выразить эту логику, позволяя реализовать функцию в одной строке. Если бы мы не могли использовать оператор ? в Option, нам пришлось бы пришлось бы реализовать эту логику, используя больше вызовов методов или выражение match.

Обратите внимание, что вы можете использовать оператор ? на Result в функции, которая возвращает Result, и вы можете использовать оператор ? на Option в функции, которая возвращает Option, но вы не можете смешивать и сочетать их. Оператор ? не будет автоматически преобразовывать Result в Option или наоборот; в этих случаях, вы можете использовать такие методы, как ok из Result или ok_or из Option для явного преобразования.

До сих пор все функции main, которые мы использовали, возвращали (). Функция main особенная, потому что она является точкой входа и выхода исполняемых программ, и существуют ограничения на то, каким может быть её возвращаемый тип, чтобы программы вели себя так как ожидается.

К счастью, main также может возвращать Result<(), E> . В листинге 9-12 используется код из листинга 9-10, но мы изменили возвращаемый тип main на Result<(), Box<dyn Error>> и добавили возвращаемое значение Ok(()) в конец. Теперь этот код будет скомпилирован:

use std::error::Error;
use std::fs::File;

fn main() -> Result<(), Box<dyn Error>> {
    let greeting_file = File::open("hello.txt")?;

    Ok(())
}

Листинг 9-12: Замена main на return Result<(), E> позволяет использовать оператор ? оператор над значениями Result

Тип Box<dyn Error> — это трейт-объект , о котором мы поговорим в разделе «Использование трейт-объектов, допускающих значения разных типов» в главе 17. На данный момент вы можете читать Box<dyn Error> как «любую ошибку». Использование ? для значения Result в main функции с типом ошибки Box<dyn Error> допускается, так как это позволяет досрочно возвращать любое значение Err .

Когда main функция возвращает Result<(), E> , исполняемый файл завершится со значением 0 , если main вернёт Ok(()) , и выйдет с ненулевым значением, если main вернёт значение Err . Исполняемые файлы, написанные на C, при выходе возвращают целые числа: успешно завершённые программы возвращают целое число 0 , а программы с ошибкой возвращают целое число, отличное от 0 . Rust также возвращает целые числа из исполняемых файлов, чтобы быть совместимым с этим соглашением.

main функция может возвращать любые типы, реализующие std::process::Termination .. На момент написания этой статьи Termination был нестабильной функцией, доступной только в Nightly Rust, поэтому вы пока не можете реализовать её для своих типов в Stable Rust, но, возможно, когда-нибудь сможете!

Теперь, когда мы обсудили детали вызова panic! или возврата Result, давайте вернёмся к тому, как решить, какой из случаев подходит для какой ситуации.

panic! или не panic!

Итак, как принимается решение о том, когда следует вызывать panic!, а когда вернуть Result? При панике код не имеет возможности восстановить своё выполнение. Можно было бы вызывать panic! для любой ошибочной ситуации, независимо от того, имеется ли способ восстановления или нет, но с другой стороны, вы принимаете решение от имени вызывающего вас кода, что ситуация необратима. Когда вы возвращаете значение Result, вы делегируете принятие решения вызывающему коду. Вызывающий код может попытаться выполнить восстановление способом, который подходит в данной ситуации, или же он может решить, что из ошибки в Err нельзя восстановиться и вызовет panic!, превратив вашу исправимую ошибку в неисправимую. Поэтому возвращение Result является хорошим выбором по умолчанию для функции, которая может дать сбой.

В таких ситуация как примеры, прототипы и тесты, более уместно писать код, который паникует вместо возвращения Result. Давайте рассмотрим почему, а затем мы обсудим ситуации, в которых компилятор не может доказать, что ошибка невозможна, но вы, как человек, можете это сделать. Глава будет заканчиваться некоторыми общими руководящими принципами о том, как решить, стоит ли паниковать в коде библиотеки.

Примеры, прототипирование и тесты

Когда вы пишете пример, иллюстрирующий некоторую концепцию, наличие хорошего кода обработки ошибок может сделать пример менее понятным. Понятно, что в примерах вызов метода unwrap, который может привести к панике, является лишь обозначением способа обработки ошибок в приложении, который может отличаться в зависимости от того, что делает остальная часть кода.

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

Если в тесте происходит сбой при вызове метода, то вы бы хотели, чтобы весь тест не прошёл, даже если этот метод не является тестируемой функциональностью. Поскольку вызов panic! это способ, которым тест помечается как провалившийся, использование unwrap или expect - именно то, что нужно.

Случаи, в которых у вас больше информации, чем у компилятора

Также было бы целесообразно вызывать unwrap когда у вас есть какая-то другая логика, которая гарантирует, что Result будет иметь значение Ok, но вашу логику не понимает компилятор. У вас по-прежнему будет значение Result которое нужно обработать: любая операция, которую вы вызываете, все ещё имеет возможность неудачи в целом, хотя это логически невозможно в вашей конкретной ситуации. Если, проверяя код вручную, вы можете убедиться, что никогда не будет вариант с Err, то вполне допустимо вызывать unwrap. Вот пример:

fn main() {
    use std::net::IpAddr;

    let home: IpAddr = "127.0.0.1"
        .parse()
        .expect("Hardcoded IP address should be valid");
}

Мы создаём экземпляр IpAddr, анализируя жёстко закодированную строку. Можно увидеть, что 127.0.0.1 является действительным IP-адресом, поэтому здесь допустимо использование unwrap. Однако наличие жёстко закодированной допустимой строки не меняет тип возвращаемого значения метода parse: мы все ещё получаем значение Result и компилятор все также заставляет нас обращаться с Result так, будто возможен вариант Err. Это потому, что компилятор недостаточно умён, чтобы увидеть, что эта строка всегда действительный IP-адрес. Если строка IP-адреса пришла от пользователя, то она не является жёстко запрограммированной в программе и, следовательно, может привести к ошибке, мы определённо хотели бы обработать Result более надёжным способом.

Руководство по обработке ошибок

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

  • Не корректное состояние — это что-то неожиданное, отличается от того, что может происходить время от времени, например, когда пользователь вводит данные в неправильном формате.
  • Ваш код после этой точки должен полагаться на то, что он не находится в не корректном состоянии, вместо проверок наличия проблемы на каждом этапе.
  • Нет хорошего способа закодировать данную информацию в типах, которые вы используете. Мы рассмотрим пример того, что мы имеем в виду в разделе “Кодирование состояний и поведения на основе типов” главы 17.

Если кто-то вызывает ваш код и передаёт значения, которые не имеют смысла, лучшим выбором может быть вызов panic! для оповещения пользователя библиотеки, что в его коде есть ошибка и он может её исправить. Также panic! подходит, если вы вызываете внешний, неподконтрольный вам код, и он возвращает недопустимое состояние, которое вы не можете исправить.

Однако, когда ожидается сбой, лучше вернуть Result, чем выполнить вызов panic!. В качестве примера можно привести синтаксический анализатор, которому передали неправильно сформированные данные, или HTTP-запрос, возвращающий статус указывающий на то, что вы достигли ограничения на частоту запросов. В этих случаях возврат Result означает, что ошибка является ожидаемой и вызывающий код должен решить, как её обрабатывать.

Когда код выполняет операции над данными, он должен проверить, что они корректны, и паниковать, если это не так. Так рекомендуется делать в основном из соображений безопасности: попытка оперировать некорректными данными может подвергнуть ваш код уязвимости. Это основная причина, по которой стандартная библиотека будет вызывать panic!, если попытаться получить доступ к памяти вне границ массива: доступ к памяти, не относящейся к текущей структуре данных, является известной проблемой безопасности. Функции часто имеют контракты: их поведение гарантируется, только если входные данные отвечают определённым требованиям. Паника при нарушении контракта имеет смысл, потому что это всегда указывает на дефект со стороны вызывающего кода, и это не ошибка, которую вы хотели бы, чтобы вызывающий код явно обрабатывал. На самом деле, нет разумного способа для восстановления вызывающего кода; программисты, вызывающие ваш код, должны исправить свой. Контракты для функции, особенно когда нарушение вызывает панику, следует описать в документации по API функции.

Тем не менее, наличие множества проверок ошибок во всех ваших функциях было бы многословным и раздражительным. К счастью, можно использовать систему типов Rust (следовательно и проверку типов компилятором), чтобы она сделала множество проверок вместо вас. Если ваша функция имеет определённый тип в качестве параметра, вы можете продолжить работу с логикой кода зная, что компилятор уже обеспечил правильное значение. Например, если используется обычный тип, а не тип Option, то ваша программа ожидает наличие чего-то вместо ничего. Ваш код не должен будет обрабатывать оба варианта Some и None: он будет иметь только один вариант для определённого значения. Код, пытающийся ничего не передавать в функцию, не будет даже компилироваться, поэтому ваша функция не должна проверять такой случай во время выполнения. Другой пример - это использование целого типа без знака, такого как u32, который гарантирует, что параметр никогда не будет отрицательным.

Создание пользовательских типов для проверки

Давайте разовьём идею использования системы типов Rust чтобы убедиться, что у нас есть корректное значение, и рассмотрим создание пользовательского типа для валидации. Вспомним игру угадывания числа из Главы 2, в которой наш код просил пользователя угадать число между 1 и 100. Мы никогда не проверяли, что предположение пользователя лежит между этими числами, перед сравнением предположения с загаданным нами числом; мы только проверяли, что оно положительно. В этом случае последствия были не очень страшными: наши сообщения «Слишком много» или «Слишком мало», выводимые в консоль, все равно были правильными. Но было бы лучше подталкивать пользователя к правильным догадкам и иметь различное поведение для случаев, когда пользователь предлагает число за пределами диапазона, и когда пользователь вводит, например, буквы вместо цифр.

Один из способов добиться этого - пытаться разобрать введённое значение как i32, а не как u32, чтобы разрешить потенциально отрицательные числа, а затем добавить проверку для нахождение числа в диапазоне, например, так:

use rand::Rng;
use std::cmp::Ordering;
use std::io;

fn main() {
    println!("Guess the number!");

    let secret_number = rand::thread_rng().gen_range(1..=100);

    loop {
        // --snip--

        println!("Please input your guess.");

        let mut guess = String::new();

        io::stdin()
            .read_line(&mut guess)
            .expect("Failed to read line");

        let guess: i32 = match guess.trim().parse() {
            Ok(num) => num,
            Err(_) => continue,
        };

        if guess < 1 || guess > 100 {
            println!("The secret number will be between 1 and 100.");
            continue;
        }

        match guess.cmp(&secret_number) {
            // --snip--
            Ordering::Less => println!("Too small!"),
            Ordering::Greater => println!("Too big!"),
            Ordering::Equal => {
                println!("You win!");
                break;
            }
        }
    }
}

Выражение if проверяет, находится ли наше значение вне диапазона, сообщает пользователю о проблеме и вызывает continue, чтобы начать следующую итерацию цикла и попросить ввести другое число. После выражения if мы можем продолжить сравнение значения guess с загаданным числом, зная, что guess лежит в диапазоне от 1 до 100.

Однако это не идеальное решение: если бы было чрезвычайно важно, чтобы программа работала только со значениями от 1 до 100, существовало бы много функций, требующих этого, то такая проверка в каждой функции была бы утомительной (и могла бы отрицательно повлиять на производительность).

Вместо этого можно создать новый тип и поместить проверки в функцию создания экземпляра этого типа, не повторяя их везде. Таким образом, функции могут использовать новый тип в своих сигнатурах и быть уверены в значениях, которые им передают. Листинг 9-13 показывает один из способов, как определить тип Guess, чтобы экземпляр Guess создавался только при условии, что функция new получает значение от 1 до 100.


#![allow(unused)]
fn main() {
pub struct Guess {
    value: i32,
}

impl Guess {
    pub fn new(value: i32) -> Guess {
        if value < 1 || value > 100 {
            panic!("Guess value must be between 1 and 100, got {}.", value);
        }

        Guess { value }
    }

    pub fn value(&self) -> i32 {
        self.value
    }
}
}

Листинг 9-13. Тип Guess, который будет создавать экземпляры только для значений от 1 до 100

Сначала мы определяем структуру с именем Guess, которая имеет поле с именем value типа i32, в котором будет храниться число.

Затем мы реализуем ассоциированную функцию new, создающую экземпляры значений типа Guess. Функция new имеет один параметр value типа i32, и возвращает Guess. Код в теле функции new проверяет, что значение value находится между 1 и 100. Если value не проходит эту проверку, мы вызываем panic!, которая оповестит программиста, написавшего вызывающий код, что в его коде есть ошибка, которую необходимо исправить, поскольку попытка создания Guess со значением value вне заданного диапазона нарушает контракт, на который полагается Guess::new. Условия, в которых Guess::new паникует, должны быть описаны в документации к API; мы рассмотрим соглашения о документации, указывающие на возможность появления panic! в документации API, которую вы создадите в Главе 14. Если value проходит проверку, мы создаём новый экземпляр Guess, у которого значение поля value равно значению параметра value, и возвращаем Guess.

Затем мы реализуем метод с названием value, который заимствует self, не имеет других параметров, и возвращает значение типа i32. Этот метод иногда называют извлекатель (getter), потому что его цель состоит в том, чтобы извлечь данные из полей структуры и вернуть их. Этот публичный метод является необходимым, поскольку поле value структуры Guess является приватным. Важно, чтобы поле value было приватным, чтобы код, использующий структуру Guess, не мог устанавливать value напрямую: код снаружи модуля должен использовать функцию Guess::new для создания экземпляра Guess, таким образом гарантируя, что у Guess нет возможности получить value, не проверенное условиями в функции Guess::new.

Функция, которая принимает или возвращает только числа от 1 до 100, может объявить в своей сигнатуре, что она принимает или возвращает Guess, вместо i32, таким образом не будет необходимости делать дополнительные проверки в теле такой функции.

Итоги

Функции обработки ошибок в Rust призваны помочь написанию более надёжного кода. Макрос panic! сигнализирует, что ваша программа находится в состоянии, которое она не может обработать, и позволяет сказать процессу чтобы он прекратил своё выполнение, вместо попытки продолжить выполнение с некорректными или неверными значениями. Перечисление Result использует систему типов Rust, чтобы сообщить, что операции могут завершиться неудачей, и ваш код мог восстановиться. Можно использовать Result, чтобы сообщить вызывающему коду, что он должен обрабатывать потенциальный успех или потенциальную неудачу. Использование panic! и Result правильным образом сделает ваш код более надёжным перед лицом неизбежных проблем.

Теперь, когда вы увидели полезные способы использования обобщённых типов Option и Result в стандартной библиотеке, мы поговорим о том, как работают обобщённые типы и как вы можете использовать их в своём коде.

Обобщённые типы, типажи и время жизни

Каждый язык программирования имеет в своём арсенале эффективные средства борьбы с дублированием кода. В Rust одним из таких инструментов являются обобщённые типы данных - generics. Это абстрактные подставные типы на место которых возможно поставить какой-либо конкретный тип или другое свойство. Когда мы пишем код, мы можем выразить поведение обобщённых типов или их связь с другими обобщёнными типами, не зная какой тип будет использован на их месте при компиляции и запуске кода.

Это подобно тому, как функция принимает на вход параметры с разными заранее неизвестными значениями и запускает на них одинаковый код. Функции могут принимать параметры некоторого "обобщённого" типа вместо конкретного типа, вроде i32 или String. Мы уже использовали такие типы данных в Главе 6 (Option<T>), в Главе 8 (Vec<T> и HashMap<K, V>) и в Главе 9 (Result<T, E>). В этой главе мы рассмотрим, как определить наши собственные типы данных, функции и методы, используя возможности обобщённых типов.

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

Затем вы изучите как использовать типажи (traits) для определения поведения в обобщённом виде. Можно комбинировать типажи с обобщёнными типами для ограничения обобщённого типа только теми типами, которые имеют определённое поведение, в отличии от любых типов.

В конце мы обсудим времена жизни (lifetimes), вариации обобщённых типов, которые дают компилятору информацию о том, как сроки жизни ссылок относятся друг к другу. Времена жизни позволяют одалживать (borrow) значения во многих ситуациях, предоставляя возможность компилятору удостовериться, что ссылки являются корректными.

Удаление дублирования кода с помощью выделения общей функциональности

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

Рассмотрим небольшую программу, которая ищет наибольшее число в списке, как показано в листинге 10-1.

Файл: src/main.rs

fn main() {
    let number_list = vec![34, 50, 25, 100, 65];

    let mut largest = &number_list[0];

    for number in &number_list {
        if number > largest {
            largest = number;
        }
    }

    println!("The largest number is {}", largest);
    assert_eq!(*largest, 100);
}

Листинг 10-1: Код поиска наибольшего числа в списке

Программа сохраняет вектор целых чисел в переменной number_list и помещает первое значение из списка в переменную largest. Далее, итератор проходит по всем элементам списка. Если текущий элемент больше числа сохранённого в переменной largest, то его значение заменяет предыдущее значение в этой переменной. Если текущий элемент меньше или равен "наибольшему" найденному ранее, то значение переменной не изменяется. После полного перебора всех элементов, переменная largest должна содержать наибольшее значение, которое в нашем случае будет равно 100.

Чтобы найти наибольшее число в двух различных списках, мы можем дублировать код листинга 10-1 и использовать такую же логику в двух различных местах программы, как показано в листинге 10-2:

Файл: src/main.rs

fn main() {
    let number_list = vec![34, 50, 25, 100, 65];

    let mut largest = &number_list[0];

    for number in &number_list {
        if number > largest {
            largest = number;
        }
    }

    println!("The largest number is {}", largest);

    let number_list = vec![102, 34, 6000, 89, 54, 2, 43, 8];

    let mut largest = &number_list[0];

    for number in &number_list {
        if number > largest {
            largest = number;
        }
    }

    println!("The largest number is {}", largest);
}

Листинг 10-2: Программа поиска наибольшего числа в двух списках

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

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

В листинге 10-3, мы извлекли код, который находит наибольшее число, в отдельную функцию с именем largest. В отличии от кода в листинге 10-1, который находит наибольшее число только в одном конкретном списке, новая программа может искать наибольшее число в двух разных списках.

Файл: src/main.rs

fn largest(list: &[i32]) -> &i32 {
    let mut largest = &list[0];

    for item in list {
        if item > largest {
            largest = item;
        }
    }

    largest
}

fn main() {
    let number_list = vec![34, 50, 25, 100, 65];

    let result = largest(&number_list);
    println!("The largest number is {}", result);
    assert_eq!(*result, 100);

    let number_list = vec![102, 34, 6000, 89, 54, 2, 43, 8];

    let result = largest(&number_list);
    println!("The largest number is {}", result);
    assert_eq!(*result, 6000);
}

Листинг 10-3: Абстрактный код для поиска наибольшего числа в двух списках.

Функция largest имеет параметр с именем list, который представляет срез любых значений типа i32, которые мы можем передать в неё. В результате вызова функции, код выполнится с конкретными, переданными в неё значениями. Не беспокойтесь о синтаксисе цикла for на данный момент. Мы не ссылаемся здесь на ссылку на i32; мы сопоставляем шаблон и деструктурируем каждый &i32 который получает цикл for по этой причине item будет типа i32 внутри тела цикла. Мы подробно рассмотрим сопоставление с образцом в Главе 18.

Итак, вот шаги выполненные для изменения кода из листинга 10-2 в листинг 10-3:

  1. Определить дублирующийся код.
  2. Извлечь дублирующийся код и поместить в тело функции, определяя входные и выходные значения сигнатуры функции.
  3. Обновить и заменить два участка дублирующегося кода вызовом одной функции.

Далее, мы воспользуемся этими же шагами для обобщённых типов, чтобы различными способами уменьшить дублирование кода. Обобщённые типы позволяют работать над абстрактными типами тем же образом, как тело функции может работать над абстрактным списком list вместо конкретных значений.

Например, у нас есть две функции: одна ищет наибольший элемент внутри среза значений типа i32, а другая внутри среза значений типа char. Как уменьшить такое дублирование? Давайте выяснять!

Обобщённые типы данных

Мы можем использовать обобщённые типы данных для функций или структур, которые затем можно использовать с различными конкретными типами данных. Давайте сначала посмотрим, как объявлять функции, структуры, перечисления и методы, используя обобщённые типы данных. Затем мы обсудим, как обобщённые типы данных влияют на производительность кода.

В объявлении функций

Когда мы объявляем функцию с обобщёнными типами, мы размещаем обобщённые типы в сигнатуре функции, где мы обычно указываем типы данных аргументов и возвращаемое значение. Используя обобщённые типы, мы делаем код более гибким, и предоставляем большую функциональность при вызове нашей функции, предотвращая дублирование кода.

Рассмотрим пример с функцией largest. Листинг 10-4 показывает две функции, каждая из которых находит самое большое значение в срезе своего типа.

Файл: src/main.rs

fn largest_i32(list: &[i32]) -> &i32 {
    let mut largest = &list[0];

    for item in list {
        if item > largest {
            largest = item;
        }
    }

    largest
}

fn largest_char(list: &[char]) -> &char {
    let mut largest = &list[0];

    for item in list {
        if item > largest {
            largest = item;
        }
    }

    largest
}

fn main() {
    let number_list = vec![34, 50, 25, 100, 65];

    let result = largest_i32(&number_list);
    println!("The largest number is {}", result);
    assert_eq!(*result, 100);

    let char_list = vec!['y', 'm', 'a', 'q'];

    let result = largest_char(&char_list);
    println!("The largest char is {}", result);
    assert_eq!(*result, 'y');
}

Листинг 10-4: Две функции, отличающихся только именем и типом обрабатываемых данных

Функция largest_i32 уже встречалась нам: мы извлекли её в листинге 10-3, когда боролись с дублированием кода, она находит наибольшее значение типа i32 в срезе. Функция largest_char находит самое большое значение типа char в срезе. Тело у этих функций одинаковое, поэтому давайте избавимся от дублируемого кода, добавив обобщённые типы данных.

Для параметризации типов данных в новой объявляемой функции, нам нужно дать имя обобщённому типу, также как мы это делаем для аргументов функций. Можно использовать любой идентификатор для имени параметра типа. Но мы будем использовать T, потому что, по соглашению, имена параметров в Rust должны быть короткими (обычно длиной в один символ) и именование типов в Rust делается в нотации CamelCase. Сокращение слова "type" до одной буквы T является стандартным выбором большинства программистов использующих язык Rust.

Когда мы используем параметр в теле функции, мы должны объявить имя параметра в сигнатуре, так компилятор будет знать, что означает имя. Аналогично, когда мы используем имя параметра в сигнатуре функции, мы должны объявить имя параметра раньше, чем мы его используем. Чтобы определить обобщённую функцию largest, поместим объявление имён параметров в треугольные скобки, <>, между именем функции и списком параметров, как здесь:

fn largest<T>(list: &[T]) -> T {

Объявление читается так: функция largest является обобщённой по типу T. Эта функция имеет один параметр с именем list, который является срезом значений с типом данных T. Функция largest возвращает данные такого же типа T.

Листинг 10-5 показывает определение функции largest с использованием обобщённых типов данных в её сигнатуре. Листинг также показывает, как мы можем вызвать функцию со срезом данных типа i32 или char. Данный код пока не будет компилироваться, но мы исправим это к концу раздела.

Файл: src/main.rs

fn largest<T>(list: &[T]) -> &T {
    let mut largest = &list[0];

    for item in list {
        if item > largest {
            largest = item;
        }
    }

    largest
}

fn main() {
    let number_list = vec![34, 50, 25, 100, 65];

    let result = largest(&number_list);
    println!("The largest number is {}", result);

    let char_list = vec!['y', 'm', 'a', 'q'];

    let result = largest(&char_list);
    println!("The largest char is {}", result);
}

Листинг 10-5: определение функции largest с использованием обобщённых типов, но код пока не компилируется

Если мы скомпилируем программу сейчас, мы получим следующую ошибку:

$ cargo run
   Compiling chapter10 v0.1.0 (file:///projects/chapter10)
error[E0369]: binary operation `>` cannot be applied to type `&T`
 --> src/main.rs:5:17
  |
5 |         if item > largest {
  |            ---- ^ ------- &T
  |            |
  |            &T
  |
help: consider restricting type parameter `T`
  |
1 | fn largest<T: std::cmp::PartialOrd>(list: &[T]) -> &T {
  |             ++++++++++++++++++++++

For more information about this error, try `rustc --explain E0369`.
error: could not compile `chapter10` due to previous error

В подсказке упоминается std::cmp::PartialOrd, который является типажом. Мы поговорим про типажи в следующей секции. Сейчас, ошибка в функции largest указывает, что функция не будет работать для всех возможных типов T. Так как мы хотим сравнивать значения типа T в теле функции, то можно использовать только те типы, данные которых можно упорядочить: можем упорядочить, значит можем и сравнить. Для возможности сравнения, стандартная библиотека имеет типаж std::cmp::PartialOrd, который вы можете реализовать для типов (смотрите Дополнение С для большей информации про данный типаж). Вы узнаете, как потребовать чтобы обобщённый тип реализовывал определённый типаж в секции "Типажи как параметры", но сначала давайте рассмотрим другие варианты использования обобщённых типов.

В определении структур

Также можно определять структуры с использованием обобщённых типов в одном или нескольких полях структуры с помощью синтаксиса <>. Листинг 10-6 показывает как определить структуру Point<T>, чтобы хранить поля координат x и y любого типа данных.

Файл: src/main.rs

struct Point<T> {
    x: T,
    y: T,
}

fn main() {
    let integer = Point { x: 5, y: 10 };
    let float = Point { x: 1.0, y: 4.0 };
}

Листинг 10-6: структура Point содержащая поля x и y типа T

Синтаксис использования обобщённых типов в определении структуры такой же как и в определении функции. Сначала мы объявляем имена параметров внутри треугольных скобок сразу после имени структуры. Затем мы можем использовать обобщённые типы в определении структуры на местах, где ранее мы бы указывали конкретные типы.

Так как мы используем только один обобщённый тип данных для определения структуры Point<T>, это определение означает, что структура Point<T> является обобщённой с типом T, и оба поля x и y имеют одинаковый тип, каким бы он типом не являлся. Если мы создадим экземпляр структуры Point<T> со значениями разных типов, как показано в Листинге 10-7, наш код не компилируется.

Файл: src/main.rs

struct Point<T> {
    x: T,
    y: T,
}

fn main() {
    let wont_work = Point { x: 5, y: 4.0 };
}

Листинг 10-7: поля x и y должны быть одного типа, так как они имеют один и тот же обобщённый тип T

В этом примере, когда мы присваиваем целочисленное значение 5 переменной x , мы сообщаем компилятору, что обобщённый тип T будет целым числом для этого экземпляра Point<T>. Затем, когда мы указываем значение 4.0 (имеющее тип отличный от целого числа) для y, который мы определили имеющим тот же тип, что и x, мы получим ошибку несоответствия типов:

$ cargo run
   Compiling chapter10 v0.1.0 (file:///projects/chapter10)
error[E0308]: mismatched types
 --> src/main.rs:7:38
  |
7 |     let wont_work = Point { x: 5, y: 4.0 };
  |                                      ^^^ expected integer, found floating-point number

For more information about this error, try `rustc --explain E0308`.
error: could not compile `chapter10` due to previous error

Чтобы определить структуру Point где оба x и y являются обобщёнными, но могут иметь различные типы, можно использовать несколько параметров обобщённого типа. Например, в листинге 10-8 мы можем изменить определение Point, чтобы оно было общим для типов T и U где x имеет тип T а y имеет тип U.

Файл: src/main.rs

struct Point<T, U> {
    x: T,
    y: U,
}

fn main() {
    let both_integer = Point { x: 5, y: 10 };
    let both_float = Point { x: 1.0, y: 4.0 };
    let integer_and_float = Point { x: 5, y: 4.0 };
}

Листинг 10-8: структура Point<T, U> обобщена для двух типов, так что x и y могут быть значениями разных типов

Теперь разрешены все показанные экземпляры типа Point! В объявлении можно использовать столько много обобщённых параметров типа, сколько хочется, но использование более чем несколько типов делает код трудно читаемым. Когда вам нужно много обобщённых типов в коде, это может указывать на то, что ваш код нуждается в реструктуризации на более мелкие части.

В определениях перечислений

Как и в случае со структурами, можно определить перечисления для хранения обобщённых типов в их вариантах. Давайте ещё раз посмотрим на перечисление Option<T> предоставленное стандартной библиотекой, которое мы использовали в Главе 6:


#![allow(unused)]
fn main() {
enum Option<T> {
    Some(T),
    None,
}
}

Это определение теперь должно иметь больше смысла. Как видите, перечисление Option<T>, которое является обобщённым по типу T и имеет два варианта: Some, который содержит одно значение типа T и вариант None, который не содержит никакого значения. Используя перечисление Option<T>, можно выразить абстрактную концепцию необязательного значения и так как Option<T> является обобщённым, можно использовать эту абстракцию независимо от того, каким будет тип для необязательного значения.

Перечисления также могут использовать в определении несколько обобщённых типов. Определение перечисления Result, которое мы использовали в Главе 9, является таким примером:


#![allow(unused)]
fn main() {
enum Result<T, E> {
    Ok(T),
    Err(E),
}
}

Перечисление Result имеет два обобщённых типа T и E и два варианта: Ok, которое содержит тип T, и Err, которое содержит тип E. Такое определение позволяет использовать перечисление Result везде, где операции могут быть выполнены успешно (возвращая значение типа данных T) или неуспешно (возвращая значение типа данных E). Это то что мы делали в коде листинга 9-2, где при открытии файла заполнялись данные типа T, в примере тип std::fs::File или E тип std::io::Error при ошибке, при каких-либо проблемах открытия файла.

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

В определении методов

Также, как и в Главе 5, можно реализовать методы структур и перечислений с помощью обобщённых типов и их объявлений. Код листинга 10-9 демонстрирует пример добавления метода с названием x в структуру Point<T>, которую мы ранее описали в листинге 10-6.

Файл: src/main.rs

struct Point<T> {
    x: T,
    y: T,
}

impl<T> Point<T> {
    fn x(&self) -> &T {
        &self.x
    }
}

fn main() {
    let p = Point { x: 5, y: 10 };

    println!("p.x = {}", p.x());
}

Листинг 10-9. Реализация метода с именем x у структуры Point, которая будет возвращать ссылку на поле x типа T

Здесь мы определили метод с именем x у Point<T> который возвращает ссылку на данные в поле x.

Обратите внимание, что нужно объявить T сразу после impl, чтобы можно было использовать его для указания, что мы реализуем методы для типа Point<T>. Объявляя T как обобщённый тип после impl, Rust может определить, что тип в угловых скобках у Point - это обобщённый, а не конкретный тип.

Мы могли бы, например, реализовать методы только для экземпляров типа Point<f32> вместо остальных экземпляров Point<T> где используется какой-то другой обобщённый тип. В листинге 10-10 мы реализуем код для конкретного типа f32: здесь мы не объявляем иных блоков impl для других вариантов обобщённого типа после.

Файл: src/main.rs

struct Point<T> {
    x: T,
    y: T,
}

impl<T> Point<T> {
    fn x(&self) -> &T {
        &self.x
    }
}

impl Point<f32> {
    fn distance_from_origin(&self) -> f32 {
        (self.x.powi(2) + self.y.powi(2)).sqrt()
    }
}

fn main() {
    let p = Point { x: 5, y: 10 };

    println!("p.x = {}", p.x());
}

Листинг 10-10: блок impl который применяется только к структуре с конкретным типом для параметра обобщённого типа T

Этот код означает, что тип Point<f32> будет иметь метод с именем distance_from_origin, а другие экземпляры Point<T> где T имеет тип отличный от f32 не будут иметь этого метода. Метод измеряет, насколько далеко наша точка находится от точки с координатами (0,0, 0,0) и использует математические операции, доступные только для типов с плавающей запятой.

Обобщённые типы в определении структуры не всегда являются теми же, которые вы используете в сигнатурах методов этой же структуры. Чтобы сделать пример более понятным, в листинге 10-11 используются обобщённые типы X1 и Y1 для структуры Point и X2 Y2 для метода mixup. Метод создаёт новый Point со значением x из self Point (типа X1) и значением y из другой Point (типа Y2), переданной в качестве параметра.

Файл: src/main.rs

struct Point<X1, Y1> {
    x: X1,
    y: Y1,
}

impl<X1, Y1> Point<X1, Y1> {
    fn mixup<X2, Y2>(self, other: Point<X2, Y2>) -> Point<X1, Y2> {
        Point {
            x: self.x,
            y: other.y,
        }
    }
}

fn main() {
    let p1 = Point { x: 5, y: 10.4 };
    let p2 = Point { x: "Hello", y: 'c' };

    let p3 = p1.mixup(p2);

    println!("p3.x = {}, p3.y = {}", p3.x, p3.y);
}

Листинг 10-11: метод, использующий разные обобщённые типы из определения структуры для которой он определён

В функции main, мы определили тип Point, который имеет i32 для x (со значением 5 ) и тип f64 для y (со значением 10.4 ). Переменная p2 является структурой Point которая имеет строковый срез для x (со значением "Hello") и char для y (со значением c ). Вызов mixup на p1 с аргументом p2 создаст для нас экземпляр структуры p3. Новый экземпляр p3 будет иметь для x тип i32 (потому что x взят из p1), а для y тип char (потому что y взят из p2). Вызов макроса println! выведет p3.x = 5, p3.y = c.

Цель этого примера продемонстрировать ситуацию, в которой одни обобщённые параметры объявлены в impl, а другие в определении метода. Здесь обобщённые параметры X1 и Y1 объявляются после impl, потому что они идут вместе с определением структуры. Обобщённые параметры типа X2 и Y2 объявляются после fn mixup, потому что они относятся только к методу.

Производительность кода использующего обобщённые типы

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

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

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

Давайте посмотрим, как это работает, на примере, который использует перечисление Option<T> из стандартной библиотеки:


#![allow(unused)]
fn main() {
let integer = Some(5);
let float = Some(5.0);
}

Когда Rust компилирует этот код, он выполняет мономорфизацию. Во время этого процесса компилятор считывает значения, которые были использованы у экземпляра Option<T> и определяет два вида Option<T>: один для i32, а другой для f64. Таким образом, он расширяет общее определение Option<T> в Option_i32 и Option_f64, тем самым заменяя обобщённое определение на конкретное.

Мономорфизированная версия кода выглядит следующим образом. Обобщённый Option<T> заменяется конкретными определениями, созданными компилятором:

Файл: src/main.rs

enum Option_i32 {
    Some(i32),
    None,
}

enum Option_f64 {
    Some(f64),
    None,
}

fn main() {
    let integer = Option_i32::Some(5);
    let float = Option_f64::Some(5.0);
}

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

Типажи: определение общего поведения

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

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

Определение типажа

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

Например, скажем есть несколько структур, которые имеют различный тип и различное количество текста: структура NewsArticle, которая содержит новости, напечатанные в различных местах в мире; структура Tweet, которая содержит 280 символьную строку твита и мета-данные, обозначающие является ли твит новым или ответом на другой твит.

Мы хотим создать библиотеку медиа-агрегатора, которая может отображать сводку данных сохранённых в экземплярах структур NewsArticle или Tweet. Чтобы этого достичь, нам необходимо иметь возможность для каждой структуры сделать короткую сводку на основе имеющихся данных: надо, чтобы обе структуры реализовали общее поведение. Мы можем делать такую сводку вызовом метода summarize у экземпляра объекта. Пример листинга 10-12 иллюстрирует определение типажа Summary, который выражает данное поведение:

Файл: src/lib.rs

pub trait Summary {
    fn summarize(&self) -> String;
}

Листинг 10-12: Определение типажа Summary, который содержит поведение предоставленное методом summarize

Здесь мы объявляем типаж с использованием ключевого слова trait, а затем его название, которым является Summary в данном случае. Внутри фигурных скобок объявляются сигнатуры методов, которые описывают поведения типов, реализующих данный типаж, в данном случае поведение определяется только одной сигнатурой метода: fn summarize(&self) -> String.

После сигнатуры метода, вместо предоставления реализации в фигурных в скобках, мы используем точку с запятой. Каждый тип, реализующий данный типаж, должен предоставить своё собственное поведение для данного метода. Компилятор обеспечит, что любой тип содержащий типаж Summary, будет также иметь и метод summarize объявленный с точно такой же сигнатурой.

Типаж может иметь несколько методов в описании его тела: сигнатуры методов перечисляются по одной на каждой строке и должны закачиваться символом ;.

Реализация типажа у типа

Теперь, после того как мы определили желаемое поведение используя типаж Summary, можно реализовать его у типов в нашем медиа-агрегаторе. Листинг 10-13 показывает реализацию типажа Summary у структуры NewsArticle, которая использует для создания сводки в методе summarize заголовок, автора и место публикации статьи. Для структуры Tweet мы определяем реализацию summarize используя пользователя и полный текст твита, полагая содержание твита уже ограниченным 280 символами.

Файл: src/lib.rs

pub trait Summary {
    fn summarize(&self) -> String;
}

pub struct NewsArticle {
    pub headline: String,
    pub location: String,
    pub author: String,
    pub content: String,
}

impl Summary for NewsArticle {
    fn summarize(&self) -> String {
        format!("{}, by {} ({})", self.headline, self.author, self.location)
    }
}

pub struct Tweet {
    pub username: String,
    pub content: String,
    pub reply: bool,
    pub retweet: bool,
}

impl Summary for Tweet {
    fn summarize(&self) -> String {
        format!("{}: {}", self.username, self.content)
    }
}

Код программы 10-13: Реализация типажа Summary для структур NewsArticle и Tweet

Реализация типажа у типа аналогична реализации обычных методов. Разница в том, что после impl мы ставим имя типажа, который мы хотим реализовать, затем используем ключевое слово for, а затем указываем имя типа, для которого мы хотим сделать реализацию типажа. Внутри блока impl мы помещаем сигнатуру метода объявленную в типаже. Вместо добавления точки с запятой в конце, после каждой сигнатуры используются фигурные скобки и тело метода заполняется конкретным поведением, которое мы хотим получить у методов типажа для конкретного типа.

После того, как мы реализовали типаж, можно вызвать его методы у экземпляров NewsArticle и Tweet тем же способом, что и вызов обычных методов, например так:

use aggregator::{Summary, Tweet};

fn main() {
    let tweet = Tweet {
        username: String::from("horse_ebooks"),
        content: String::from(
            "of course, as you probably already know, people",
        ),
        reply: false,
        retweet: false,
    };

    println!("1 new tweet: {}", tweet.summarize());
}

Данный код напечатает: 1 new tweet: horse_ebooks: of course, as you probably already know, people.

Обратите внимание, что поскольку мы определили типаж Summary и типы NewsArticle и Tweet в одном и том же файле lib.rs примера 10-13, все они находятся в одной области видимости. Допустим, что lib.rs предназначен для крейта, который мы назвали aggregator и кто-то ещё хочет использовать функциональность нашего крейта для реализации типажа Summary у структуры, определённой в области видимости внутри их библиотеки. Им нужно будет сначала подключить типаж в их область видимости. Они сделали бы это, указав use aggregator::Summary;, что позволит реализовать Summary для их типа. Типажу Summary также необходимо быть публичным для реализации в других крейтах, потому мы поставили ключевое слово pub перед trait в листинге 10-12.

Одно ограничение, на которое следует обратить внимание при реализации типажей это то, что мы можем реализовать типаж для типа, только если либо типаж, либо тип являются локальным для нашего крейта. Например, можно реализовать типажи из стандартной библиотеки, такие как Display для пользовательского типа Tweet являющимся частью функциональности крейта aggregator, потому что тип Tweet является локальным в крейте aggregator. Мы также можем реализовать типаж Summary для Vec<T> в нашем крейте aggregator, потому что типаж Summary является локальным для крейта aggregator.

Но мы не можем реализовать внешние типажи для внешних типов. Например, мы не можем реализовать функцию Display для Vec<T> в нашем крейте aggregator, потому что и типаж Display и тип Vec<T> определены в стандартной библиотеке, а не локально в нашем крейте aggregator. Это ограничение является частью свойства программы называемое согласованность, а точнее сиротское правило (orphan rule), называемое так, потому что родительский тип не представлен. Это правило гарантирует, что код других людей не может сломать ваш код и наоборот. Без этого правила два крейта могли бы реализовать один типаж для одинакового типа и Rust не будет знать, какой реализацией пользоваться.

Реализация поведения по умолчанию

Иногда полезно иметь поведение по умолчанию для некоторых или всех методов в типаже вместо того, чтобы требовать реализации всех методов в каждом типе, реализующим данный типаж. Затем, когда мы реализуем типаж для определённого типа, можно сохранить или переопределить поведение каждого метода по умолчанию уже внутри типов.

В примере 10-14 показано, как указать строку по умолчанию для метода summarize из типажа Summary вместо определения только сигнатуры метода, как мы сделали в примере 10-12.

Файл: src/lib.rs

pub trait Summary {
    fn summarize(&self) -> String {
        String::from("(Read more...)")
    }
}

pub struct NewsArticle {
    pub headline: String,
    pub location: String,
    pub author: String,
    pub content: String,
}

impl Summary for NewsArticle {}

pub struct Tweet {
    pub username: String,
    pub content: String,
    pub reply: bool,
    pub retweet: bool,
}

impl Summary for Tweet {
    fn summarize(&self) -> String {
        format!("{}: {}", self.username, self.content)
    }
}

Листинг 10-14. Определение типажа Summary с реализацией метода summarize по умолчанию

Для использования реализации по умолчанию при создании сводки у экземпляров NewsArticle вместо определения пользовательской реализации, мы указываем пустой блок impl с impl Summary for NewsArticle {}.

Хотя мы больше не определяем метод summarize непосредственно в NewsArticle, мы предоставили реализацию по умолчанию и указали, что NewsArticle реализует типаж Summary. В результате мы всё ещё можем вызвать метод summarize у экземпляра NewsArticle, например так:

use aggregator::{self, NewsArticle, Summary};

fn main() {
    let article = NewsArticle {
        headline: String::from("Penguins win the Stanley Cup Championship!"),
        location: String::from("Pittsburgh, PA, USA"),
        author: String::from("Iceburgh"),
        content: String::from(
            "The Pittsburgh Penguins once again are the best \
             hockey team in the NHL.",
        ),
    };

    println!("New article available! {}", article.summarize());
}

Этот код печатает New article available! (Read more...) .

Создание реализации по умолчанию для метода summarize не требует от нас изменений чего-либо в реализации Summary для типа Tweet в листинге 10-13. Причина заключается в том, что синтаксис для переопределения реализации по умолчанию является таким же, как синтаксис для реализации метода типажа, который не имеет реализации по умолчанию.

Реализации по умолчанию могут вызывать другие методы в том же типаже, даже если эти другие методы не имеют реализации по умолчанию. Таким образом, типаж может предоставить много полезной функциональности и только требует от разработчиков указывать небольшую его часть. Например, мы могли бы определить типаж Summary имеющий метод summarize_author, реализация которого требуется, а затем определить метод summarize который имеет реализацию по умолчанию, которая внутри вызывает метод summarize_author :

pub trait Summary {
    fn summarize_author(&self) -> String;

    fn summarize(&self) -> String {
        format!("(Read more from {}...)", self.summarize_author())
    }
}

pub struct Tweet {
    pub username: String,
    pub content: String,
    pub reply: bool,
    pub retweet: bool,
}

impl Summary for Tweet {
    fn summarize_author(&self) -> String {
        format!("@{}", self.username)
    }
}

Чтобы использовать такую версию типажа Summary, нужно только определить метод summarize_author, при реализации типажа для типа:

pub trait Summary {
    fn summarize_author(&self) -> String;

    fn summarize(&self) -> String {
        format!("(Read more from {}...)", self.summarize_author())
    }
}

pub struct Tweet {
    pub username: String,
    pub content: String,
    pub reply: bool,
    pub retweet: bool,
}

impl Summary for Tweet {
    fn summarize_author(&self) -> String {
        format!("@{}", self.username)
    }
}

После того, как мы определим summarize_author, можно вызвать summarize для экземпляров структуры Tweet и реализация по умолчанию метода summarize будет вызывать определение summarize_author которое мы уже предоставили. Так как мы реализовали метод summarize_author типажа Summary, то типаж даёт нам поведение метода summarize без необходимости писать код.

use aggregator::{self, Summary, Tweet};

fn main() {
    let tweet = Tweet {
        username: String::from("horse_ebooks"),
        content: String::from(
            "of course, as you probably already know, people",
        ),
        reply: false,
        retweet: false,
    };

    println!("1 new tweet: {}", tweet.summarize());
}

Этот код печатает 1 new tweet: (Read more from @horse_ebooks...) .

Обратите внимание, что невозможно вызвать реализацию по умолчанию из переопределённой реализации того же метода.

Типажи как параметры

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

Например, в листинге 10-13 мы реализовали типаж Summary для типов структур NewsArticle и Tweet. Можно определить функцию notify которая вызывает метод summarize с параметром item, который имеет тип реализующий типаж Summary . Для этого можно использовать синтаксис &impl Trait, например так:

pub trait Summary {
    fn summarize(&self) -> String;
}

pub struct NewsArticle {
    pub headline: String,
    pub location: String,
    pub author: String,
    pub content: String,
}

impl Summary for NewsArticle {
    fn summarize(&self) -> String {
        format!("{}, by {} ({})", self.headline, self.author, self.location)
    }
}

pub struct Tweet {
    pub username: String,
    pub content: String,
    pub reply: bool,
    pub retweet: bool,
}

impl Summary for Tweet {
    fn summarize(&self) -> String {
        format!("{}: {}", self.username, self.content)
    }
}

pub fn notify(item: &impl Summary) {
    println!("Breaking news! {}", item.summarize());
}

Вместо конкретного типа у параметра item указывается ключевое слово impl и имя типажа. Этот параметр принимает любой тип, который реализует указанный типаж. В теле notify мы можем вызывать любые методы у экземпляра item, которые должны быть определены при реализации типажа Summary, например можно вызвать метод summarize. Мы можем вызвать notify и передать в него любой экземпляр NewsArticle или Tweet. Код, который вызывает данную функцию с любым другим типом, таким как String или i32, не будет компилироваться, потому что эти типы не реализуют типаж Summary.

Синтаксис ограничения типажа

Синтаксис impl Trait работает для простых случаев, но на самом деле является синтаксическим сахаром для более длинной формы, которая называется ограничением типажа; это выглядит так:

pub fn notify<T: Summary>(item: &T) {
    println!("Breaking news! {}", item.summarize());
}

Эта более длинная форма эквивалентна примеру в предыдущем разделе, но она более многословна. Мы помещаем объявление параметра обобщённого типа с ограничением типажа после двоеточия внутри угловых скобок.

Синтаксис impl Trait удобен и делает более выразительным код в простых случаях. Синтаксис ограничений типажа может выразить большую сложность в других случаях. Например, у нас может быть два параметра, которые реализуют типаж Summary. Использование синтаксиса impl Trait выглядит следующим образом:

pub fn notify(item1: &impl Summary, item2: &impl Summary) {

Если бы мы хотели, чтобы эта функция позволяла иметь item1 и item2 разных типов, то использование impl Trait было бы уместно (до тех пор, пока оба типа реализуют Summary). Если мы хотим форсировать, чтобы оба параметра имели одинаковый тип, то это можно выразить только с использованием ограничения типажа, например так:

pub fn notify<T: Summary>(item1: &T, item2: &T) {

Обобщённый тип T указан для типов параметров item1 и item2 и ограничивает функцию так, что конкретные значения типов переданные аргументами в item1 и item2 должны быть одинаковыми.

Задание нескольких границ типажей с помощью синтаксиса +

Также можно указать более одного ограничения типажа. Скажем, мы хотели бы использовать в методе notify для параметра item с форматированием отображения, также как метод summarize: для этого мы указываем в определении notify, что item должен реализовывать как типаж Display так и Summary. Мы можем сделать это используя синтаксис +:

pub fn notify(item: &(impl Summary + Display)) {

Синтаксис + также допустим с ограничениями типажа для обобщённых типов:

pub fn notify<T: Summary + Display>(item: &T) {

При наличии двух ограничений типажа, тело метода notify может вызывать метод summarize и использовать {} для форматирования item при его печати.

Более ясные границы типажа с помощью where

Использование слишком большого количества ограничений типажа имеет свои недостатки. Каждый обобщённый тип имеет свои границы типажа, поэтому функции с несколькими параметрами обобщённого типа могут содержать много информации об ограничениях между названием функции и списком её параметров затрудняющих чтение сигнатуры. По этой причине в Rust есть альтернативный синтаксис для определения ограничений типажа внутри предложения where после сигнатуры функции. Поэтому вместо того, чтобы писать так:

fn some_function<T: Display + Clone, U: Clone + Debug>(t: &T, u: &U) -> i32 {

можно использовать предложение where , например так:

fn some_function<T, U>(t: &T, u: &U) -> i32
    where T: Display + Clone,
          U: Clone + Debug
{

Сигнатура этой функции менее загромождена: название функции, список параметров, и возвращаемый тип находятся рядом, а сигнатура не содержит в себе множество ограничений типажа.

Возврат значений типа реализующего определённый типаж

Также можно использовать синтаксис impl Trait в возвращаемой позиции, чтобы вернуть значение некоторого типа реализующего типаж, как показано здесь:

pub trait Summary {
    fn summarize(&self) -> String;
}

pub struct NewsArticle {
    pub headline: String,
    pub location: String,
    pub author: String,
    pub content: String,
}

impl Summary for NewsArticle {
    fn summarize(&self) -> String {
        format!("{}, by {} ({})", self.headline, self.author, self.location)
    }
}

pub struct Tweet {
    pub username: String,
    pub content: String,
    pub reply: bool,
    pub retweet: bool,
}

impl Summary for Tweet {
    fn summarize(&self) -> String {
        format!("{}: {}", self.username, self.content)
    }
}

fn returns_summarizable() -> impl Summary {
    Tweet {
        username: String::from("horse_ebooks"),
        content: String::from(
            "of course, as you probably already know, people",
        ),
        reply: false,
        retweet: false,
    }
}

Используя impl Summary для возвращаемого типа, мы указываем, что функция returns_summarizable возвращает некоторый тип, который реализует типаж Summary без обозначения конкретного типа. В этом случае returns_summarizable возвращает Tweet, но код, вызывающий эту функцию, этого не знает.

Возможность возвращать тип, который определяется только реализуемым им признаком, особенно полезна в контексте замыканий и итераторов, которые мы рассмотрим в Главе 13. Замыкания и итераторы создают типы, которые знает только компилятор или типы, которые очень долго указывать. Синтаксис impl Trait позволяет кратко указать, что функция возвращает некоторый тип, который реализует типаж Iterator без необходимости писать очень длинный тип.

Однако, impl Trait возможно использовать, если возвращаете только один тип. Например, данный код, который возвращает значения или типа NewsArticle или типа Tweet, но в качестве возвращаемого типа объявляет impl Summary, не будет работать:

pub trait Summary {
    fn summarize(&self) -> String;
}

pub struct NewsArticle {
    pub headline: String,
    pub location: String,
    pub author: String,
    pub content: String,
}

impl Summary for NewsArticle {
    fn summarize(&self) -> String {
        format!("{}, by {} ({})", self.headline, self.author, self.location)
    }
}

pub struct Tweet {
    pub username: String,
    pub content: String,
    pub reply: bool,
    pub retweet: bool,
}

impl Summary for Tweet {
    fn summarize(&self) -> String {
        format!("{}: {}", self.username, self.content)
    }
}

fn returns_summarizable(switch: bool) -> impl Summary {
    if switch {
        NewsArticle {
            headline: String::from(
                "Penguins win the Stanley Cup Championship!",
            ),
            location: String::from("Pittsburgh, PA, USA"),
            author: String::from("Iceburgh"),
            content: String::from(
                "The Pittsburgh Penguins once again are the best \
                 hockey team in the NHL.",
            ),
        }
    } else {
        Tweet {
            username: String::from("horse_ebooks"),
            content: String::from(
                "of course, as you probably already know, people",
            ),
            reply: false,
            retweet: false,
        }
    }
}

Возврат либо NewsArticle либо Tweet не допускается из-за ограничений того, как реализован синтаксис impl Trait в компиляторе. Мы рассмотрим, как написать функцию с таким поведением в разделе "Использование объектов типажей, которые разрешены для значений или разных типов" Главы 17.

Исправление кода функции largest с помощью ограничений типажа

Теперь, когда вы знаете, как указать поведение, которое вы хотите использовать для ограничения параметра обобщённого типа, давайте вернёмся к листингу 10-5 и исправим определение функции largest. В прошлый раз мы пытались запустить этот код, но получили ошибку:

$ cargo run
   Compiling chapter10 v0.1.0 (file:///projects/chapter10)
error[E0369]: binary operation `>` cannot be applied to type `&T`
 --> src/main.rs:5:17
  |
5 |         if item > largest {
  |            ---- ^ ------- &T
  |            |
  |            &T
  |
help: consider restricting type parameter `T`
  |
1 | fn largest<T: std::cmp::PartialOrd>(list: &[T]) -> &T {
  |             ++++++++++++++++++++++

For more information about this error, try `rustc --explain E0369`.
error: could not compile `chapter10` due to previous error

В теле функции largest мы хотели сравнить два значения типа T используя оператор больше чем ( > ). Так как этот оператор определён у типажа std::cmp::PartialOrd из стандартной библиотеки как метод по умолчанию, то нам нужно указать PartialOrd в качестве ограничения для типа T: благодаря этому функция largest сможет работать со срезами любого типа, значения которого мы можем сравнить. Нам не нужно подключать PartialOrd в область видимости, потому что он есть в авто-импорте. Изменим сигнатуру largest, чтобы она выглядела так:

{{#rustdoc_include ../listings/ch10-generic-types-traits-and-lifetimes/no-listing-07-fixing-listing-10-05/src/main.rs:here}}

На этот раз при компиляции кода мы получаем другой набор ошибок:

{{#include ../listings/ch10-generic-types-traits-and-lifetimes/no-listing-07-fixing-listing-10-05/output.txt}}

Ключевая строка в этой ошибке cannot move out of type [T], a non-copy slice. В нашей необобщённой версии функции largest мы пытались найти самый большой элемент только для типа i32 или char. Как обсуждалось в разделе "Данные только для стека: Копирование" Главы 4, типы подобные i32 и char, имеющие известный размер, могут храниться в стеке, поэтому они реализуют типаж Copy. Но когда мы сделали функцию largest обобщённой, для параметра list стало возможным иметь типы, которые не реализуют типаж Copy. Следовательно, мы не сможем переместить значение из переменной list[0] в переменную largest, в результате чего появляется эта ошибка.

Чтобы вызывать этот код только с теми типами, которые реализуют типаж Copy, можно добавить типаж Copy в список ограничений типа T! Листинг 10-15 показывает полный код обобщённой функции largest, которая будет компилироваться, пока типы значений среза передаваемых в функцию, реализуют одновременно типажи PartialOrd и Copy, как это делают i32 и char.

Файл: src/main.rs


#![allow(unused)]
fn main() {
{{#rustdoc_include ../listings/ch10-generic-types-traits-and-lifetimes/listing-10-15/src/main.rs}}
}

Листинг 10-15: Объявление функции largest работающей с любыми обобщёнными типами, которые реализуют типажи PartialOrd и Copy

Если мы не хотим ограничить функцию largest типами, которые реализуют типаж Copy, мы можем указать, что T имеет ограничение типажа Clone вместо Copy. Затем мы могли бы клонировать каждое значение в срезе, если бы хотели чтобы функция largest забирала владение. Использование функции clone означает, что потенциально делается больше операций выделения памяти в куче для типов, которые владеют данными в куче, например для String. В то же время стоит помнить о том, что выделение памяти в куче может быть медленным, если мы работаем с большими объёмами данных.

Ещё один способ, который мы могли бы реализовать в largest - это создать функцию возвращающую ссылку на значение T из среза. Если мы изменим возвращаемый тип на &T вместо T, то тем самым изменим тело функции, чтобы она возвращала ссылку, тогда нам были бы не нужны ограничения входных значений типажами Clone или Copy и мы могли бы избежать выделения памяти в куче. Попробуйте реализовать эти альтернативные решения самостоятельно!

Использование ограничений типажа для условной реализации методов

Используя ограничение типажа с блоком impl, который использует параметры обобщённого типа, можно реализовать методы условно, для тех типов, которые реализуют указанный типаж. Например, тип Pair<T> в листинге 10-16 всегда реализует функцию new. Но Pair<T> реализует метод cmp_display только если его внутренний тип T реализует типаж PartialOrd (позволяющий сравнивать) и типаж Display (позволяющий выводить на печать).

Файл: src/lib.rs

{{#rustdoc_include ../listings/ch10-generic-types-traits-and-lifetimes/listing-10-16/src/lib.rs}}

Листинг 10-17: Условная реализация методов у обобщённых типов в зависимости от ограничений типажа

Мы также можем условно реализовать типаж для любого типа, который реализует другой типаж. Реализации типажа для любого типа, который удовлетворяет ограничениям типажа называются общими реализациями и широко используются в стандартной библиотеке Rust. Например, стандартная библиотека реализует типаж ToString для любого типа, который реализует типаж Display. Блок impl в стандартной библиотеке выглядит примерно так:

impl<T: Display> ToString for T {
    // --snip--
}

Поскольку стандартная библиотека имеет эту общую реализацию, то можно вызвать метод to_string определённый типажом ToString для любого типа, который реализует типаж Display. Например, мы можем превратить целые числа в их соответствующие String значения, потому что целые числа реализуют типаж Display:


#![allow(unused)]
fn main() {
let s = 3.to_string();
}

Общие реализации приведены в документации к типажу в разделе "Implementors".

Типажи и ограничения типажей позволяют писать код, который использует параметры обобщённого типа для уменьшения дублирования кода, а также указывая компилятору, что мы хотим обобщённый тип, чтобы иметь определённое поведение. Затем компилятор может использовать информацию про ограничения типажа, чтобы проверить, что все конкретные типы, используемые с нашим кодом, обеспечивают правильное поведение. В динамически типизированных языках мы получили бы ошибку во время выполнения, если бы вызвали метод для типа, который не реализует тип определяемый методом. Но Rust перемещает эти ошибки на время компиляции, поэтому мы вынуждены исправить проблемы, прежде чем наш код начнёт работать. Кроме того, мы не должны писать код, который проверяет своё поведение во время выполнения, потому что это уже проверено во время компиляции. Это повышает производительность без необходимости отказываться от гибкости обобщённых типов.

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

Валидация ссылок при помощи времён жизни

Когда мы говорили о ссылках в разделе "Ссылки и заимствование" Главы 4, мы опустили весьма важную деталь: каждая ссылка в Rust имеет время жизни (lifetime), определяющее область действия, в которой ссылка является действительной. В большинстве случаев, времена жизни выводятся неявно также как у типов. Мы должны явно аннотировать типы, когда возможно выведение нескольких типов. Аналогичным образом, мы должны аннотировать времена жизни, когда времена жизни ссылок могут быть соотнесены несколькими различными способами. Rust требует, чтобы мы аннотировали отношения, используя обобщённые параметры времени жизни для гарантирования того, что реальные ссылки используемые во время выполнения, будут однозначно действительными.

Аннотирование времени жизни — это концепция отсутствующая в большинстве других языков программирования, так что она может показаться незнакомой. Хотя в этой главе мы не будем полностью рассматривать время жизни, мы обсудим распространённые способы, с помощью которых вы можете столкнуться с синтаксисом времени жизни, чтобы вы могли познакомиться с этой концепцией.

Времена жизни предотвращают появление недействительных ссылок

Основная цель времён жизни состоит в том, чтобы предотвратить недействительные ссылки (dangling references), которые приводят к тому, что программа ссылается на данные отличные от данных на которые она должна ссылаться. Рассмотрим программу из листинга 10-17, которая имеет внешнюю и внутреннюю области видимости.

fn main() {
    let r;                // ---------+-- 'a
                          //          |
    {                     //          |
        let x = 5;        // -+-- 'b  |
        r = &x;           //  |       |
    }                     // -+       |
                          //          |
    println!("r: {}", r); //          |
}                         // ---------+

Листинг 10-17: Попытка использования ссылки, значение которой вышло из области видимости

Примечание: примеры в листингах 10-17, 10-18 и 10-24 объявляют переменные без предоставления им начального значения, поэтому переменные существуют во внешней области видимости. На первый взгляд может показаться, что это противоречит отсутствию нулевых (null) значений. Однако, если мы попытаемся использовать переменную, прежде чем дать ей значение, мы получим ошибку во время компиляции, которая показывает, что Rust действительно не позволяет использование нулевых (null) значений.

Внешняя область видимости объявляет переменную с именем r без начального значения, а внутренняя область объявляет переменную с именем x с начальным значением 5. Во внутренней области мы пытаемся установить значение r как ссылку на x. Затем внутренняя область видимости заканчивается и мы пытаемся напечатать значение из r. Этот код не будет скомпилирован, потому что значение на которое ссылается r исчезает из области видимости, прежде чем мы попробуем использовать его. Вот сообщение об ошибке:

{{#include ../listings/ch10-generic-types-traits-and-lifetimes/listing-10-17/output.txt}}

Переменная x «не живёт достаточно долго». Причина в том, что x выйдет из области видимости, когда эта внутренняя область закончится в строке 7. Но r все ещё является действительной во внешней области видимости; поскольку её охват больше, мы говорим, что она «живёт дольше». Если бы Rust позволил такому коду работать, то переменная r бы смогла ссылаться на память, которая была освобождена (в тот момент, когда x вышла из внутренней области видимости) и всё что мы попытались бы сделать с r не работало бы правильно. Так как же Rust определяет, что этот код неверен? Он использует анализатор заимствований.

Анализатор заимствований

Компилятор Rust имеет в своём составе анализатор заимствований, который сравнивает области видимости для определения являются ли все заимствования действительными. В листинге 10-18 показан тот же код, что и в листинге 10-17, но с аннотациями, показывающими времена жизни переменных.

fn main() {
    let x = 5;            // ----------+-- 'b
                          //           |
    let r = &x;           // --+-- 'a  |
                          //   |       |
    println!("r: {}", r); //   |       |
                          // --+       |
}                         // ----------+

Пример 10-18: Описание времён жизни переменных r и x, с помощью идентификаторов времени жизни 'a и 'b

Здесь мы описали время жизни для r с помощью 'a и время жизни x с помощью 'b . Как видите, внутренний блок времени жизни 'b гораздо меньше времени жизни внешнего блока 'a. Во время компиляции Rust сравнивает размер двух времён жизни и видит, что r имеет время жизни 'a, но ссылается на память со временем жизни 'b. Программа отклоняется, потому что 'b короче, чем 'a: объект ссылки не живёт так же долго как сама ссылка на него.

Листинг 10-19 исправляет код так, что в нём нет проблем с недействительными ссылками: он компилируется без ошибок.

fn main() {
    let string1 = String::from("abcd");
    let string2 = "xyz";

    let result = longest(string1.as_str(), string2);
    println!("The longest string is {}", result);
}

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

Здесь переменная x имеет время жизни 'b, которое больше, чем время жизни 'a. Это означает, что переменная r может ссылаться на переменную x потому что Rust знает, что ссылка в переменной r будет всегда действительной до тех пор, пока переменная x является действительной.

После того, как мы на примерах рассмотрели времена жизни ссылок и обсудили как Rust их анализирует, давайте поговорим об обобщённых временах жизни входных параметров и возвращаемых значений функций.

Обобщённые времена жизни в функциях

Давайте напишем функцию, которая возвращает наиболее длинный срез строки из двух. Эта функция принимает два среза строки и вернёт один срез строки. После того как мы реализовали функцию longest, код в листинге 10-20 должен вывести The longest string is abcd.

Файл: src/main.rs

fn main() {
    let string1 = String::from("abcd");
    let string2 = "xyz";

    let result = longest(string1.as_str(), string2);
    println!("The longest string is {}", result);
}

// ANCHOR: here
fn longest(x: &str, y: &str) -> &str {
    if x.len() > y.len() {
        x
    } else {
        y
    }
}
// ANCHOR_END: here

Листинг 10-20: Функция main вызывает функцию longest для поиска наибольшей строки

Обратите внимание, что мы хотим чтобы функция принимала строковые срезы, которые являются ссылками, потому что мы не хотим, чтобы функция longest забирала во владение параметры. Обратитесь к разделу "Строковые фрагменты как параметры" Главы 4 для более подробного обсуждения того, почему параметры используемые в листинге 10-20 выбраны именно таким образом.

Если мы попробуем реализовать функцию longest так, как это показано в листинге 10-22, то программа не будет скомпилирована:

Файл: src/main.rs

fn main() {
    let string1 = String::from("abcd");
    let string2 = "xyz";

    let result = longest(string1.as_str(), string2);
    println!("The longest string is {}", result);
}

fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() {
        x
    } else {
        y
    }
}

Листинг 10-21: Реализация функции longest, которая возвращает наибольший срез строки, но пока не компилируется

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

{{#include ../listings/ch10-generic-types-traits-and-lifetimes/listing-10-21/output.txt}}

Текст показывает, что возвращаемому типу нужен обобщённый параметр времени жизни, потому что Rust не может определить, относится ли возвращаемая ссылка к x или к y. На самом деле, мы тоже не знаем, потому что блок if в теле функции возвращает ссылку на x, а блок else возвращает ссылку на y!

Когда мы определяем функцию longest таким образом, то мы не знаем конкретных значений передаваемых в неё. Поэтому мы не знаем какая из ветвей оператора if или else будет выполнена. Мы также не знаем конкретных времён жизни ссылок, передаваемых в функцию, из-за чего не можем посмотреть на их области видимости, как мы делали в примерах 10-19 и 10-20, чтобы убедиться в том, что возвращаемая ссылка всегда действительна. Анализатор заимствований тоже не может этого определить, потому что не знает как времена жизни переменных x и y соотносятся с временем жизни возвращаемого значения. Мы добавим обобщённый параметр времени жизни, который определит отношения между ссылками, чтобы анализатор зависимостей мог провести анализ ссылок с помощью проверки заимствования.

Синтаксис аннотации времени жизни

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

Аннотации времени жизни имеют немного необычный синтаксис: имена параметров времени жизни должны начинаться с апострофа ', они обычно очень короткие и пишутся в нижнем регистре. Обычно, по умолчанию, большинство людей использует имя 'a. Аннотации параметров времени жизни следуют после символа & и отделяются пробелом от названия ссылочного типа.

Приведём несколько примеров: у нас есть ссылка на i32 без указания времени жизни, ссылка на i32, с временем жизни имеющим имя 'a и изменяемая ссылка на i32, которая тоже имеет время жизни 'a.

&i32        // ссылка
&'a i32     // ссылка с явным временем жизни
&'a mut i32 // изменяемая ссылка с явным временем жизни

Одна аннотация времени жизни сама по себе не имеет большого смысла, потому что эти аннотации призваны сообщить компилятору Rust как соотносятся между собой несколько обобщённых параметров времени жизни. Предположим, что у нас есть функция с параметром first, имеющим ссылочный тип данных &i32 и временем жизни 'a, и вторым параметром second, который также имеет ссылочный тип &i32 со временем жизни 'a. Аннотации времени жизни этих параметров имеют одинаковое имя, что говорит о том, что обе ссылки first и second должны жить одинаково долго.

Аннотации времени жизни в сигнатурах функций

Теперь давайте рассмотрим аннотации времён жизни в контексте функции longest. Как в случае с обобщёнными параметрами типов, необходимо объявить параметры времени жизни внутри угловых скобок между именем функции и списком параметров. Ограничение, которое мы хотим выразить в этой сигнатуре, заключается в том, что время жизни обоих параметров и время жизни возвращаемой ссылки связаны таким образом, что возвращаемая ссылка будет действительной до тех пор, пока верны оба параметра. Мы назовём время жизни 'a а затем добавим его к каждой ссылке, как показано в листинге 10.22.

Файл: src/main.rs

fn main() {
    let string1 = String::from("long string is long");

    {
        let string2 = String::from("xyz");
        let result = longest(string1.as_str(), string2.as_str());
        println!("The longest string is {}", result);
    }
}

fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() {
        x
    } else {
        y
    }
}

Листинг 10-23: В определении функции longest указано, что все ссылки должны иметь одинаковое время жизни обозначенное как 'a

Этот код должен компилироваться и давать желаемый результат, когда мы вызовем его в main функции листинга 10-20.

Сигнатура функции теперь сообщает Rust, что для некоторого времени жизни 'a функция принимает два параметра, оба из которых являются фрагментами строк, которые живут не меньше, чем время жизни 'a. Сигнатура функции также сообщает Rust, что фрагмент строки, возвращаемый функцией, будет жить как минимум столько, сколько длится время жизни 'a. На практике это означает, что время жизни ссылки, возвращаемой самой функцией longest, равно меньшему времени жизни передаваемых в неё ссылок. Именно эти отношения мы хотим, чтобы Rust использовал при анализе этого кода.

Помните, когда мы указываем параметры времени жизни в этой сигнатуре функции, мы не меняем время жизни каких-либо переданных или возвращённых значений. Скорее, мы указываем, что проверка заимствований должна отклонять любые значения, которые не соответствуют этим ограничениям. Обратите внимание, что самой функции longest не нужно точно знать, как долго будут жить x и y, только то, что некоторая область может быть заменена на 'a, которая будет удовлетворять этой сигнатуре.

При аннотировании времени жизни функций, аннотации помещаются в сигнатуру функции, а не в тело функции. Аннотации времени жизни становятся частью контракта функции, как и типы в сигнатуре. Наличие сигнатур функций, содержащих контракт времени жизни, означает, что анализ который делает компилятор Rust, может быть проще. Если есть проблема с тем, как функция аннотируется или как она вызывается, ошибки компилятора могут указывать на часть нашего кода и ограничения более точно. Если вместо этого компилятор Rust сделает больше выводов о том, какими мы предполагали отношения времени жизни, компилятор сможет указать только на использование нашего кода за много шагов от причины проблемы.

Когда мы передаём конкретные ссылки в longest, время жизни, которое заменено на 'a, будет привязано к времени жизни которое является пересечением времени жизни области видимости x с временем жизни области видимости y. Другими словами, обобщённое время жизни 'a получит конкретное время жизни: время равное меньшему из времён жизни x и y. Так как мы аннотировали возвращаемую ссылку тем же параметром времени жизни 'a, то возвращённая ссылка также будет действительна в течение меньшего из времён жизни x и y.

Давайте посмотрим, как аннотации времени жизни ограничивают функцию longest передавая внутрь ссылки, которые имеют разные конкретные времена жизни. Листинг 10-23 является простым примером.

Файл: src/main.rs

fn main() {
    let string1 = String::from("long string is long");
    let result;
    {
        let string2 = String::from("xyz");
        result = longest(string1.as_str(), string2.as_str());
    }
    println!("The longest string is {}", result);
}

fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() {
        x
    } else {
        y
    }
}

Листинг 10-23: Использование функции longest со ссылками на значения типа String, которые имеют разное время жизни

В этом примере переменная string1 действительна до конца внешней области, string2 действует до конца внутренней области видимости и result ссылается на что-то, что является действительным до конца внутренней области видимости. Запустите этот код, и вы увидите что анализатор заимствований разрешает такой код; он скомпилирует и напечатает The longest string is long string is long.

Далее, давайте попробуем пример, который показывает, что время жизни ссылки result должно быть меньшим временем жизни одного из двух аргументов. Мы переместим объявление переменной result наружу из внутренней области видимости, но оставим присвоение значения переменной result в области видимости string2. Затем мы переместим println!, который использует result за пределы внутренней области видимости, после того как внутренняя область видимости закончилась. Код в листинге 10-24 не будет компилироваться.

Файл: src/main.rs

struct ImportantExcerpt<'a> {
    part: &'a str,
}

fn main() {
    let novel = String::from("Call me Ishmael. Some years ago...");
    let first_sentence = novel.split('.').next().expect("Could not find a '.'");
    let i = ImportantExcerpt {
        part: first_sentence,
    };
}

Листинг 10-24: Попытка использования переменной result после выхода string2 за пределы области видимости

Когда мы попытаемся скомпилировать этот код, мы получим ошибку:

{{#include ../listings/ch10-generic-types-traits-and-lifetimes/listing-10-24/output.txt}}

Эта ошибка говорит о том, что если мы хотим использовать result в println!, переменная string2 должна бы быть действительной до конца внешней области видимости. Rust знает об этом, потому что мы аннотировали параметры функции и её возвращаемое значение одинаковым временем жизни 'a.

Как люди, мы можем увидеть, что string1 живёт дольше, чем string2 и следовательно, result будет содержать ссылку на string1. Поскольку string1 ещё не вышла из области видимости, ссылка на string1 будет все ещё действительной в выражении println!. Однако компилятор не видит, что ссылка действительная в этом случае. Мы сказали Rust, что время жизни ссылки, возвращаемой из функции longest, равняется меньшему из времён жизни переданных в неё ссылок. Таким образом, проверка заимствования запрещает код в листинге 10-24, как возможно имеющий недействительную ссылку.

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

Мышление в терминах времён жизни

Правильный способ определения времён жизни зависит от того, что функция делает. Например, если мы изменим реализацию функции longest таким образом, чтобы она всегда возвращала свой первый аргумент вместо самого длинного среза строки, то и не придётся указывать время жизни для параметра y. Этот код компилируется:

Файл: src/main.rs

fn main() {
    let string1 = String::from("abcd");
    let string2 = "efghijklmnopqrstuvwxyz";

    let result = longest(string1.as_str(), string2);
    println!("The longest string is {}", result);
}

fn longest<'a>(x: &'a str, y: &str) -> &'a str {
    x
}

В этом примере мы указали параметр времени жизни 'a для параметра x и возвращаемого значения, но не для параметра y, поскольку параметр y никак не соотносится с параметром x и возвращаемым значением.

При возврате ссылки из функции, параметр времени жизни для возвращаемого типа должен соответствовать параметру времени жизни одного из аргументов. Если возвращаемая ссылка не ссылается на один из параметров то, она должна ссылаться на значение, созданное внутри функции, что приведёт к недействительной ссылке, поскольку значение, на которое она ссылается, выйдет из области видимости в конце функции. Посмотрите на пример реализации функции longest, который не компилируется:

Файл: src/main.rs

fn main() {
    let string1 = String::from("abcd");
    let string2 = "xyz";

    let result = longest(string1.as_str(), string2);
    println!("The longest string is {}", result);
}

fn longest<'a>(x: &str, y: &str) -> &'a str {
    let result = String::from("really long string");
    result.as_str()
}

Здесь, несмотря на то, что мы указали параметр времени жизни 'a для возвращаемого типа, реализация не будет скомпилирована, потому что возвращаемое значение времени жизни совсем не связано с временем жизни параметров. Получаемое сообщение об ошибке:

$ cargo run
   Compiling chapter10 v0.1.0 (file:///projects/chapter10)
error[E0515]: cannot return reference to local variable `result`
  --> src/main.rs:11:5
   |
11 |     result.as_str()
   |     ^^^^^^^^^^^^^^^ returns a reference to data owned by the current function

For more information about this error, try `rustc --explain E0515`.
error: could not compile `chapter10` due to previous error

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

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

Определение времён жизни при объявлении структур

До сих пор мы объявляли структуры, которые содержали не ссылочные типы данных. Структуры могут содержать и ссылочные типы данных, но при этом необходимо добавить аннотацию времени жизни для каждой ссылки в определение структуры. Листинг 10-25 описывает структуру ImportantExcerpt, содержащую срез строковых данных:

Файл: src/main.rs

// ANCHOR: here
fn first_word(s: &str) -> &str {
    let bytes = s.as_bytes();

    for (i, &item) in bytes.iter().enumerate() {
        if item == b' ' {
            return &s[0..i];
        }
    }

    &s[..]
}
// ANCHOR_END: here

fn main() {
    let my_string = String::from("hello world");

    // first_word works on slices of `String`s
    let word = first_word(&my_string[..]);

    let my_string_literal = "hello world";

    // first_word works on slices of string literals
    let word = first_word(&my_string_literal[..]);

    // Because string literals *are* string slices already,
    // this works too, without the slice syntax!
    let word = first_word(my_string_literal);
}

Листинг 10-25. Структура, которая содержит ссылку, поэтому её объявление требует аннотации времени жизни

У структуры имеется одно поле part, хранящее ссылку на срез строки. Как в случае с обобщёнными типами данных, объявляется имя обобщённого параметра времени жизни внутри угловых скобок после имени структуры, чтобы иметь возможность использовать его внутри тела определения структуры. Данная аннотация означает, что экземпляр ImportantExcerpt не может пережить ссылку, которую он содержит в своём поле part.

Функция main здесь создаёт экземпляр структуры ImportantExcerpt, который содержит ссылку на первое предложение типа String принадлежащее переменной novel. Данные в novel существуют до создания экземпляра ImportantExcerpt. Кроме того, novel не выходит из области видимости до тех пор, пока ImportantExcerpt выходит за область видимости, поэтому ссылка в внутри экземпляра ImportantExcerpt является действительной.

Правила неявного выведения времени жизни

Вы изучили, что у каждой ссылки есть время жизни и что нужно указывать параметры времени жизни для функций или структур, которые используют ссылки. Однако в Главе 4 у нас была функция в листинге 4-9, которая снова показана в листинге 10-26, где код собран без аннотаций времени жизни.

Файл: src/lib.rs


#![allow(unused)]
fn main() {
{{#rustdoc_include ../listings/ch10-generic-types-traits-and-lifetimes/listing-10-26/src/main.rs:here}}
}

Листинг 10-26: Функция, которую мы определили в листинге 4-9 компилируется без описания времени жизни параметров, несмотря на то, что входной и возвращаемый тип параметров являются ссылками

Причина, по которой этот код компилируется — историческая. В первых (pre-1.0) версиях Rust этот код не скомпилировался бы, поскольку каждой ссылке нужно было явно назначать время жизни. В те времена, сигнатура функции была бы написана примерно так:

fn first_word<'a>(s: &'a str) -> &'a str {

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

Мы упоминаем этот фрагмент истории Rust, потому что возможно, что в будущем может появиться и будет добавлено больше шаблонов для автоматического выведения времён жизни, которые могут быть добавлены в компилятор и понадобится меньшее количество аннотаций.

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

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

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

Компилятор использует три правила, чтобы выяснить времена жизни имеющиеся у ссылок, когда нет явных аннотаций. Первое правило относится ко времени жизни ввода, второе и третье правила применяются ко временам жизни вывода. Если компилятор доходит до конца проверки трёх правил и всё ещё есть ссылки для которых он не может выяснить время жизни, то компилятор остановится с ошибкой. Эти правила применяются к объявлениям fn, а также impl блоков.

Первое правило говорит, что каждый параметр являющийся ссылкой, получает свой собственный параметр времени жизни. Другими словами, функция с одним параметром получит один параметр времени жизни: fn foo<'a>(x: &'a i32); функция с двумя аргументами получит два различных параметра времени жизни: fn foo<'a, 'b>(x: &'a i32, y: &'b i32), и так далее.

Второе правило говорит, что если существует точно один входной параметр времени жизни, то его время жизни назначается всем выходным параметрам: fn foo<'a>(x: &'a i32) -> &'a i32.

Третье правило о том, что если есть множество входных параметров времени жизни, но один из них является ссылкой &self или &mut self при условии что эта функция является методом структуры или перечисления, то время жизни self назначается временем жизни всем выходным параметрам метода. Это третье правило делает методы намного приятнее для чтения и записи, потому что требуется меньше символов.

Давайте представим, что мы компилятор и применим эти правила, чтобы вывести времена жизни ссылок в сигнатуре функции first_word листинга 10-26. Сигнатура этой функции начинается без объявления времён жизни ссылок:

fn first_word(s: &str) -> &str {

Теперь мы (в качестве компилятора) применим первое правило, утверждающее, что каждый параметр функции получает своё собственное время жизни. Как обычно, мы собираемся назвать его 'a и сигнатура выглядит так:

fn first_word<'a>(s: &'a str) -> &str {

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

fn first_word<'a>(s: &'a str) -> &'a str {

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

Давайте рассмотрим ещё один пример: заголовок функции longest, в котором не было параметров времени жизни в начале работы с листингом 10-21:

fn longest(x: &str, y: &str) -> &str {

Применим первое правило: каждому параметру назначается собственное время жизни. На этот раз у функции есть два параметра, поэтому есть два времени жизни:

fn longest<'a, 'b>(x: &'a str, y: &'b str) -> &str {

Видно, что второе правило не применимо, потому что в сигнатуре указано больше одного входного параметра. Третье правило также не применимо, так как longest — функция, а не метод, следовательно, в ней нет параметра self. Итак, мы прошли все три правила, но так и не смогли вычислить время жизни выходного параметра. Вот почему мы получили ошибку при попытке скомпилировать код листинга 10-21: компилятор работал по правилам неявного выведения времён жизни, но не мог выяснить все времена жизни ссылок в сигнатуре.

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

Аннотация времён жизни в определении методов

Когда мы реализуем методы для структур с временами жизни, синтаксис аннотаций снова схож с аннотациями обобщённых типов данных, как было показано в листинге 10-11. Место объявления времён жизни зависит от того, с чем оно связано — с полем структуры или с аргументами методов и возвращаемыми значениями.

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

В сигнатурах методов внутри блока impl ссылки могут быть привязаны ко времени жизни ссылок в полях структуры или могут быть независимыми. Вдобавок, правила неявного выведения времён жизни часто делают так, что аннотации переменных времён жизни являются необязательными в сигнатурах методов. Рассмотрим несколько примеров использования структуры с названием ImportantExcerpt, которую мы определили в листинге 10-25.

Сначала, воспользуемся методом с именем level где входной параметр является ссылкой на self, а возвращаемое значение i32, не является ссылкой:

struct ImportantExcerpt<'a> {
    part: &'a str,
}

impl<'a> ImportantExcerpt<'a> {
    fn level(&self) -> i32 {
        3
    }
}

impl<'a> ImportantExcerpt<'a> {
    fn announce_and_return_part(&self, announcement: &str) -> &str {
        println!("Attention please: {}", announcement);
        self.part
    }
}

fn main() {
    let novel = String::from("Call me Ishmael. Some years ago...");
    let first_sentence = novel.split('.').next().expect("Could not find a '.'");
    let i = ImportantExcerpt {
        part: first_sentence,
    };
}

Объявление параметра времени жизни находится после impl и его использование после типа структуры является обязательным, но нам не нужно аннотировать время жизни ссылки у self, благодаря первому правилу неявного выведения времён жизни.

Пример применения третьего правила неявного выведения времён жизни:

struct ImportantExcerpt<'a> {
    part: &'a str,
}

impl<'a> ImportantExcerpt<'a> {
    fn level(&self) -> i32 {
        3
    }
}

impl<'a> ImportantExcerpt<'a> {
    fn announce_and_return_part(&self, announcement: &str) -> &str {
        println!("Attention please: {}", announcement);
        self.part
    }
}

fn main() {
    let novel = String::from("Call me Ishmael. Some years ago...");
    let first_sentence = novel.split('.').next().expect("Could not find a '.'");
    let i = ImportantExcerpt {
        part: first_sentence,
    };
}

В этом методе имеется два входных параметра, поэтому Rust применят первое правило и назначает обоим параметрам &self и announcement собственные времена жизни. Далее, поскольку один из параметров является &self, то возвращаемое значение получает время жизни переменой &self и все времена жизни выведены.

Статическое время жизни

Существует ещё одно особенное время жизни, которое мы должны обсудить это 'static, которое означает, что данная ссылка может жить всю продолжительность работы программы. Все строковые литералы по умолчанию имеют время жизни 'static, но мы можем указать его явным образом:


#![allow(unused)]
fn main() {
let s: &'static str = "I have a static lifetime.";
}

Содержание этой строки сохраняется внутри бинарного файла вашей программы и всегда доступно для использования. Следовательно, время жизни всех строковых литералов равно 'static.

Сообщения компилятора об ошибках в качестве решения проблемы могут предлагать вам использовать 'static. Но прежде чем указывать время жизни для ссылки как 'static, подумайте, должна ли данная ссылка всегда быть доступна во время всей работы программы. Большинство таких проблем появляются при попытках создания недействительных ссылок или несовпадения времён жизни. В таких случаях, она может быть решена без указания статического времени жизни 'static.

Обобщённые типы параметров, ограничения типажей и время жизни вместе

Давайте кратко рассмотрим синтаксис задания параметров обобщённых типов, ограничений типажа и времён жизни в одной функции:

fn main() {
    let string1 = String::from("abcd");
    let string2 = "xyz";

    let result = longest_with_an_announcement(
        string1.as_str(),
        string2,
        "Today is someone's birthday!",
    );
    println!("The longest string is {}", result);
}

use std::fmt::Display;

fn longest_with_an_announcement<'a, T>(
    x: &'a str,
    y: &'a str,
    ann: T,
) -> &'a str
where
    T: Display,
{
    println!("Announcement! {}", ann);
    if x.len() > y.len() {
        x
    } else {
        y
    }
}

Это функция longest из листинга 10.22, которая возвращает наибольший из двух фрагментов строки. Но теперь у неё есть дополнительный параметр с именем ann обобщённого типа T, который может быть заполнен любым типом, реализующим типаж Display, как указано в предложении where. Этот дополнительный параметр будет напечатан с использованием {} , поэтому привязка типажа Display необходима. Поскольку время жизни является обобщённым типом, то объявления параметра времени жизни 'a и параметра обобщённого типа T помещаются в один список внутри угловых скобок после имени функции.

Итоги

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

Верите или нет, но в рамках этой темы всё есть ещё чему поучиться: в Главе 17 обсуждаются типажи-объекты, что является ещё одним способом использовать типажи. Существуют также более сложные сценарии с аннотациями времени жизни, которые вам понадобятся только в очень сложных случаях; для этого вам следует прочитать Rust Reference. Далее вы узнаете, как писать тесты на Rust, чтобы убедиться, что ваш код работает так, как должен.

Написание автоматизированных тестов

В своём эссе 1972 года “The Humble Programmer,” Edsger W. Dijkstra сказал, что «Тестирование программы может быть очень эффективным способом показать наличие ошибок, но это безнадёжно неадекватно для показа их отсутствия». Это не значит, что мы не должны пытаться тестировать столько, сколько мы можем!

Правильность в наших программах - это степень, в которой наш код выполняет то для чего он предназначен. Rust разработан с высокой степенью заботы о правильности программ, но правильность сложна и её не легко доказать. Система типов Rust несёт огромную часть этого бремени, но система типов не может обнаружить каждый вид некорректности. Таким образом, Rust включает в себя поддержку написания автоматизированных программных тестов внутри языка.

В качестве примера, скажем, мы пишем функцию с именем add_two которая добавляет 2 к любому числу передаваемому в неё. Сигнатура этой функции принимает параметром целое число и возвращает в результате целое число. Когда мы реализуем и компилируем такую функцию, Rust выполняет все проверки типов и заимствований про которые вы уже узнали, чтобы убедиться, например, что мы не передаём значение String или неверную ссылку в эту функцию. Но Rust не может проверить, что данная функция будет делать именно то, что мы намереваемся получить, что она возвращает входной параметр плюс 2, а не скажем, параметр плюс 10 или параметр минус 50! Это то, где тесты приходят на помощь.

Мы можем написать тесты, которые утверждают, например, что когда мы передаём 3 в функцию add_two, возвращаемое значение будет 5. Мы можем запускать эти тесты всякий раз, когда мы вносим изменения в наш код, чтобы убедиться, что любое существующее правильное поведение не изменилось.

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

Как писать тесты

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

  1. Установка любых необходимых данных или состояния.
  2. Запуск кода, который вы хотите проверить.
  3. Утверждение, что результаты являются теми, которые вы ожидаете.

Давайте рассмотрим функции предоставляемые в Rust специально для написания тестов, которые выполнят все эти действия, включая атрибут test, несколько макросов и атрибут should_panic.

Структура тестирующей функции

В простейшем случае в Rust тест - это функция, аннотированная атрибутом test. Атрибуты представляют собой метаданные о фрагментах кода Rust; один из примеров атрибут derive, который мы использовали со структурами в главе 5. Чтобы изменить функцию в тестирующую функцию добавьте #[test] в строку перед fn . Когда вы запускаете тесты командой cargo test, Rust создаёт бинарный модуль выполняющий функции аннотированные атрибутом test и сообщающий о том, прошла успешно или не прошла каждая тестирующая функция.

Когда мы создаём новый проект библиотеки с помощью Cargo, то в нём автоматически генерируется тестовый модуль с тест функцией для нас. Этот модуль поможет вам начать написание ваших тестов, так что вам не нужно искать точную структуру и синтаксис тестовых функций каждый раз, когда вы начинаете новый проект. Вы можете добавить как большее количество дополнительных тестовых функций так и несколько тестовых модулей!

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

Давайте создадим новый проект библиотеки под названием adder:

$ cargo new adder --lib
     Created library `adder` project
$ cd adder

Содержимое файла src/lib.rs вашей библиотеки adder должно выглядеть как в листинге 11-1.

Файл: src/lib.rs

#[cfg(test)]
mod tests {
    #[test]
    fn it_works() {
        let result = 2 + 2;
        assert_eq!(result, 4);
    }
}

Листинг 11-1: Тестовый модуль и функция, сгенерированные автоматически с помощью cargo new

Сейчас проигнорируем первые две строчки кода и сосредоточимся на функции, чтобы увидеть как она работает. Обратите внимание на синтаксис аннотации #[test] перед ключевым словом fn. Этот атрибут сообщает компилятору, что это является заголовком тестирующей функции, так что функционал запускающий тесты на выполнение теперь знает, что это тестирующая функция. Также в составе модуля тестов tests могут быть вспомогательные функции, помогающие настроить и выполнить общие подготовительные операции, поэтому специальная аннотация важна для указания объявления функций тестами с использованием атрибута #[test].

Тело функции использует макрос assert_eq!, чтобы утверждать, что 2 + 2 равно 4. Это утверждение служит примером формата для типичного теста. Давайте запустим, чтобы увидеть, что этот тест проходит.

Команда cargo test выполнит все тесты в выбранном проекте и сообщит о результатах как в листинге 11-2:

$ cargo test
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished test [unoptimized + debuginfo] target(s) in 0.57s
     Running unittests src/lib.rs (target/debug/deps/adder-92948b65e88960b4)

running 1 test
test tests::it_works ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

   Doc-tests adder

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

Листинг 11-2: Вывод информации о работе автоматически сгенерированных тестов

Cargo скомпилировал и выполнил тест. После строк Compiling, Finished и Running мы видим строку running 1 test. Следующая строка показывает имя созданной тест функции с названием it_works и результат её выполнения - ok. Далее вы видите обобщённую информацию о работе всех тестов. Текст test result: ok. означает, что все тесты пройдены успешно и часть вывода 1 passed; 0 failed сообщает общее количество тестов, которые прошли или были ошибочными.

Поскольку у нас нет тестов, которые мы пометили как игнорируемые, в сводке отображается 0 ignored. Мы также не отфильтровывали тесты для выполнения, поэтому конец сводки пишет 0 filtered out. Мы поговорим про игнорирование и фильтрацию тестов в следующем разделе "Контролирование хода выполнения тестов".

Статистика 0 measured предназначена для тестов производительности. На момент написания этой статьи такие тесты доступны только в ночной сборке Rust. Посмотрите документацию о тестах производительности, чтобы узнать больше.

Следующая часть вывода тестов начинается с Doc-tests adder - это информация о тестах в документации. У нас пока нет тестов документации, но Rust может компилировать любые примеры кода, которые находятся в API документации. Такая возможность помогает поддерживать документацию и код в синхронизированном состоянии. Мы поговорим о написании тестов документации в секции "Комментарии документации как тесты" Главы 14. Пока просто проигнорируем часть Doc-tests вывода.

Давайте поменяем название нашего теста и посмотрим что же измениться в строке вывода. Назовём нашу функцию it_works другим именем - exploration:

Файл: src/lib.rs

#[cfg(test)]
mod tests {
    #[test]
    fn exploration() {
        assert_eq!(2 + 2, 4);
    }
}

Снова выполним команду cargo test. Вывод показывает наименование нашей тест функции - exploration вместо it_works:

$ cargo test
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished test [unoptimized + debuginfo] target(s) in 0.59s
     Running unittests src/lib.rs (target/debug/deps/adder-92948b65e88960b4)

running 1 test
test tests::exploration ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

   Doc-tests adder

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

Добавим ещё один тест, но в этот раз специально сделаем так, чтобы этот новый тест не отработал. Тест терпит неудачу, когда что-то паникует в тестируемой функции. Каждый тест запускается в новом потоке и когда главный поток видит, что тестовый поток упал, то помечает тест как завершившийся аварийно. Мы говорили о простейшем способе вызвать панику в главе 9, используя для этого известный макрос panic!. Введём код тест функции another, как в файле src/lib.rs из листинга 11-3.

Файл: src/lib.rs

#[cfg(test)]
mod tests {
    #[test]
    fn exploration() {
        assert_eq!(2 + 2, 4);
    }

    #[test]
    fn another() {
        panic!("Make this test fail");
    }
}

Листинг 11-3: Добавление второго теста, который завершится ошибкой, потому что мы вызываем panic! макрос

Запустим команду cargo test. Вывод результатов показан в листинге 11-4, который сообщает, что тест exploration пройден, а another нет:

$ cargo test
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished test [unoptimized + debuginfo] target(s) in 0.72s
     Running unittests src/lib.rs (target/debug/deps/adder-92948b65e88960b4)

running 2 tests
test tests::another ... FAILED
test tests::exploration ... ok

failures:

---- tests::another stdout ----
thread 'main' panicked at 'Make this test fail', src/lib.rs:10:9
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace


failures:
    tests::another

test result: FAILED. 1 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

error: test failed, to rerun pass '--lib'

Листинг 11-4. Результаты теста, когда один тест пройден, а другой нет

Вместо ok, строка test tests::another сообщает FAILED. У нас есть два новых раздела между результатами и итогами. Первый раздел показывает детальную причину ошибки каждого теста. В данном случае тест another не сработал, потому что panicked at 'Make this test fail', произошло в строке 10 файла src/lib.rs. В следующем разделе перечисляют имена всех не пройденных тестов, что удобно, когда тестов очень много и есть много деталей про аварийное завершение. Мы можем использовать имя не пройденного теста для его дальнейшей отладки; мы больше поговорим о способах запуска тестов в разделе "Контролирование хода выполнения тестов".

Итоговая строка отображается в конце: общий результат нашего тестирования FAILED. У нас один тест пройден и один тест завершён аварийно.

Теперь, когда вы увидели, как выглядят результаты теста при разных сценариях, давайте рассмотрим другие макросы полезные в тестах, кроме panic!.

Проверка результатов с помощью макроса assert!

Макрос assert! доступен из стандартной библиотеки и является удобным, когда вы хотите проверить что некоторое условие в тесте вычисляется в значение true. Внутри макроса assert! переданный аргумент вычисляется в логическое значение. Если оно true, то assert! в тесте ничего не делает и он считается пройденным. Если же значение вычисляется в false, то макрос assert! вызывает макрос panic!, что делает тест аварийным. Использование макроса assert! помогает проверить, что код функционирует как ожидалось.

В главе 5, листинга 5-15, мы использовали структуру Rectangle и метод can_hold, который повторён в листинге 11-5. Давайте поместим этот код в файл src/lib.rs и напишем несколько тестов для него используя assert! макрос.

Файл: src/lib.rs

#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}

impl Rectangle {
    fn can_hold(&self, other: &Rectangle) -> bool {
        self.width > other.width && self.height > other.height
    }
}

Листинг 11-5: Использование структуры Rectangle и её метода can_hold из главы 5

Метод can_hold возвращает логическое значение, что означает, что она является идеальным вариантом использования в макросе assert!. В листинге 11-6 мы пишем тест, который выполняет метод can_hold путём создания экземпляра Rectangle шириной 8 и высотой 7 и убеждаемся, что он может содержать другой экземпляр Rectangle имеющий ширину 5 и высоту 1.

Файл: src/lib.rs

#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}

impl Rectangle {
    fn can_hold(&self, other: &Rectangle) -> bool {
        self.width > other.width && self.height > other.height
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn larger_can_hold_smaller() {
        let larger = Rectangle {
            width: 8,
            height: 7,
        };
        let smaller = Rectangle {
            width: 5,
            height: 1,
        };

        assert!(larger.can_hold(&smaller));
    }
}

Листинг 11-6: Теста для метода can_hold, который проверяет что больший прямоугольник действительно может содержать меньший

Также, в модуле tests обратите внимание на новую добавленную строку use super::*;. Модуль tests является обычным и подчиняется тем же правилам видимости, которые мы обсуждали в главе 7 "Пути для ссылки на элементы внутри дерева модуля". Так как этот модуль tests является внутренним, нужно подключить тестируемый код из внешнего модуля в область видимости внутреннего модуля с тестами. Для этого используется глобальное подключение, так что все что определено во внешнем модуле становится доступным внутри tests модуля.

Мы назвали наш тест larger_can_hold_smaller и создали два нужных экземпляра Rectangle. Затем вызвали макрос assert! и передали результат вызова larger.can_hold(&smaller) в него. Это выражение должно возвращать true, поэтому наш тест должен пройти. Давайте выясним!

$ cargo test
   Compiling rectangle v0.1.0 (file:///projects/rectangle)
    Finished test [unoptimized + debuginfo] target(s) in 0.66s
     Running unittests src/lib.rs (target/debug/deps/rectangle-6584c4561e48942e)

running 1 test
test tests::larger_can_hold_smaller ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

   Doc-tests rectangle

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

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

Файл: src/lib.rs

#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}

impl Rectangle {
    fn can_hold(&self, other: &Rectangle) -> bool {
        self.width > other.width && self.height > other.height
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn larger_can_hold_smaller() {
        // --snip--
        let larger = Rectangle {
            width: 8,
            height: 7,
        };
        let smaller = Rectangle {
            width: 5,
            height: 1,
        };

        assert!(larger.can_hold(&smaller));
    }

    #[test]
    fn smaller_cannot_hold_larger() {
        let larger = Rectangle {
            width: 8,
            height: 7,
        };
        let smaller = Rectangle {
            width: 5,
            height: 1,
        };

        assert!(!smaller.can_hold(&larger));
    }
}

Поскольку правильный результат функции can_hold в этом случае false, то мы должны инвертировать этот результат, прежде чем передадим его в assert! макро. Как результат, наш тест пройдёт, если can_hold вернёт false:

$ cargo test
   Compiling rectangle v0.1.0 (file:///projects/rectangle)
    Finished test [unoptimized + debuginfo] target(s) in 0.66s
     Running unittests src/lib.rs (target/debug/deps/rectangle-6584c4561e48942e)

running 2 tests
test tests::larger_can_hold_smaller ... ok
test tests::smaller_cannot_hold_larger ... ok

test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

   Doc-tests rectangle

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

Два теста работают. Теперь проверим, как отреагируют тесты, если мы добавим ошибку в код. Давайте изменим реализацию метода can_hold заменив одно из логических выражений знак сравнения с "больше чем" на противоположный "меньше чем" при сравнении ширины:

#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}

// --snip--
impl Rectangle {
    fn can_hold(&self, other: &Rectangle) -> bool {
        self.width < other.width && self.height > other.height
    }
}